PMFlow/frontend/src/pages/InboxPage.tsx

914 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
}