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:
co-authored by
Claude Opus 4.8
parent
05215206f1
commit
9643f9ba5e
@@ -382,6 +382,11 @@
|
||||
letter-spacing: .02em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* 查看日志:后端缺 log 字段时的占位(壳照出、内容留白,不造假) */
|
||||
.msg-log-empty {
|
||||
color: var(--accent-white);
|
||||
opacity: .48;
|
||||
}
|
||||
.msg-detail-f {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -391,6 +396,14 @@
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
.msg-detail-f .spacer { flex: 1; }
|
||||
/* 「查看日志」展开中的高亮(单橙锚点,alpha 表达态,不换 hue) */
|
||||
.msg-detail-f .btn.is-active {
|
||||
border-color: var(--heat-20);
|
||||
background: var(--heat-12);
|
||||
color: var(--heat);
|
||||
}
|
||||
/* 底栏带图标的 ghost 动作(静音同类)图标与文字间距 */
|
||||
.msg-detail-f .btn svg { margin-right: 6px; }
|
||||
.msg-foot-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user