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>
This commit is contained in:
@@ -53,10 +53,27 @@ function fmtFull(iso: string): string {
|
||||
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
||||
// 处理记录:从 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");
|
||||
@@ -149,6 +166,21 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
|
||||
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],
|
||||
@@ -175,7 +207,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
|
||||
|
||||
<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} 条</span></div>
|
||||
<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)}>
|
||||
@@ -183,11 +215,9 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="msg-search-wrap">
|
||||
<div className="search-inline">
|
||||
<Search />
|
||||
<input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||
</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 ? (
|
||||
@@ -247,8 +277,21 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user