PMFlow/frontend/src/components/layout/TopBar.tsx

364 lines
16 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 { 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>
);
}