import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { ArrowLeft, Bell, BellOff, CheckCheck, Clapperboard, CreditCard, Info, Search, Settings2, Users } from "lucide-react"; import { api } from "../api"; import type { Notification, NotificationTypeCounts } from "../types"; import type { Page } from "./route-config"; import { routeLabels } from "./route-config"; type TabKey = "all" | "unread" | "task" | "team" | "billing" | "system"; const PAGE_SIZE = 10; // 每次滚到底加载一批 const ZERO_COUNTS: NotificationTypeCounts = { all: 0, unread: 0, task: 0, team: 0, billing: 0, system: 0 }; const TYPE_TABS = new Set(["task", "team", "billing", "system"]); const ZH_TYPE: Record = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" }; const MUTE_TYPES = ["task", "team", "billing", "system"] as const; function mutePrefKey(type: string): string { return `mute-${type}`; } function isMutedFlag(value: unknown): boolean { return value === true || value === "true" || value === 1 || value === "1"; } function mutedFromNotify(notify: Record | undefined): Set { const next = new Set(); for (const type of MUTE_TYPES) { if (isMutedFlag(notify?.[mutePrefKey(type)])) next.add(type); } return next; } function notifyFromMuted(types: Set): Record { const notify: Record = {}; for (const type of MUTE_TYPES) notify[mutePrefKey(type)] = types.has(type); return notify; } // tab → 服务端查询参数(tab/未读/搜索全部走后端,滚动逐页拉) // 静音只从「全部 / 未读」里藏掉;点进该类仍可查看,不会因此取消静音 function tabParams(tab: TabKey, mutedTypes: Set): { type?: string; unread?: boolean; excludeTypes?: string[] } { const excludeTypes = [...mutedTypes].filter((type) => type !== tab); if (tab === "unread") return { unread: true, excludeTypes }; if (TYPE_TABS.has(tab)) return { type: tab, excludeTypes }; return { excludeTypes }; } function typeIcon(type: string): ReactNode { if (type === "task") return ; if (type === "team") return ; if (type === "billing") return ; return ; } // 通知 related_url(.html 风格)→ 应用内 Page function targetPage(n: Notification): Page { const url = n.related_url || ""; if (url.includes("pipeline")) return "pipeline"; if (url.includes("account")) return "account"; if (url.includes("library")) return "library"; if (url.includes("settings")) return "settingsNotify"; if (url.includes("product")) return "products"; if (url.includes("team")) return "team"; return "dashboard"; } function fmtTime(iso: string): string { const diff = Math.round((Date.now() - new Date(iso).getTime()) / 60000); if (diff < 1) return "刚刚"; if (diff < 60) return `${diff}m`; if (diff < 1440) return `${Math.floor(diff / 60)}h`; return `${Math.floor(diff / 1440)}d`; } function fmtFull(iso: string): string { const d = new Date(iso); const z = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`; } // 处理记录:从 notification.metadata.timeline 解析成 [时间, 描述] 列表;兼容数组对 / 对象两种存法 function readTimeline(meta: Record | undefined): Array<[string, string]> { const raw = meta?.timeline; if (!Array.isArray(raw)) return []; return raw .map((step): [string, string] => { if (Array.isArray(step)) return [String(step[0] ?? ""), String(step[1] ?? "")]; if (step && typeof step === "object") { const o = step as Record; return [String(o.t ?? o.time ?? ""), String(o.d ?? o.desc ?? "")]; } return ["", String(step ?? "")]; }) .filter(([t, d]) => t || d); } // 失败/异常日志:后端只有 notification.metadata(无独立 log 字段),按 metadata.log 取; // 缺则返回 ""(此时只展开壳、不造假日志)。兼容字符串 / 字符串数组两种存法。 function readLog(meta: Record | undefined): string { const raw = meta?.log ?? meta?.logs ?? meta?.error_log; if (typeof raw === "string") return raw; if (Array.isArray(raw)) return raw.map((line) => String(line ?? "")).join("\n"); return ""; } // 详情底栏的「派生动作」:除固定的 归档 / 静音同类,主操作集按 notification_type + priority 推断。 // kind 决定点击行为:nav=跳目标页;log=就地展开日志;ack=标已读;mute 由底栏固定项单独处理。 type DetailAction = { id: string; label: string; primary?: boolean; kind: "nav" | "log" | "ack"; }; // 按消息类型 / 状态推断动作集(纯前端,不依赖后端额外字段)。 // 首个为 primary;err/ok 任务带「查看日志」(展开 .msg-log);system 类无额外跳转,只「我已了解」。 function inferActions(n: Notification): DetailAction[] { const type = n.notification_type; const list: DetailAction[] = []; if (type === "task") { list.push({ id: "goto", label: "进入项目", primary: true, kind: "nav" }); if (n.priority === "err" || n.priority === "ok") list.push({ id: "log", label: "查看日志", kind: "log" }); } else if (type === "team") { list.push({ id: "goto", label: "查看团队", primary: true, kind: "nav" }); list.push({ id: "perm", label: "调整权限", kind: "nav" }); } else if (type === "billing") { list.push({ id: "goto", label: "前往充值", primary: true, kind: "nav" }); } else if (type === "system") { if (!n.is_read) list.push({ id: "ack", label: "我已了解", primary: true, kind: "ack" }); } else { // 未知类型:保底给一个跳目标页的主操作(label 在渲染时用解析出的目标页名) list.push({ id: "goto", label: "", primary: true, kind: "nav" }); } return list; } export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive, navigate }: { unreadCount: number; onMarkRead: (id: string) => void | Promise; onMarkAllRead: () => void | Promise; onArchive: (id: string) => void | Promise; navigate: (page: Page) => void; }) { const [tab, setTab] = useState("all"); const [query, setQuery] = useState(""); const [debounced, setDebounced] = useState(""); const [selectedId, setSelectedId] = useState(""); // 静音同类:记被静音的 notification_type,请求带 exclude_type,服务端不再返回该类 const [mutedTypes, setMutedTypes] = useState>(() => new Set()); const [prefsReady, setPrefsReady] = useState(false); const [lastMutedType, setLastMutedType] = useState(""); const persistQueue = useRef(Promise.resolve()); // 查看日志:记当前展开日志的消息 id(单条展开),切换其它消息自动收起 const [showLogId, setShowLogId] = useState(""); // 轻量本地 toast(复用 design-restraint 的 .toast 组件;本页没有全局 toast 注入,故就地渲染) const [toast, setToast] = useState<{ title: string; sub: string } | null>(null); const toastTimer = useRef | null>(null); const [items, setItems] = useState([]); const [counts, setCounts] = useState(() => ({ ...ZERO_COUNTS, unread: unreadCount })); const [total, setTotal] = useState(0); // 当前筛选(tab/搜索)下的总条数,作「已加载 X / Y」的分母 const [page, setPage] = useState(1); // 下一个要拉的页码 const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(false); const loadingRef = useRef(false); // 防滚动重复触发追加 const genRef = useRef(0); // 代号:tab/搜索一变就 +1,丢弃旧请求的回包 const listRef = useRef(null); // 搜索去抖 300ms 再打服务端 useEffect(() => { const t = setTimeout(() => setDebounced(query.trim()), 300); return () => clearTimeout(t); }, [query]); useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []); useEffect(() => { let cancelled = false; void api.preferences() .then((pref) => { if (cancelled) return; const next = mutedFromNotify(pref.notify); setMutedTypes(next); setLastMutedType([...next][0] || ""); }) .catch(() => undefined) .finally(() => { if (!cancelled) setPrefsReady(true); }); return () => { cancelled = true; }; }, []); function persistMutedTypes(types: Set) { const notify = notifyFromMuted(types); persistQueue.current = persistQueue.current .then(() => api.updatePreferences({ notify })) .then(() => undefined) .catch(() => undefined); } const mutedKey = [...mutedTypes].sort().join(","); function showToast(title: string, sub: string) { setToast({ title, sub }); if (toastTimer.current) clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(null), 2800); } const load = useCallback( async (pageToLoad: number, replace: boolean) => { // 追加(滚动)要防并发;重拉(replace)不阻塞,靠代号作废在途旧请求 if (!replace && loadingRef.current) return; const gen = replace ? (genRef.current += 1) : genRef.current; loadingRef.current = true; setLoading(true); try { const res = await api .listNotifications({ ...tabParams(tab, new Set(mutedKey ? mutedKey.split(",") : [])), search: debounced || undefined, page: pageToLoad, pageSize: PAGE_SIZE, }) .catch(() => null); if (gen !== genRef.current) return; // tab/搜索已切换,丢弃过期结果 if (!res) { if (!replace) setHasMore(false); return; } setItems((prev) => (replace ? res.results : [...prev, ...res.results])); setHasMore(Boolean(res.next)); setPage(pageToLoad + 1); setTotal(res.count); if (res.type_counts) setCounts(res.type_counts); } finally { if (gen === genRef.current) { loadingRef.current = false; setLoading(false); } } }, [tab, debounced, mutedKey] ); // tab / 搜索 / 静音变化 → 清空重拉第 1 页(等偏好就绪,避免先拉全量再排除) useEffect(() => { if (!prefsReady) return; setItems([]); setSelectedId(""); setHasMore(false); void load(1, true); }, [load, prefsReady]); // 滚到接近底部就拉下一批 const onScroll = useCallback(() => { const el = listRef.current; if (!el || !hasMore || loadingRef.current) return; if (el.scrollHeight - el.scrollTop - el.clientHeight < 120) void load(page, false); }, [hasMore, page, load]); // 首批撑不满面板(没出现滚动条)却还有更多 → 自动续拉,保证可触达 useEffect(() => { const el = listRef.current; if (el && hasMore && !loadingRef.current && el.scrollHeight <= el.clientHeight) void load(page, false); }, [items, hasMore, page, load]); const selected = items.find((n) => n.id === selectedId) || items[0] || null; // 标记单条已读:同步后端/侧边栏徽标 + 本地乐观更新(列表与未读计数) function markOne(id: string) { void onMarkRead(id); setItems((prev) => prev.map((x) => (x.id === id ? { ...x, is_read: true, unread: false } : x))); setCounts((c) => ({ ...c, unread: Math.max(0, c.unread - 1) })); } function selectItem(n: Notification) { setSelectedId(n.id); setShowLogId(""); // 切换消息时收起上一条的日志 if (!n.is_read) markOne(n.id); } async function markAll() { await onMarkAllRead(); setItems((prev) => prev.map((x) => ({ ...x, is_read: true, unread: false }))); setCounts((c) => ({ ...c, unread: 0 })); } // 归档:后端落 archived_at(下次拉取即不再返回)+ 本地乐观移除该条,并扣减总数/分类/未读计数 function archiveOne(n: Notification) { void onArchive(n.id); setItems((prev) => prev.filter((x) => x.id !== n.id)); setTotal((t) => Math.max(0, t - 1)); setSelectedId(""); // 落回 items[0] setCounts((c) => { const next: NotificationTypeCounts = { ...c, all: Math.max(0, c.all - 1) }; if (!n.is_read) next.unread = Math.max(0, c.unread - 1); const tk = n.notification_type as keyof NotificationTypeCounts; if (typeof next[tk] === "number") next[tk] = Math.max(0, next[tk] - 1); return next; }); showToast("已归档", n.title); } // 静音同类:写入 mute-* 偏好;再点同一按钮或对应分类即可恢复 function unmuteType(type: string) { if (!type || !mutedTypes.has(type)) return; const next = new Set(mutedTypes); next.delete(type); setMutedTypes(next); setLastMutedType((cur) => (cur === type ? "" : cur)); persistMutedTypes(next); showToast("已取消静音", `${ZH_TYPE[type] || type} 类提醒已恢复显示`); } function muteType(n: Notification) { const type = n.notification_type; if (!type) return; if (mutedTypes.has(type) || (lastMutedType && mutedTypes.has(lastMutedType))) { unmuteType(mutedTypes.has(type) ? type : lastMutedType); return; } const next = new Set(mutedTypes); next.add(type); setMutedTypes(next); setLastMutedType(type); persistMutedTypes(next); setSelectedId(""); if (tab === type) setTab("all"); const label = ZH_TYPE[type] || type; showToast("已静音同类", `${label} 类已从「全部」隐藏 · 点「${label}」仍可查看,点取消静音才恢复`); } // 详情底栏派生动作的统一分发 function runAction(n: Notification, action: DetailAction) { if (action.kind === "log") { setShowLogId((cur) => (cur === n.id ? "" : n.id)); return; } if (action.kind === "ack") { if (!n.is_read) markOne(n.id); showToast("已确认", n.title); return; } // nav:跳到该消息关联的目标页 navigate(targetPage(n)); } const filters: Array<[TabKey, string, number]> = [ ["all", "全部", counts.all], ["unread", "未读", counts.unread], ["task", "任务", counts.task], ["team", "团队", counts.team], ["billing", "计费", counts.billing], ["system", "系统", counts.system] ]; const target = selected ? targetPage(selected) : "dashboard"; const detailActions = selected ? inferActions(selected) : []; // 「标为已读」与派生的「我已了解」(ack)同义,二者择一,避免底栏重复 const hasAck = detailActions.some((a) => a.kind === "ack"); const bootLoading = !prefsReady || (loading && items.length === 0); const tabMuted = TYPE_TABS.has(tab) && mutedTypes.has(tab); const unmuteTarget = (selected && mutedTypes.has(selected.notification_type) && selected.notification_type) || (tabMuted ? tab : "") || (lastMutedType && mutedTypes.has(lastMutedType) ? lastMutedType : ""); return (

