import { useEffect, useMemo, useRef, useState } from "react"; import { Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo"; import { sstcApi, type SSTCRisks } from "@/api/sstc"; import { useAuthStore } from "@/stores/auth"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/toast"; import { notificationsApi, type NotificationListPayload } from "@/api/notifications"; import { SSTCRiskTopbarEntry } from "@/components/sstc/SSTCRiskFloatPanel"; import { Badge } from "@/components/ui/badge"; import { parseBackendDate } from "@/lib/datetime"; import { zhCN } from "@/lib/zhCN"; import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { buildInboxView } from "@/lib/inbox"; import type { InboxDisplayKind, InboxItemViewModel } from "@/types/inbox"; const POLL_INTERVAL_MS = 15000; const EMPTY_NOTIFICATIONS: NotificationListPayload = { items: [], unread_count: 0, }; const EMPTY_WORK_ITEMS: AIPMOWorkItemListPayload = { items: [], }; const EMPTY_PROJECT_RISKS: SSTCRisks = { conflicts: [], risks: [], summary: { conflict_count: 0, risk_count: 0, blocked_task_count: 0, overdue_task_count: 0, }, }; const SECTION_STYLES: Record = { 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 SECTION_LABELS: Record = { action_required: "需你决策", auto_running: "AI处理中", auto_done: "AI已完成", info: "仅通知", }; const SECTION_CHROME: Record = { action_required: "bg-amber-50 text-amber-700 dark:bg-amber-900/35 dark:text-amber-300", auto_running: "bg-sky-50 text-sky-700 dark:bg-sky-900/35 dark:text-sky-300", auto_done: "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-300", info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300", }; interface TopBarProps { onMenuToggle: () => void; } function formatRelativeTime(value?: string | null) { if (!value) return "-"; const date = parseBackendDate(value); if (!date) return "-"; const diffMs = Date.now() - date.getTime(); const minute = 60 * 1000; const hour = 60 * minute; const day = 24 * hour; if (diffMs < minute) return "刚刚"; if (diffMs < hour) return `${Math.floor(diffMs / minute)} 分钟前`; if (diffMs < day) return `${Math.floor(diffMs / hour)} 小时前`; return `${Math.floor(diffMs / day)} 天前`; } export function TopBar({ onMenuToggle }: TopBarProps) { const { user, logout } = useAuthStore(); const navigate = useNavigate(); const location = useLocation(); const { toast } = useToast(); const [notifications, setNotifications] = useState(EMPTY_NOTIFICATIONS); const [workItems, setWorkItems] = useState(EMPTY_WORK_ITEMS); const maxSeenBadgeRef = useRef(0); const latestBellStateRef = useRef({ notifications: EMPTY_NOTIFICATIONS, workItems: EMPTY_WORK_ITEMS, }); const inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]); const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null; const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox"; const { data: boardOverviewPayload } = useQuery({ queryKey: ["sstc-topbar-risk-overview", currentProjectId], queryFn: () => sstcApi.getBoardOverview(currentProjectId!), enabled: !!currentProjectId, refetchInterval: POLL_INTERVAL_MS, refetchOnWindowFocus: true, }); const currentProjectRisks = boardOverviewPayload?.risks || EMPTY_PROJECT_RISKS; const fetchBellData = async (notifyOnNew: boolean) => { const [notificationsResult, workItemsResult] = await Promise.allSettled([ notificationsApi.list({ limit: 18 }), aiPmoApi.listWorkItems({ limit: 18 }), ]); let nextNotifications = latestBellStateRef.current.notifications; let nextWorkItems = latestBellStateRef.current.workItems; if (notificationsResult.status === "fulfilled") { nextNotifications = notificationsResult.value; setNotifications(nextNotifications); } if (workItemsResult.status === "fulfilled") { nextWorkItems = workItemsResult.value; setWorkItems(nextWorkItems); } const nextBadgeCount = nextNotifications.unread_count + nextWorkItems.items.filter((item) => item.state === "waiting_manual").length; if (notifyOnNew && maxSeenBadgeRef.current > 0 && nextBadgeCount > maxSeenBadgeRef.current) { toast({ title: "有新的收件项", variant: "success" }); } maxSeenBadgeRef.current = Math.max(maxSeenBadgeRef.current, nextBadgeCount); }; const openPreviewItem = async (item: InboxItemViewModel) => { const params = new URLSearchParams(); params.set("section", item.display_kind); if (item.project_id) { params.set("project_id", String(item.project_id)); } else if (currentProjectId) { params.set("project_id", currentProjectId); } if (item.source_kind === "notification" && item.source_notification && !item.source_notification.is_read) { try { await notificationsApi.markRead(item.source_notification.id); } catch { // Ignore preview read failures and still navigate to inbox. } } navigate(`/inbox?${params.toString()}`); }; useEffect(() => { latestBellStateRef.current = { notifications, workItems }; }, [notifications, workItems]); useEffect(() => { void fetchBellData(false); const timer = window.setInterval(() => { void fetchBellData(true); }, POLL_INTERVAL_MS); return () => window.clearInterval(timer); }, []); const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count; return (
PMFlow
AI 原生项目管理操作台
{zhCN.bell.title}
铃铛只看摘要,完整处理流统一进入 Inbox。
未读 {notifications.unread_count} · 待决策 {inboxData.leaderSummary.decision_count}
{[ { key: "decision", label: "需你决策", value: inboxData.leaderSummary.decision_count, chrome: "bg-amber-50 text-amber-700 dark:bg-amber-900/35 dark:text-amber-300", icon: ShieldAlert, }, { key: "running", label: "AI处理中", value: inboxData.leaderSummary.auto_running_count, chrome: "bg-sky-50 text-sky-700 dark:bg-sky-900/35 dark:text-sky-300", icon: LoaderCircle, }, { key: "done", label: "今日完成", value: inboxData.leaderSummary.auto_done_today, chrome: "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-300", icon: CheckCircle2, }, ].map(({ key, label, value, chrome, icon: Icon }) => (
{label}
{value}
))}
{inboxData.previewSections.every((section) => section.count === 0) ? (
当前没有需要进入消息台的收件项。
) : ( inboxData.previewSections.map((section, index) => (
{index > 0 && }
{SECTION_LABELS[section.key as InboxDisplayKind]}
前 3 条摘要预览
{section.count} 条
{section.items.length === 0 ? (
当前分区为空
) : (
{section.items.map((item) => ( ))}
)}
)) )}
{currentProjectId && }

{user?.name || user?.username || "用户"}

{user?.email || user?.username}

个人中心
); }