Files
yingqing/core/frontend/src/routes/messages.tsx
T
zycandClaude Opus 4.8 b714976d38 feat(core): 消息中心对齐设计稿 · 处理记录(真实数据)+ 字号/搜索框/页脚 + 归档接活
- ops/views.py: 通知 metadata 写入真实 timeline(项目/资产/计费/欢迎,真时间戳),create_once 给旧通知补齐 timeline
- messages.tsx/css: 新增「处理记录」区块(读 metadata.timeline)+ 补回 .msg-timeline/.msg-step/.msg-log 样式;搜索框结构回设计稿 .msg-search;mono 字号对齐 10.5/10px;空态图标色 token 化
- 归档按钮接活:api.archiveNotification + App 装配 onArchive + 详情页脚归档(后端落 archived_at + 本地乐观移除/扣计数)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:13:32 +08:00

313 lines
14 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bell, Clapperboard, CreditCard, Info, Search, 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<TabKey>(["task", "team", "billing", "system"]);
// tab → 服务端查询参数(tab/未读/搜索全部走后端,滚动逐页拉)
function tabParams(tab: TabKey): { type?: string; unread?: boolean } {
if (tab === "unread") return { unread: true };
if (TYPE_TABS.has(tab)) return { type: tab };
return {};
}
const PRI_LABEL: Record<string, string> = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
const ZH_TYPE: Record<string, string> = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" };
function typeIcon(type: string): ReactNode {
if (type === "task") return <Clapperboard size={14} />;
if (type === "team") return <Users size={14} />;
if (type === "billing") return <CreditCard size={14} />;
return <Info size={14} />;
}
// 通知 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<string, unknown> | 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<string, unknown>;
return [String(o.t ?? o.time ?? ""), String(o.d ?? o.desc ?? "")];
}
return ["", String(step ?? "")];
})
.filter(([t, d]) => t || d);
}
export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive, navigate }: {
unreadCount: number;
onMarkRead: (id: string) => void | Promise<unknown>;
onMarkAllRead: () => void | Promise<unknown>;
onArchive: (id: string) => void | Promise<unknown>;
navigate: (page: Page) => void;
}) {
const [tab, setTab] = useState<TabKey>("all");
const [query, setQuery] = useState("");
const [debounced, setDebounced] = useState("");
const [selectedId, setSelectedId] = useState("");
const [items, setItems] = useState<Notification[]>([]);
const [counts, setCounts] = useState<NotificationTypeCounts>(() => ({ ...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<HTMLDivElement>(null);
// 搜索去抖 300ms 再打服务端
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
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), search: debounced || undefined, page: pageToLoad, pageSize: PAGE_SIZE })
.catch(() => null);
if (gen !== genRef.current) return; // tab/搜索已切换,丢弃过期结果
if (!res) 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]
);
// tab / 搜索变化 → 清空重拉第 1 页
useEffect(() => {
setItems([]);
setSelectedId("");
setHasMore(false);
void load(1, true);
}, [load]);
// 滚到接近底部就拉下一批
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);
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;
});
}
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";
return (
<div className="msg-page">
<div className="page-head">
<div>
<h1>消息中心</h1>
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {counts.all} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
</div>
<div className="msg-head-actions">
<button className="btn" type="button" onClick={() => void markAll()} disabled={counts.unread === 0}>全部标已读</button>
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button>
</div>
</div>
<div className="msg-workbench">
<section className="msg-panel msg-inbox">
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {items.length} 条{total > items.length ? ` / ${total}` : ""}</span></div>
<div className="msg-filters">
{filters.map(([id, label, ct]) => (
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
{label}<span className="ct">{ct}</span>
</button>
))}
</div>
<div className="msg-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input type="text" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
</div>
<div className="msg-list" ref={listRef} onScroll={onScroll}>
{items.length === 0 && !loading ? (
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
) : (
<>
{items.map((n) => (
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
<span className="msg-item-main">
<span className="msg-item-row">
<span className="msg-dot"></span>
<span className="msg-item-title">{n.title}</span>
<span className="msg-time">{fmtTime(n.created_at)}</span>
</span>
<span className="msg-brief">{n.brief}</span>
<span className="msg-item-foot">
<span className={`msg-priority ${n.priority}`}>{PRI_LABEL[n.priority] || "更新"}</span>
{n.project_name ? <span className="msg-priority">{n.project_name}</span> : null}
</span>
</span>
</button>
))}
{(loading || hasMore) && <div className="msg-load-more mono">{loading ? "// 加载中…" : "// 滚动加载更多"}</div>}
{!loading && !hasMore && items.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
</>
)}
</div>
</section>
<section className="msg-panel msg-detail">
{selected ? (
<>
<div className="msg-detail-body">
<div className="msg-detail-top">
<span className={`msg-type-ic ${selected.notification_type}`}>{typeIcon(selected.notification_type)}</span>
<div className="msg-detail-title">
<h2>{selected.title}</h2>
<div className="meta"><span>{selected.source || ZH_TYPE[selected.notification_type]}</span><span>// {ZH_TYPE[selected.notification_type]}</span><span>{fmtFull(selected.created_at)}</span></div>
</div>
<span className={`msg-priority ${selected.priority}`}>{PRI_LABEL[selected.priority] || "更新"}</span>
</div>
<p className="msg-body-text">{selected.body || selected.brief}</p>
<div className="msg-props">
{([
["来源", selected.source || "-"],
["类别", ZH_TYPE[selected.notification_type] || selected.notification_type],
["项目", selected.project_name || "-"],
["阶段", selected.stage || "-"],
["负责人", selected.owner_label || "-"],
["费用", selected.cost_label || "-"],
["时间", fmtFull(selected.created_at)]
] as Array<[string, string]>).flatMap(([k, v]) => [
<span className="k" key={`${k}-k`}>{k}</span>,
<span className="v" key={`${k}-v`}>{v}</span>
])}
<span className="k">关联资源</span>
<span className="v"><a onClick={() => navigate(target)}>{routeLabels[target]} →</a></span>
</div>
{(() => {
const timeline = readTimeline(selected.metadata);
if (timeline.length === 0) return null;
return (
<div className="msg-timeline">
<div className="msg-timeline-h">处理记录</div>
{timeline.map(([t, d], i) => (
<div className="msg-step" key={i}><span className="t">{t}</span><span className="d">{d}</span></div>
))}
</div>
);
})()}
</div>
<div className="msg-detail-f">
<button className="btn btn-ghost" type="button" onClick={() => archiveOne(selected)}>归档</button>
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
<span className="spacer"></span>
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
</div>
</>
) : (
<div className="msg-detail-empty"><div className="ic"><Bell /></div><div>暂无消息</div></div>
)}
</section>
</div>
<div className="msg-foot-note">
<span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>
<a onClick={() => navigate("settingsNotify")}>管理通知策略 →</a>
</div>
</div>
);
}