消息中心

{counts.unread} 条未读 · {counts.all} 条总计 · 任务提醒、团队协作、计费与系统公告
{filters.map(([id, label, ct]) => { const muted = TYPE_TABS.has(id) && mutedTypes.has(id); return ( ); })}
{bootLoading ? (
{Array.from({ length: 7 }, (_, i) => (
))}
) : ( <> {tabMuted && (
已从「全部」隐藏 · 仅在此分类查看
)} {items.length === 0 ? (
没有符合条件的消息
) : ( <> {items.map((n) => ( ))} {(loading || hasMore) && (
{loading ? : "滚动加载更多"}
)} {!loading && !hasMore && items.length > 0 &&
已全部加载
} )} )}
{bootLoading ? (
{Array.from({ length: 6 }, (_, i) => (
))}
) : selected ? ( <>

{selected.title}

{selected.body || selected.brief}

来源{selected.source || ZH_TYPE[selected.notification_type] || "—"} 类别{ZH_TYPE[selected.notification_type] || "—"} 项目{selected.project_name || selected.project || "—"} 阶段{selected.stage || "—"} 负责人{selected.owner_label || "—"} 费用{selected.cost_label || "—"} 时间{fmtFull(selected.created_at)} 关联资源 {selected.related_url ? : }
{(() => { const timeline = readTimeline(selected.metadata); if (timeline.length === 0) return null; return (
处理记录
{timeline.map(([t, d], i) => (
{t}{d}
))}
); })()} {showLogId === selected.id && (() => { const log = readLog(selected.metadata); return log ?
{log}
:
暂无日志详情
; })()}
{!selected.is_read && !hasAck && } {detailActions.map((a) => ( ))}
) : (
{tabMuted ? : }
{tabMuted ? `已静音${ZH_TYPE[tab] || ""}类` : "暂无消息"}
{tabMuted && ( )}
)}
{toast && (
{toast.title}{toast.sub}
)}
); }