364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
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<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 SECTION_LABELS: Record<InboxDisplayKind, string> = {
|
||
action_required: "需你决策",
|
||
auto_running: "AI处理中",
|
||
auto_done: "AI已完成",
|
||
info: "仅通知",
|
||
};
|
||
|
||
const SECTION_CHROME: Record<InboxDisplayKind, string> = {
|
||
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<NotificationListPayload>(EMPTY_NOTIFICATIONS);
|
||
const [workItems, setWorkItems] = useState<AIPMOWorkItemListPayload>(EMPTY_WORK_ITEMS);
|
||
const maxSeenBadgeRef = useRef<number>(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 (
|
||
<header className="flex h-16 min-w-0 items-center justify-between border-b border-slate-200 bg-white px-3 shadow-[0_1px_0_rgba(15,23,42,0.04)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-none sm:px-4 md:px-6">
|
||
<div className="flex min-w-0 items-center gap-2 sm:gap-4">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="md:hidden"
|
||
onClick={onMenuToggle}
|
||
aria-label="打开侧边栏菜单"
|
||
>
|
||
<Menu className="h-5 w-5" />
|
||
</Button>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">PMFlow</div>
|
||
<div className="hidden text-xs text-slate-500 dark:text-slate-400 sm:block">AI 原生项目管理操作台</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1 sm:gap-2">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon" title={zhCN.bell.title} className="relative">
|
||
<Bell className="h-5 w-5" />
|
||
{badgeCount > 0 && (
|
||
<span className="absolute -right-0.5 -top-0.5 min-w-4 rounded-full bg-red-500 px-1 text-center text-[10px] leading-4 text-white">
|
||
{badgeCount > 99 ? "99+" : badgeCount}
|
||
</span>
|
||
)}
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="end"
|
||
className="w-[min(460px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] rounded-2xl border-0 bg-[#F8F9FA] p-3 shadow-[0_14px_30px_rgba(15,23,42,0.12)] dark:bg-slate-950 dark:shadow-none"
|
||
>
|
||
<div className="rounded-2xl bg-white px-4 py-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{zhCN.bell.title}</div>
|
||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||
铃铛只看摘要,完整处理流统一进入 Inbox。
|
||
</div>
|
||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||
未读 {notifications.unread_count} · 待决策 {inboxData.leaderSummary.decision_count}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
|
||
asChild
|
||
>
|
||
<Link to={inboxHref}>{zhCN.bell.viewAll}</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||
{[
|
||
{
|
||
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 }) => (
|
||
<div key={key} className="rounded-xl bg-slate-50 px-3 py-3 dark:bg-slate-800">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div>
|
||
<div className="text-[11px] font-semibold text-slate-500 dark:text-slate-400">{label}</div>
|
||
<div className="mt-1 text-lg font-bold text-slate-900 dark:text-slate-100">{value}</div>
|
||
</div>
|
||
<span className={cn("inline-flex h-8 w-8 items-center justify-center rounded-full", chrome)}>
|
||
<Icon className="h-4 w-4" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 max-h-[560px] space-y-3 overflow-auto">
|
||
{inboxData.previewSections.every((section) => section.count === 0) ? (
|
||
<div className="rounded-2xl bg-white p-4 text-sm text-slate-500 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:text-slate-400 dark:shadow-none">
|
||
当前没有需要进入消息台的收件项。
|
||
</div>
|
||
) : (
|
||
inboxData.previewSections.map((section, index) => (
|
||
<section
|
||
key={section.key}
|
||
className="rounded-2xl bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none"
|
||
>
|
||
{index > 0 && <DropdownMenuSeparator className="hidden" />}
|
||
<div className="flex items-center justify-between px-1">
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
"inline-flex rounded-full px-3 py-1 text-[11px] font-semibold",
|
||
SECTION_CHROME[section.key as InboxDisplayKind]
|
||
)}
|
||
>
|
||
{SECTION_LABELS[section.key as InboxDisplayKind]}
|
||
</span>
|
||
<div className="text-xs text-slate-500 dark:text-slate-400">前 3 条摘要预览</div>
|
||
</div>
|
||
<div className="text-xs text-slate-400 dark:text-slate-500">{section.count} 条</div>
|
||
</div>
|
||
|
||
{section.items.length === 0 ? (
|
||
<div className="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 p-3 text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
|
||
当前分区为空
|
||
</div>
|
||
) : (
|
||
<div className="mt-3 space-y-2">
|
||
{section.items.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => void openPreviewItem(item)}
|
||
className="w-full rounded-xl border border-slate-100 bg-slate-50 p-3 text-left transition hover:border-slate-200 hover:bg-white dark:border-slate-700 dark:bg-slate-800 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<Badge variant="outline" className={cn("text-[11px]", SECTION_STYLES[item.display_kind])}>
|
||
{SECTION_LABELS[item.display_kind]}
|
||
</Badge>
|
||
{item.project_id && (
|
||
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||
{item.project_name || `项目 #${item.project_id}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<span className="shrink-0 text-[11px] text-slate-400 dark:text-slate-500">
|
||
{formatRelativeTime(item.updated_at || item.created_at)}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 line-clamp-1 text-sm font-semibold text-slate-900 dark:text-slate-100">{item.title}</div>
|
||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500 dark:text-slate-400">{item.summary}</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
))
|
||
)}
|
||
</div>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
{currentProjectId && <SSTCRiskTopbarEntry risks={currentProjectRisks} />}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="rounded-full">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20">
|
||
<User className="h-4 w-4 text-primary" />
|
||
</div>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-48">
|
||
<div className="px-2 py-2">
|
||
<p className="text-sm font-medium">{user?.name || user?.username || "用户"}</p>
|
||
<p className="text-xs text-muted-foreground">{user?.email || user?.username}</p>
|
||
</div>
|
||
<DropdownMenuSeparator />
|
||
<Link to={`/users/${user?.id || ""}/profile`} className="flex items-center px-2 py-1.5 text-sm">
|
||
<User className="mr-2 h-4 w-4" />
|
||
个人中心
|
||
</Link>
|
||
<DropdownMenuSeparator />
|
||
<button
|
||
type="button"
|
||
onClick={logout}
|
||
className="flex w-full items-center px-2 py-1.5 text-left text-sm text-destructive"
|
||
>
|
||
<LogOut className="mr-2 h-4 w-4" />
|
||
退出登录
|
||
</button>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|