import { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { BellRing } from "lucide-react"; import { aiPmoApi } from "@/api/aiPmo"; import { notificationsApi } from "@/api/notifications"; import { projectsApi } from "@/api/projects"; import InboxActionPanel, { type InboxProjectOption } from "@/components/inbox/InboxActionPanel"; import InboxListItem, { buildInboxActionKey } from "@/components/inbox/InboxListItem"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useToast } from "@/components/ui/toast"; import { getApiErrorMessage } from "@/lib/apiError"; import { parseBackendDate } from "@/lib/datetime"; import { buildInboxView } from "@/lib/inbox"; import { cn } from "@/lib/utils"; import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel } from "@/types/inbox"; const POLL_INTERVAL_MS = 15000; type InboxTabKey = "all" | "needs_action" | "in_progress" | "done"; const TAB_LABELS: Record = { all: "全部", needs_action: "待处理", in_progress: "进行中", done: "已完成", }; const TAB_TO_DISPLAY_KIND: Partial> = { needs_action: "action_required", in_progress: "auto_running", done: "auto_done", }; function normalizeTabKey(raw?: string | null): InboxTabKey { const value = String(raw || "").trim(); if (value === "action_required" || value === "needs_action") return "needs_action"; if (value === "auto_running" || value === "in_progress") return "in_progress"; if (value === "auto_done" || value === "done") return "done"; return "all"; } function sortByLatest(items: InboxItemViewModel[]): InboxItemViewModel[] { const toTimestamp = (item: InboxItemViewModel): number => { const date = parseBackendDate(item.updated_at || item.created_at); return date ? date.getTime() : -1; }; return [...items].sort((a, b) => toTimestamp(b) - toTimestamp(a)); } function matchesTab(item: InboxItemViewModel, tab: InboxTabKey): boolean { if (tab === "all") return true; return item.display_kind === TAB_TO_DISPLAY_KIND[tab]; } function parsePositiveInt(value: unknown): number | null { const parsed = Number(value); if (!Number.isFinite(parsed)) return null; const integer = Math.trunc(parsed); return integer > 0 ? integer : null; } function isMissingProjectScopeItem(item: InboxItemViewModel): boolean { if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return false; const state = String(item.source_work_item.state || "").toLowerCase(); const gateCode = String(item.latest_policy_gate_code || item.source_work_item.latest_policy_gate_code || "").toLowerCase(); return state === "waiting_manual" && gateCode === "missing_project_scope"; } export default function InboxPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { toast } = useToast(); const [searchParams, setSearchParams] = useSearchParams(); const [pendingActionKey, setPendingActionKey] = useState(null); const [expandedItemId, setExpandedItemId] = useState(null); const [selectedProjectByItem, setSelectedProjectByItem] = useState>({}); const [autoExpandedByQuery, setAutoExpandedByQuery] = useState(false); const activeTab = normalizeTabKey(searchParams.get("section")); const projectFilter = searchParams.get("project_id") || "all"; const searchValue = searchParams.get("q") || ""; const notificationsQuery = useQuery({ queryKey: ["notifications", "inbox"], queryFn: () => notificationsApi.list({ limit: 80 }), refetchInterval: POLL_INTERVAL_MS, }); const workItemsQuery = useQuery({ queryKey: ["ai-pmo-work-items", "inbox"], queryFn: () => aiPmoApi.listWorkItems({ limit: 80 }), refetchInterval: POLL_INTERVAL_MS, }); const activeProjectsQuery = useQuery({ queryKey: ["projects", "active", "inbox-inline-actions"], queryFn: () => projectsApi.list({ status: "active" }), refetchInterval: POLL_INTERVAL_MS, }); 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(); 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 activeProjectOptions = useMemo(() => { const projects = activeProjectsQuery.data || []; return projects.map((project) => ({ value: String(project.id), label: String(project.title || `项目 #${project.id}`), })); }, [activeProjectsQuery.data]); const projectScopedItems = useMemo(() => { return inboxData.items.filter((item) => { if (projectFilter === "all") return true; return String(item.project_id || "") === projectFilter; }); }, [inboxData.items, projectFilter]); const tabCounts = useMemo(() => { return { all: projectScopedItems.length, needs_action: projectScopedItems.filter((item) => item.display_kind === "action_required").length, in_progress: projectScopedItems.filter((item) => item.display_kind === "auto_running").length, done: projectScopedItems.filter((item) => item.display_kind === "auto_done").length, } satisfies Record; }, [projectScopedItems]); const searchedItems = useMemo(() => { const query = searchValue.trim().toLowerCase(); if (!query) return projectScopedItems; return projectScopedItems.filter((item) => { return `${item.title} ${item.summary} ${item.project_name || ""} ${item.stage_text || ""}` .toLowerCase() .includes(query); }); }, [projectScopedItems, searchValue]); const feedItems = useMemo(() => { const tabItems = searchedItems.filter((item) => matchesTab(item, activeTab)); return sortByLatest(tabItems); }, [searchedItems, activeTab]); useEffect(() => { if (autoExpandedByQuery) return; const targetId = parsePositiveInt(searchParams.get("workItemId")); if (!targetId) return; const targetKey = `work-item-${targetId}`; const matched = feedItems.find((item) => item.id === targetKey); if (matched) { setExpandedItemId(targetKey); setAutoExpandedByQuery(true); } }, [autoExpandedByQuery, feedItems, searchParams]); const setTab = (tab: string) => { const normalized = normalizeTabKey(tab); const next = new URLSearchParams(searchParams); next.set("section", normalized); 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 toggleExpand = (itemId: string) => { setExpandedItemId((current) => (current === itemId ? null : itemId)); }; const getSelectedProjectId = (item: InboxItemViewModel): number | null => { const selected = selectedProjectByItem[item.id]; if (selected) return parsePositiveInt(selected); if (item.project_id) return parsePositiveInt(item.project_id); return null; }; const getSelectedProjectValue = (item: InboxItemViewModel): string => { const selected = selectedProjectByItem[item.id]; if (selected) return selected; return item.project_id ? String(item.project_id) : ""; }; const setSelectedProjectValue = (item: InboxItemViewModel, value: string) => { setSelectedProjectByItem((current) => ({ ...current, [item.id]: value })); }; 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 openTarget = async (item: InboxItemViewModel) => { 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; } toast({ title: "当前消息暂无可打开的目标入口", variant: "default" }); }; const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => { const actionKey = buildInboxActionKey(item, action); setPendingActionKey(actionKey); try { if (action.kind === "open_target") { await openTarget(item); return; } if (action.kind === "open_detail") { await markNotificationRead(item); toggleExpand(item.id); await refreshAll(); return; } if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { const missingProjectScope = isMissingProjectScopeItem(item); const selectedProjectId = missingProjectScope ? getSelectedProjectId(item) : (item.project_id ?? null); if (missingProjectScope && !selectedProjectId) { setExpandedItemId(item.id); toast({ title: "请先选择关联项目后再确认", variant: "default" }); return; } const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, { action: "approve", project_id: selectedProjectId ?? undefined, }); await refreshAll(); const followupReply = String(result.followup_reply || "").trim(); if (result.status === "succeeded") { toast({ title: followupReply || "已继续执行并完成", variant: "success" }); } else if (result.status === "pending_manual") { toast({ title: followupReply || "继续执行后仍需人工确认", variant: "default" }); } else { toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" }); } setSelectedProjectByItem((current) => { if (!(item.id in current)) return current; const next = { ...current }; delete next[item.id]; return next; }); setExpandedItemId(item.id); 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" }); 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") { toggleExpand(item.id); } } catch (error) { toast({ title: getApiErrorMessage(error), variant: "error" }); } finally { setPendingActionKey((current) => (current === actionKey ? null : current)); } }; const markAllRead = async () => { try { await notificationsApi.markAllRead(); await refreshAll(); toast({ title: "站内通知已全部设为已读", variant: "success" }); } catch (error) { toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" }); } }; const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading; const activeProjectErrorText = activeProjectsQuery.isError ? getApiErrorMessage(activeProjectsQuery.error, "项目列表加载失败") : null; return (

消息台

列表 Feed · 最新在上 · 支持移动端

当前结果:{feedItems.length} 条

setSearch(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value); }} />
信息流筛选(单列时间流,不分栏)
{(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => ( ))}
{isLoading ? (
加载消息台中…
) : feedItems.length === 0 ? (

当前没有匹配收件项

请调整筛选条件后重试

) : (
{feedItems.map((item) => ( setSelectedProjectValue(item, value)} onCancel={() => setExpandedItemId(null)} onConfirm={() => void handleAction(item, item.primary_action)} /> ) : undefined } onToggleExpand={toggleExpand} onAction={(target, action) => void handleAction(target, action)} /> ))}
)}
); }