feat(core): Wave 3 messages — 详情多动作 actions[](按type推断)+ 静音同类 + 查看日志壳

消息中心(sub-agent):inferActions 按 notification_type+priority 推断动作集(task→进入项目/查看日志、
  team→查看团队/调整权限、billing→前往充值、system→已了解);固定 归档+静音同类(前端本地 Set 过滤);
  查看日志就地展开黑底 mono .msg-log(消费 metadata.log,后端无该字段时显占位「暂无日志」,不造假)

验证: tsc 0 · build 0

⚠️ 需后端补 metadata.log 字段(失败/异常任务原始日志)前端即生效。移交监督。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 12:26:05 +08:00
co-authored by Claude Opus 4.8
parent 05215206f1
commit 9643f9ba5e
2 changed files with 136 additions and 8 deletions
+123 -8
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bell, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
import { Bell, BellOff, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
import { api } from "../api";
import type { Notification, NotificationTypeCounts } from "../types";
import type { Page } from "./route-config";
@@ -69,6 +69,46 @@ function readTimeline(meta: Record<string, unknown> | undefined): Array<[string,
.filter(([t, d]) => t || d);
}
// 失败/异常日志:后端只有 notification.metadata(无独立 log 字段),按 metadata.log 取;
// 缺则返回 ""(此时只展开壳、不造假日志)。兼容字符串 / 字符串数组两种存法。
function readLog(meta: Record<string, unknown> | 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<unknown>;
@@ -80,6 +120,13 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
const [query, setQuery] = useState("");
const [debounced, setDebounced] = useState("");
const [selectedId, setSelectedId] = useState("");
// 静音同类:纯前端本地态(记被静音的 notification_type),命中的消息从列表里隐藏;不落后端
const [mutedTypes, setMutedTypes] = useState<Set<string>>(() => new Set());
// 查看日志:记当前展开日志的消息 id(单条展开),切换其它消息自动收起
const [showLogId, setShowLogId] = useState<string>("");
// 轻量本地 toast(复用 design-restraint 的 .toast 组件;本页没有全局 toast 注入,故就地渲染)
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [items, setItems] = useState<Notification[]>([]);
const [counts, setCounts] = useState<NotificationTypeCounts>(() => ({ ...ZERO_COUNTS, unread: unreadCount }));
@@ -97,6 +144,14 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
return () => clearTimeout(t);
}, [query]);
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
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)不阻塞,靠代号作废在途旧请求
@@ -146,7 +201,9 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
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;
// 静音同类只在前端隐藏(不打后端);其余照常分页/搜索
const visibleItems = mutedTypes.size === 0 ? items : items.filter((n) => !mutedTypes.has(n.notification_type));
const selected = visibleItems.find((n) => n.id === selectedId) || visibleItems[0] || null;
// 标记单条已读:同步后端/侧边栏徽标 + 本地乐观更新(列表与未读计数)
function markOne(id: string) {
@@ -157,6 +214,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
function selectItem(n: Notification) {
setSelectedId(n.id);
setShowLogId(""); // 切换消息时收起上一条的日志
if (!n.is_read) markOne(n.id);
}
@@ -179,6 +237,33 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
if (typeof next[tk] === "number") next[tk] = Math.max(0, next[tk] - 1);
return next;
});
showToast("已归档", n.title);
}
// 静音同类:把该 notification_type 记进本地静音集,列表即时隐藏同类;再点对应 chip / 此处可恢复
function muteType(n: Notification) {
setMutedTypes((prev) => {
const next = new Set(prev);
next.add(n.notification_type);
return next;
});
setSelectedId(""); // 当前条会被隐藏,落回首条
showToast("已静音同类", `${ZH_TYPE[n.notification_type] || n.notification_type} 类提醒已隐藏 · 可在通知设置恢复`);
}
// 详情底栏派生动作的统一分发
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]> = [
@@ -191,6 +276,9 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
];
const target = selected ? targetPage(selected) : "dashboard";
const detailActions = selected ? inferActions(selected) : [];
// 「标为已读」与派生的「我已了解」(ack)同义,二者择一,避免底栏重复
const hasAck = detailActions.some((a) => a.kind === "ack");
return (
<div className="msg-page">
@@ -207,7 +295,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
<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-panel-h"><span className="ti"></span><span className="mono">// 显示 {visibleItems.length} 条{total > visibleItems.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)}>
@@ -220,11 +308,11 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
<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 ? (
{visibleItems.length === 0 && !loading ? (
<div className="msg-empty"><Search /><span></span></div>
) : (
<>
{items.map((n) => (
{visibleItems.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">
@@ -242,7 +330,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
</button>
))}
{(loading || hasMore) && <div className="msg-load-more mono">{loading ? "// 加载中…" : "// 滚动加载更多"}</div>}
{!loading && !hasMore && items.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
{!loading && !hasMore && visibleItems.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
</>
)}
</div>
@@ -289,12 +377,32 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
</div>
);
})()}
{showLogId === selected.id && (() => {
const log = readLog(selected.metadata);
// 后端暂未下发 log 字段 → 壳照出,内容缺位时给占位,不造假日志
return log
? <pre className="msg-log">{log}</pre>
: <pre className="msg-log msg-log-empty">// 暂无日志详情</pre>;
})()}
</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>}
<button className="btn btn-ghost" type="button" onClick={() => muteType(selected)}>
<BellOff size={14} />
</button>
{!selected.is_read && !hasAck && <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>
{detailActions.map((a) => (
<button
key={a.id}
className={`btn ${a.primary ? "btn-primary" : ""} ${a.kind === "log" && showLogId === selected.id ? "is-active" : ""}`}
type="button"
onClick={() => runAction(selected, a)}
>
{/* nav 主操作未带文案时,用解析出的目标页名兜底 */}
{a.label || `进入${routeLabels[target]}`}
</button>
))}
</div>
</>
) : (
@@ -307,6 +415,13 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
<span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>
<a onClick={() => navigate("settingsNotify")}> </a>
</div>
{toast && (
<div className="toast show" role="status" aria-live="polite">
<div className="ic-t"><Info size={13} aria-hidden="true" /></div>
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
</div>
)}
</div>
);
}