914 lines
41 KiB
TypeScript
914 lines
41 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
BellRing,
|
||
CheckCircle2,
|
||
Filter,
|
||
LoaderCircle,
|
||
ShieldAlert,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
} from "lucide-react";
|
||
import { aiPmoApi } from "@/api/aiPmo";
|
||
import { notificationsApi } from "@/api/notifications";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { useToast } from "@/components/ui/toast";
|
||
import { buildInboxView, getInboxSectionLabel } from "@/lib/inbox";
|
||
import { getApiErrorMessage } from "@/lib/apiError";
|
||
import { cn } from "@/lib/utils";
|
||
import { toZhPriority } from "@/lib/display";
|
||
import { parseBackendDate } from "@/lib/datetime";
|
||
import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel, InboxSectionKey } from "@/types/inbox";
|
||
|
||
const POLL_INTERVAL_MS = 15000;
|
||
|
||
const STATUS_LABELS: Record<InboxDisplayKind, string> = {
|
||
action_required: "需你决策",
|
||
auto_running: "AI 已接手",
|
||
auto_done: "AI 执行完成",
|
||
info: "仅通知",
|
||
};
|
||
|
||
const STATUS_STYLES: Record<InboxDisplayKind, string> = {
|
||
action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||
auto_running: "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
||
auto_done: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||
info: "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300",
|
||
};
|
||
|
||
const METRIC_CARD_STYLES = [
|
||
"bg-blue-50 text-blue-700",
|
||
"bg-sky-50 text-sky-700",
|
||
"bg-emerald-50 text-emerald-700",
|
||
"bg-amber-50 text-amber-700",
|
||
"bg-violet-50 text-violet-700",
|
||
];
|
||
|
||
const PRIORITY_OPTIONS = [
|
||
{ value: "all", label: "全部优先级" },
|
||
{ value: "high", label: "高优先级" },
|
||
{ value: "medium", label: "中优先级" },
|
||
{ value: "low", label: "低优先级" },
|
||
];
|
||
|
||
function matchesPriority(item: InboxItemViewModel, priorityFilter: string): boolean {
|
||
if (priorityFilter === "all") return true;
|
||
if (priorityFilter === "high") return item.priority_rank >= 3;
|
||
if (priorityFilter === "medium") return item.priority_rank === 2;
|
||
return item.priority_rank <= 1;
|
||
}
|
||
|
||
function buildScopedSections(items: InboxItemViewModel[]) {
|
||
const sectionKeys: InboxSectionKey[] = ["all", "action_required", "auto_running", "auto_done", "info"];
|
||
return sectionKeys.map((key) => ({
|
||
key,
|
||
label: getInboxSectionLabel(key),
|
||
count: key === "all" ? items.length : items.filter((item) => item.display_kind === key).length,
|
||
}));
|
||
}
|
||
|
||
function isToday(value?: string | null) {
|
||
const date = parseBackendDate(value);
|
||
if (!date) return false;
|
||
const now = new Date();
|
||
return (
|
||
date.getFullYear() === now.getFullYear() &&
|
||
date.getMonth() === now.getMonth() &&
|
||
date.getDate() === now.getDate()
|
||
);
|
||
}
|
||
|
||
function formatDetailTime(value?: string | null) {
|
||
const date = parseBackendDate(value);
|
||
if (!date) return value || "-";
|
||
return date.toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function formatJsonBlock(value: unknown): string {
|
||
if (!value) return "暂无";
|
||
try {
|
||
return JSON.stringify(value, null, 2);
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
function buildActionKey(item: InboxItemViewModel, action: InboxActionDescriptor): string {
|
||
return `${item.id}:${action.kind}:${action.label}`;
|
||
}
|
||
|
||
function renderMiniList(title: string, description: string, items: InboxItemViewModel[], onOpen: (item: InboxItemViewModel) => void) {
|
||
return (
|
||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base text-slate-900">{title}</CardTitle>
|
||
<CardDescription className="text-slate-500">{description}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{items.length === 0 ? (
|
||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
||
当前为空
|
||
</div>
|
||
) : (
|
||
items.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className="w-full rounded-xl border border-slate-100 bg-slate-50 px-3 py-3 text-left transition hover:border-slate-200 hover:bg-white"
|
||
onClick={() => onOpen(item)}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<span className="line-clamp-1 text-sm font-semibold text-slate-900">{item.title}</span>
|
||
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[item.display_kind])}>
|
||
{STATUS_LABELS[item.display_kind]}
|
||
</Badge>
|
||
</div>
|
||
<p className="mt-1 line-clamp-2 text-xs text-slate-500">{item.summary}</p>
|
||
</button>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function InboxCard({
|
||
item,
|
||
onAction,
|
||
pendingActionKey,
|
||
}: {
|
||
item: InboxItemViewModel;
|
||
onAction: (item: InboxItemViewModel, action: InboxActionDescriptor) => void;
|
||
pendingActionKey: string | null;
|
||
}) {
|
||
const stageCards = [
|
||
{ label: "当前阶段", value: item.stage_text || STATUS_LABELS[item.display_kind] },
|
||
{ label: "预计下一步", value: item.next_step_text || item.ai_plan || "-" },
|
||
{ label: "撤销状态", value: item.can_undo ? `可撤销,截止 ${item.undo_deadline || "-"}` : "不可撤销" },
|
||
];
|
||
const primaryActionKey = buildActionKey(item, item.primary_action);
|
||
const secondaryActionKey = buildActionKey(item, item.secondary_action);
|
||
const weakActionKey = buildActionKey(item, item.weak_action);
|
||
const primaryPending = pendingActionKey === primaryActionKey;
|
||
const secondaryPending = pendingActionKey === secondaryActionKey;
|
||
const weakPending = pendingActionKey === weakActionKey;
|
||
|
||
return (
|
||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(15,23,42,0.12)]">
|
||
<CardHeader className="space-y-3 pb-3">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[item.display_kind])}>
|
||
{STATUS_LABELS[item.display_kind]}
|
||
</Badge>
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||
{toZhPriority(item.priority)}
|
||
</span>
|
||
{item.project_id && (
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||
{item.project_name || `项目 #${item.project_id}`}
|
||
</span>
|
||
)}
|
||
{item.dedupe_count > 1 && (
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||
重复 {item.dedupe_count} 次
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-xs text-slate-400">{item.updated_at || item.created_at || "-"}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="space-y-1">
|
||
<CardTitle className="text-base text-slate-900">{item.title}</CardTitle>
|
||
<CardDescription className="line-clamp-2 text-slate-500">{item.summary}</CardDescription>
|
||
</div>
|
||
<BellRing className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{item.ai_plan && (
|
||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">AI 已做/将做什么</div>
|
||
<p className="mt-2 text-sm leading-6 text-slate-700">{item.ai_plan}</p>
|
||
</div>
|
||
)}
|
||
|
||
{item.blocking_reason && (
|
||
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-amber-700">为什么需要你</div>
|
||
<p className="mt-2 text-sm leading-6 text-amber-900/80">{item.blocking_reason}</p>
|
||
</div>
|
||
)}
|
||
|
||
{(item.stage_text || item.next_step_text || item.can_undo) && (
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
{stageCards.map((stage) => (
|
||
<div key={stage.label} className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">{stage.label}</div>
|
||
<p className="mt-2 text-sm text-slate-700">{stage.value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||
disabled={primaryPending}
|
||
onClick={() => onAction(item, item.primary_action)}
|
||
>
|
||
{primaryPending ? "处理中…" : item.primary_action.label}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||
disabled={secondaryPending}
|
||
onClick={() => onAction(item, item.secondary_action)}
|
||
>
|
||
{secondaryPending ? "加载中…" : item.secondary_action.label}
|
||
</Button>
|
||
{item.weak_action.kind === "none" ? (
|
||
<span className="px-3 py-2 text-sm text-slate-400">{item.weak_action.label}</span>
|
||
) : (
|
||
<Button
|
||
variant="ghost"
|
||
className="text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||
disabled={weakPending}
|
||
onClick={() => onAction(item, item.weak_action)}
|
||
>
|
||
{weakPending ? "处理中…" : item.weak_action.label}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export default function InboxPage() {
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const { toast } = useToast();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const [notificationDetailItem, setNotificationDetailItem] = useState<InboxItemViewModel | null>(null);
|
||
const [workItemDetailItem, setWorkItemDetailItem] = useState<InboxItemViewModel | null>(null);
|
||
const [pendingActionKey, setPendingActionKey] = useState<string | null>(null);
|
||
const [priorityFilter, setPriorityFilter] = useState("all");
|
||
const [onlyDecision, setOnlyDecision] = useState(false);
|
||
const [onlyUnread, setOnlyUnread] = useState(false);
|
||
const activeSection = (searchParams.get("section") as InboxSectionKey) || "all";
|
||
const projectFilter = searchParams.get("project_id") || "all";
|
||
const searchValue = searchParams.get("q") || "";
|
||
|
||
const notificationsQuery = useQuery({
|
||
queryKey: ["notifications", "inbox"],
|
||
queryFn: () => notificationsApi.list({ limit: 60 }),
|
||
refetchInterval: POLL_INTERVAL_MS,
|
||
});
|
||
|
||
const workItemsQuery = useQuery({
|
||
queryKey: ["ai-pmo-work-items", "inbox"],
|
||
queryFn: () => aiPmoApi.listWorkItems({ limit: 60 }),
|
||
refetchInterval: POLL_INTERVAL_MS,
|
||
});
|
||
|
||
const workItemDetailQuery = useQuery({
|
||
queryKey: ["ai-pmo-work-item-detail", workItemDetailItem?.source_work_item?.id || null],
|
||
queryFn: () => aiPmoApi.getWorkItem(Number(workItemDetailItem?.source_work_item?.id)),
|
||
enabled: Boolean(workItemDetailItem?.source_work_item?.id),
|
||
});
|
||
|
||
const inboxData = useMemo(() => {
|
||
return buildInboxView({
|
||
notifications: notificationsQuery.data || { items: [], unread_count: 0 },
|
||
workItems: workItemsQuery.data || { items: [] },
|
||
});
|
||
}, [notificationsQuery.data, workItemsQuery.data]);
|
||
|
||
const projectOptions = useMemo(() => {
|
||
const map = new Map<string, string>();
|
||
for (const item of inboxData.items) {
|
||
if (!item.project_id) continue;
|
||
map.set(String(item.project_id), item.project_name || `项目 #${item.project_id}`);
|
||
}
|
||
return Array.from(map.entries()).map(([value, label]) => ({ value, label }));
|
||
}, [inboxData.items]);
|
||
|
||
const projectScopedItems = useMemo(() => {
|
||
return inboxData.items.filter((item) => {
|
||
if (projectFilter === "all") return true;
|
||
return String(item.project_id || "") === projectFilter;
|
||
});
|
||
}, [inboxData.items, projectFilter]);
|
||
|
||
const filteredItems = useMemo(() => {
|
||
const query = searchValue.trim().toLowerCase();
|
||
return projectScopedItems.filter((item) => {
|
||
if (activeSection !== "all" && item.display_kind !== activeSection) return false;
|
||
if (onlyDecision && item.display_kind !== "action_required") return false;
|
||
if (onlyUnread && item.is_read) return false;
|
||
if (!matchesPriority(item, priorityFilter)) return false;
|
||
if (!query) return true;
|
||
return `${item.title} ${item.summary} ${item.ai_plan || ""} ${item.project_name || ""}`.toLowerCase().includes(query);
|
||
});
|
||
}, [activeSection, onlyDecision, onlyUnread, priorityFilter, projectScopedItems, searchValue]);
|
||
|
||
const scopedSections = useMemo(() => buildScopedSections(projectScopedItems), [projectScopedItems]);
|
||
const scopedLeaderSummary = useMemo(() => {
|
||
return {
|
||
decision_count: projectScopedItems.filter((item) => item.display_kind === "action_required").length,
|
||
auto_running_count: projectScopedItems.filter((item) => item.display_kind === "auto_running").length,
|
||
auto_done_today: projectScopedItems.filter(
|
||
(item) => item.display_kind === "auto_done" && isToday(item.updated_at || item.created_at)
|
||
).length,
|
||
high_priority_blocked_count: projectScopedItems.filter(
|
||
(item) => item.display_kind === "action_required" && item.priority_rank >= 3 && Boolean(item.blocking_reason)
|
||
).length,
|
||
external_authorization_count: projectScopedItems.filter(
|
||
(item) =>
|
||
item.display_kind === "action_required" &&
|
||
/(github|gitlab|jira|接入|授权|跨系统|外部系统)/i.test(`${item.title} ${item.summary} ${item.blocking_reason || ""}`)
|
||
).length,
|
||
top_decisions: projectScopedItems.filter((item) => item.display_kind === "action_required").slice(0, 3),
|
||
recent_completions: projectScopedItems.filter((item) => item.display_kind === "auto_done").slice(0, 3),
|
||
};
|
||
}, [projectScopedItems]);
|
||
|
||
const setSection = (section: string) => {
|
||
const next = new URLSearchParams(searchParams);
|
||
next.set("section", section);
|
||
setSearchParams(next);
|
||
};
|
||
|
||
const setProject = (projectId: string) => {
|
||
const next = new URLSearchParams(searchParams);
|
||
if (projectId === "all") next.delete("project_id");
|
||
else next.set("project_id", projectId);
|
||
setSearchParams(next);
|
||
};
|
||
|
||
const setSearch = (value: string) => {
|
||
const next = new URLSearchParams(searchParams);
|
||
if (!value.trim()) next.delete("q");
|
||
else next.set("q", value.trim());
|
||
setSearchParams(next);
|
||
};
|
||
|
||
const refreshAll = async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["ai-pmo-work-items", "inbox"] }),
|
||
]);
|
||
};
|
||
|
||
const markNotificationRead = async (item: InboxItemViewModel) => {
|
||
if (item.source_kind === "notification" && item.source_notification && !item.source_notification.is_read) {
|
||
try {
|
||
await notificationsApi.markRead(Number(item.source_notification.id));
|
||
} catch {
|
||
// 不阻断后续跳转。
|
||
}
|
||
}
|
||
};
|
||
|
||
const openNotificationDetail = async (item: InboxItemViewModel) => {
|
||
await markNotificationRead(item);
|
||
setNotificationDetailItem(item);
|
||
await refreshAll();
|
||
};
|
||
|
||
const openWorkItemDetail = async (item: InboxItemViewModel) => {
|
||
setWorkItemDetailItem(item);
|
||
await refreshAll();
|
||
};
|
||
|
||
const openTarget = async (item: InboxItemViewModel, options?: { forceDirect?: boolean }) => {
|
||
if (!options?.forceDirect && item.source_kind === "notification" && item.display_kind === "info") {
|
||
await openNotificationDetail(item);
|
||
return;
|
||
}
|
||
|
||
await markNotificationRead(item);
|
||
|
||
if (item.target_url) {
|
||
if (item.target_url.startsWith("/")) navigate(item.target_url);
|
||
else window.location.assign(item.target_url);
|
||
await refreshAll();
|
||
return;
|
||
}
|
||
if (item.source_kind === "ai_pmo_work_item") {
|
||
await openWorkItemDetail(item);
|
||
return;
|
||
}
|
||
toast({ title: "当前卡片暂无可打开的详情入口", variant: "error" });
|
||
};
|
||
|
||
const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => {
|
||
const actionKey = buildActionKey(item, action);
|
||
setPendingActionKey(actionKey);
|
||
try {
|
||
if (item.source_kind === "notification" && action.label === "查看详情") {
|
||
await openNotificationDetail(item);
|
||
return;
|
||
}
|
||
if (action.kind === "open_target") {
|
||
await openTarget(item);
|
||
return;
|
||
}
|
||
if (action.kind === "open_detail") {
|
||
if (item.source_kind === "notification") {
|
||
await openNotificationDetail(item);
|
||
return;
|
||
}
|
||
await openWorkItemDetail(item);
|
||
return;
|
||
}
|
||
if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||
action: "approve",
|
||
project_id: item.project_id ?? undefined,
|
||
});
|
||
await refreshAll();
|
||
const followupReply = String(result.followup_reply || "").trim();
|
||
if (result.status === "succeeded") {
|
||
toast({ title: followupReply || "AI PMO 已继续执行并完成该工作项", variant: "success" });
|
||
} else if (result.status === "pending_manual") {
|
||
toast({ title: followupReply || "继续执行后仍需补充信息或人工确认", variant: "default" });
|
||
} else {
|
||
toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" });
|
||
}
|
||
setWorkItemDetailItem({
|
||
...item,
|
||
display_kind:
|
||
result.status === "succeeded"
|
||
? "auto_done"
|
||
: result.status === "pending_manual"
|
||
? "action_required"
|
||
: "info",
|
||
stage_text: String(result.target_state || item.stage_text || "").trim() || item.stage_text,
|
||
next_step_text: String(result.followup_reply || item.next_step_text || "").trim() || item.next_step_text,
|
||
});
|
||
return;
|
||
}
|
||
if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||
action: "reject",
|
||
reason: "人工审批拒绝,已标记失效",
|
||
});
|
||
await refreshAll();
|
||
const followupReply = String(result.followup_reply || "").trim();
|
||
toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" });
|
||
if (workItemDetailItem?.id === item.id) {
|
||
setWorkItemDetailItem(null);
|
||
}
|
||
return;
|
||
}
|
||
if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) {
|
||
await notificationsApi.markRead(item.source_notification.id);
|
||
await refreshAll();
|
||
return;
|
||
}
|
||
if (action.kind === "none") {
|
||
if (item.source_kind === "ai_pmo_work_item") {
|
||
await openWorkItemDetail(item);
|
||
return;
|
||
}
|
||
toast({ title: action.label, variant: "default" });
|
||
}
|
||
} catch (error) {
|
||
toast({ title: getApiErrorMessage(error), variant: "error" });
|
||
} finally {
|
||
setPendingActionKey((current) => (current === actionKey ? null : current));
|
||
}
|
||
};
|
||
|
||
const markAllRead = async () => {
|
||
try {
|
||
await notificationsApi.markAllRead();
|
||
toast({ title: "站内通知已全部设为已读", variant: "success" });
|
||
await refreshAll();
|
||
} catch (error) {
|
||
toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" });
|
||
}
|
||
};
|
||
|
||
const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading;
|
||
|
||
return (
|
||
<>
|
||
<div className="space-y-6 pb-16">
|
||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight text-slate-900">消息台</h1>
|
||
<p className="mt-1 text-slate-500">AI 决策收件箱 · 先看摘要,再处理真正需要你拍板的事项</p>
|
||
<p className="mt-1 text-xs text-slate-500">
|
||
当前结果:{filteredItems.length} 条
|
||
{projectFilter !== "all" ? `(已筛选项目 #${projectFilter})` : "(全租户汇总)"}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
variant="outline"
|
||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||
onClick={markAllRead}
|
||
disabled={notificationsQuery.data?.unread_count === 0}
|
||
>
|
||
全部已读
|
||
</Button>
|
||
<Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => setSection("action_required")}>
|
||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||
先看待决策
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||
AI 决策收件箱
|
||
</span>
|
||
<p className="text-sm font-medium text-slate-700">铃铛只看摘要,完整工作流统一沉到 Inbox</p>
|
||
</div>
|
||
<CardDescription className="mt-2 text-slate-500">
|
||
只保留真正可执行的事项;查询、闲聊、健康提示默认不进入消息台。
|
||
</CardDescription>
|
||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||
{[
|
||
{ icon: ShieldAlert, label: "需你决策", value: scopedLeaderSummary.decision_count },
|
||
{ icon: LoaderCircle, label: "AI处理中", value: scopedLeaderSummary.auto_running_count },
|
||
{ icon: CheckCircle2, label: "今日自动完成", value: scopedLeaderSummary.auto_done_today },
|
||
{ icon: Sparkles, label: "高优先级阻塞", value: scopedLeaderSummary.high_priority_blocked_count },
|
||
{ icon: Filter, label: "待授权外部接入", value: scopedLeaderSummary.external_authorization_count },
|
||
].map(({ icon: Icon, label, value }, index) => (
|
||
<div key={label} className="rounded-xl bg-slate-50 px-3 py-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-xs font-semibold text-slate-500">{label}</div>
|
||
<div className="mt-2 text-2xl font-bold text-slate-900">{value}</div>
|
||
</div>
|
||
<span className={cn("inline-flex h-9 w-9 items-center justify-center rounded-full", METRIC_CARD_STYLES[index])}>
|
||
<Icon className="h-4 w-4" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||
{renderMiniList("最需要你拍板的 3 件事", "优先看高风险、上下文不完整或需要授权的事项。", scopedLeaderSummary.top_decisions, (item) =>
|
||
void handleAction(item, item.primary_action)
|
||
)}
|
||
{renderMiniList("AI 刚完成的 3 件事", "自动结果会优先给出产物和影响范围。", scopedLeaderSummary.recent_completions, (item) =>
|
||
void handleAction(item, item.primary_action)
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||
<div className="rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="w-full min-w-0 sm:min-w-[220px] sm:flex-1">
|
||
<Input
|
||
placeholder="搜索标题、摘要、项目"
|
||
defaultValue={searchValue}
|
||
className="border-slate-200 bg-white text-slate-700 placeholder:text-slate-400"
|
||
onBlur={(event) => setSearch(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value);
|
||
}}
|
||
/>
|
||
</div>
|
||
<select
|
||
value={projectFilter}
|
||
onChange={(event) => setProject(event.target.value)}
|
||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 sm:w-auto"
|
||
>
|
||
<option value="all">全部项目</option>
|
||
{projectOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={priorityFilter}
|
||
onChange={(event) => setPriorityFilter(event.target.value)}
|
||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 sm:w-auto"
|
||
>
|
||
{PRIORITY_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<label className="inline-flex items-center gap-2 rounded-full bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||
<Switch checked={onlyDecision} onCheckedChange={setOnlyDecision} />
|
||
仅需决策
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 rounded-full bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||
<Switch checked={onlyUnread} onCheckedChange={setOnlyUnread} />
|
||
仅未读
|
||
</label>
|
||
</div>
|
||
|
||
<Tabs value={activeSection} onValueChange={setSection}>
|
||
<TabsList className="mt-4 grid h-auto w-full grid-cols-2 gap-2 rounded-2xl bg-slate-50 p-2 md:grid-cols-5">
|
||
{scopedSections.map((section) => (
|
||
<TabsTrigger
|
||
key={section.key}
|
||
value={section.key}
|
||
className="rounded-xl border border-transparent text-slate-600 data-[state=active]:border-slate-200 data-[state=active]:bg-white data-[state=active]:text-slate-900"
|
||
>
|
||
{section.label} {section.count}
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
</Tabs>
|
||
</div>
|
||
</section>
|
||
|
||
{isLoading ? (
|
||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500 shadow-[0_4px_14px_rgba(15,23,42,0.06)]">
|
||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||
加载消息台中…
|
||
</div>
|
||
</section>
|
||
) : filteredItems.length === 0 ? (
|
||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||
<CardContent className="py-12 text-center text-slate-500">
|
||
<BellRing className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||
<p className="font-medium text-slate-700">当前没有匹配收件项</p>
|
||
<p className="mt-1 text-sm">请调整筛选条件后重试</p>
|
||
</CardContent>
|
||
</Card>
|
||
</section>
|
||
) : (
|
||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
{filteredItems.map((item) => (
|
||
<InboxCard
|
||
key={item.id}
|
||
item={item}
|
||
pendingActionKey={pendingActionKey}
|
||
onAction={(target, action) => void handleAction(target, action)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
|
||
<Dialog
|
||
open={Boolean(workItemDetailItem)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setWorkItemDetailItem(null);
|
||
}}
|
||
>
|
||
<DialogContent className="max-h-[90vh] w-[min(720px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>{workItemDetailItem?.title || "工作项详情"}</DialogTitle>
|
||
<DialogDescription>
|
||
{workItemDetailItem?.project_name || (workItemDetailItem?.project_id ? `项目 #${workItemDetailItem.project_id}` : "未绑定项目")}
|
||
{" · "}
|
||
{formatDetailTime(workItemDetailItem?.updated_at || workItemDetailItem?.created_at)}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{workItemDetailItem && (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[workItemDetailItem.display_kind])}>
|
||
{STATUS_LABELS[workItemDetailItem.display_kind]}
|
||
</Badge>
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||
{toZhPriority(workItemDetailItem.priority)}
|
||
</span>
|
||
{workItemDetailItem.source_work_item?.intent && (
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||
{workItemDetailItem.source_work_item.intent}
|
||
</span>
|
||
)}
|
||
{workItemDetailItem.source_work_item?.source_system && (
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||
{workItemDetailItem.source_work_item.source_system}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">工作项摘要</div>
|
||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">
|
||
{workItemDetailItem.summary || "暂无摘要"}
|
||
</p>
|
||
</div>
|
||
|
||
{workItemDetailItem.source_work_item?.blocking_reason && (
|
||
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-amber-700">阻塞原因</div>
|
||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-amber-900/80">
|
||
{workItemDetailItem.source_work_item.blocking_reason}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{workItemDetailItem.source_work_item?.ai_plan && (
|
||
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">AI 执行计划</div>
|
||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">
|
||
{workItemDetailItem.source_work_item.ai_plan}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">当前阶段</div>
|
||
<p className="mt-2 text-sm text-slate-700">
|
||
{workItemDetailItem.source_work_item?.stage_text || STATUS_LABELS[workItemDetailItem.display_kind]}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">下一步</div>
|
||
<p className="mt-2 whitespace-pre-wrap text-sm text-slate-700">
|
||
{workItemDetailItem.source_work_item?.next_step_text || "暂无"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{workItemDetailQuery.isLoading ? (
|
||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500">
|
||
加载工作项详情中…
|
||
</div>
|
||
) : workItemDetailQuery.data ? (
|
||
<>
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">决策记录</div>
|
||
<p className="mt-2 text-sm text-slate-700">{workItemDetailQuery.data.decision_records.length} 条</p>
|
||
</div>
|
||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">执行计划</div>
|
||
<p className="mt-2 text-sm text-slate-700">{workItemDetailQuery.data.plan_bundles.length} 条</p>
|
||
</div>
|
||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">回执记录</div>
|
||
<p className="mt-2 text-sm text-slate-700">{workItemDetailQuery.data.reply_receipts.length} 条</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">执行结果</div>
|
||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-slate-700">
|
||
{formatJsonBlock(workItemDetailQuery.data.execution_result)}
|
||
</pre>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">复盘摘要</div>
|
||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-slate-700">
|
||
{formatJsonBlock(workItemDetailQuery.data.review_summary)}
|
||
</pre>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||
工作项详情加载失败,请稍后重试。
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setWorkItemDetailItem(null)}>
|
||
关闭
|
||
</Button>
|
||
{workItemDetailItem?.target_url && (
|
||
<Button
|
||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||
onClick={() => {
|
||
void openTarget(workItemDetailItem, { forceDirect: true });
|
||
setWorkItemDetailItem(null);
|
||
}}
|
||
>
|
||
进入项目
|
||
</Button>
|
||
)}
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog
|
||
open={Boolean(notificationDetailItem)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setNotificationDetailItem(null);
|
||
}}
|
||
>
|
||
<DialogContent className="max-h-[90vh] w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>{notificationDetailItem?.title || "通知详情"}</DialogTitle>
|
||
<DialogDescription>
|
||
{notificationDetailItem?.project_name || (notificationDetailItem?.project_id ? `项目 #${notificationDetailItem.project_id}` : "未绑定项目")}
|
||
{" · "}
|
||
{formatDetailTime(notificationDetailItem?.updated_at || notificationDetailItem?.created_at)}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{notificationDetailItem && (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[notificationDetailItem.display_kind])}>
|
||
{STATUS_LABELS[notificationDetailItem.display_kind]}
|
||
</Badge>
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||
{toZhPriority(notificationDetailItem.priority)}
|
||
</span>
|
||
{notificationDetailItem.source_notification?.source && (
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||
{notificationDetailItem.source_notification.source}
|
||
</span>
|
||
)}
|
||
{notificationDetailItem.source_work_item?.source_system && (
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||
{notificationDetailItem.source_work_item.source_system}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">通知摘要</div>
|
||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">
|
||
{notificationDetailItem.source_notification?.content || notificationDetailItem.summary || "暂无摘要"}
|
||
</p>
|
||
</div>
|
||
|
||
{notificationDetailItem.ai_plan && (
|
||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">AI 已做/将做什么</div>
|
||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">{notificationDetailItem.ai_plan}</p>
|
||
</div>
|
||
)}
|
||
|
||
{notificationDetailItem.blocking_reason && (
|
||
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-amber-700">当前阻塞</div>
|
||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-amber-900/80">{notificationDetailItem.blocking_reason}</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">当前阶段</div>
|
||
<p className="mt-2 text-sm text-slate-700">{notificationDetailItem.stage_text || STATUS_LABELS[notificationDetailItem.display_kind]}</p>
|
||
</div>
|
||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">建议下一步</div>
|
||
<p className="mt-2 text-sm text-slate-700">{notificationDetailItem.next_step_text || "先确认通知内容,再进入结果页处理。"}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setNotificationDetailItem(null)}>
|
||
关闭
|
||
</Button>
|
||
{notificationDetailItem?.target_url && (
|
||
<Button
|
||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||
onClick={() => {
|
||
const currentItem = notificationDetailItem;
|
||
setNotificationDetailItem(null);
|
||
if (currentItem) {
|
||
void openTarget(currentItem, { forceDirect: true });
|
||
}
|
||
}}
|
||
>
|
||
进入结果页
|
||
</Button>
|
||
)}
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|