From 9643f9ba5e49abfdae5826f0484ba476ef47c0d8 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 12:26:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20Wave=203=20messages=20=E2=80=94?= =?UTF-8?q?=20=E8=AF=A6=E6=83=85=E5=A4=9A=E5=8A=A8=E4=BD=9C=20actions[](?= =?UTF-8?q?=E6=8C=89type=E6=8E=A8=E6=96=AD)+=20=E9=9D=99=E9=9F=B3=E5=90=8C?= =?UTF-8?q?=E7=B1=BB=20+=20=E6=9F=A5=E7=9C=8B=E6=97=A5=E5=BF=97=E5=A3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消息中心(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 --- core/frontend/src/messages-page.css | 13 +++ core/frontend/src/routes/messages.tsx | 131 ++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/core/frontend/src/messages-page.css b/core/frontend/src/messages-page.css index a5b9771..dedcb80 100644 --- a/core/frontend/src/messages-page.css +++ b/core/frontend/src/messages-page.css @@ -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; diff --git a/core/frontend/src/routes/messages.tsx b/core/frontend/src/routes/messages.tsx index 775c7af..cf3f79d 100644 --- a/core/frontend/src/routes/messages.tsx +++ b/core/frontend/src/routes/messages.tsx @@ -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 | undefined): Array<[string, .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; @@ -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>(() => new Set()); + // 查看日志:记当前展开日志的消息 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 })); @@ -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 (
@@ -207,7 +295,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
-
收件箱// 显示 {items.length} 条{total > items.length ? ` / ${total}` : ""}
+
收件箱// 显示 {visibleItems.length} 条{total > visibleItems.length ? ` / ${total}` : ""}
{filters.map(([id, label, ct]) => (
- {items.length === 0 && !loading ? ( + {visibleItems.length === 0 && !loading ? (
没有符合条件的消息
) : ( <> - {items.map((n) => ( + {visibleItems.map((n) => ( ))} {(loading || hasMore) &&
{loading ? "// 加载中…" : "// 滚动加载更多"}
} - {!loading && !hasMore && items.length > 0 &&
// 已全部加载
} + {!loading && !hasMore && visibleItems.length > 0 &&
// 已全部加载
} )}
@@ -289,12 +377,32 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
); })()} + {showLogId === selected.id && (() => { + const log = readLog(selected.metadata); + // 后端暂未下发 log 字段 → 壳照出,内容缺位时给占位,不造假日志 + return log + ?
{log}
+ :
// 暂无日志详情
; + })()}
- {!selected.is_read && } + + {!selected.is_read && !hasAck && } - + {detailActions.map((a) => ( + + ))}
) : ( @@ -307,6 +415,13 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive // 消息保留 90 天 · 高风险任务会同时进入工作台队列 navigate("settingsNotify")}>管理通知策略 → + + {toast && ( +
+
+
{toast.title}// {toast.sub}
+
+ )} ); }