大量修改UI
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Bell, BellOff, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
|
||||
import { ArrowLeft, Bell, BellOff, CheckCheck, Clapperboard, CreditCard, Info, Search, Settings2, Users } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import type { Notification, NotificationTypeCounts } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
@@ -10,22 +10,46 @@ 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: "系统" };
|
||||
|
||||
const MUTE_TYPES = ["task", "team", "billing", "system"] as const;
|
||||
|
||||
function mutePrefKey(type: string): string {
|
||||
return `mute-${type}`;
|
||||
}
|
||||
|
||||
function isMutedFlag(value: unknown): boolean {
|
||||
return value === true || value === "true" || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
function mutedFromNotify(notify: Record<string, boolean> | undefined): Set<string> {
|
||||
const next = new Set<string>();
|
||||
for (const type of MUTE_TYPES) {
|
||||
if (isMutedFlag(notify?.[mutePrefKey(type)])) next.add(type);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function notifyFromMuted(types: Set<string>): Record<string, boolean> {
|
||||
const notify: Record<string, boolean> = {};
|
||||
for (const type of MUTE_TYPES) notify[mutePrefKey(type)] = types.has(type);
|
||||
return notify;
|
||||
}
|
||||
|
||||
// tab → 服务端查询参数(tab/未读/搜索全部走后端,滚动逐页拉)
|
||||
// 静音只从「全部 / 未读」里藏掉;点进该类仍可查看,不会因此取消静音
|
||||
function tabParams(tab: TabKey, mutedTypes: Set<string>): { type?: string; unread?: boolean; excludeTypes?: string[] } {
|
||||
const excludeTypes = [...mutedTypes].filter((type) => type !== tab);
|
||||
if (tab === "unread") return { unread: true, excludeTypes };
|
||||
if (TYPE_TABS.has(tab)) return { type: tab, excludeTypes };
|
||||
return { excludeTypes };
|
||||
}
|
||||
|
||||
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} />;
|
||||
if (type === "task") return <Clapperboard size={16} />;
|
||||
if (type === "team") return <Users size={16} />;
|
||||
if (type === "billing") return <CreditCard size={16} />;
|
||||
return <Info size={16} />;
|
||||
}
|
||||
|
||||
// 通知 related_url(.html 风格)→ 应用内 Page
|
||||
@@ -120,8 +144,11 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
const [query, setQuery] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
// 静音同类:纯前端本地态(记被静音的 notification_type),命中的消息从列表里隐藏;不落后端
|
||||
// 静音同类:记被静音的 notification_type,请求带 exclude_type,服务端不再返回该类
|
||||
const [mutedTypes, setMutedTypes] = useState<Set<string>>(() => new Set());
|
||||
const [prefsReady, setPrefsReady] = useState(false);
|
||||
const [lastMutedType, setLastMutedType] = useState("");
|
||||
const persistQueue = useRef(Promise.resolve());
|
||||
// 查看日志:记当前展开日志的消息 id(单条展开),切换其它消息自动收起
|
||||
const [showLogId, setShowLogId] = useState<string>("");
|
||||
// 轻量本地 toast(复用 design-restraint 的 .toast 组件;本页没有全局 toast 注入,故就地渲染)
|
||||
@@ -146,6 +173,30 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
|
||||
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void api.preferences()
|
||||
.then((pref) => {
|
||||
if (cancelled) return;
|
||||
const next = mutedFromNotify(pref.notify);
|
||||
setMutedTypes(next);
|
||||
setLastMutedType([...next][0] || "");
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => { if (!cancelled) setPrefsReady(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
function persistMutedTypes(types: Set<string>) {
|
||||
const notify = notifyFromMuted(types);
|
||||
persistQueue.current = persistQueue.current
|
||||
.then(() => api.updatePreferences({ notify }))
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
const mutedKey = [...mutedTypes].sort().join(",");
|
||||
|
||||
function showToast(title: string, sub: string) {
|
||||
setToast({ title, sub });
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
@@ -161,10 +212,18 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api
|
||||
.listNotifications({ ...tabParams(tab), search: debounced || undefined, page: pageToLoad, pageSize: PAGE_SIZE })
|
||||
.listNotifications({
|
||||
...tabParams(tab, new Set(mutedKey ? mutedKey.split(",") : [])),
|
||||
search: debounced || undefined,
|
||||
page: pageToLoad,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
.catch(() => null);
|
||||
if (gen !== genRef.current) return; // tab/搜索已切换,丢弃过期结果
|
||||
if (!res) return;
|
||||
if (!res) {
|
||||
if (!replace) setHasMore(false);
|
||||
return;
|
||||
}
|
||||
setItems((prev) => (replace ? res.results : [...prev, ...res.results]));
|
||||
setHasMore(Boolean(res.next));
|
||||
setPage(pageToLoad + 1);
|
||||
@@ -177,16 +236,17 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
}
|
||||
}
|
||||
},
|
||||
[tab, debounced]
|
||||
[tab, debounced, mutedKey]
|
||||
);
|
||||
|
||||
// tab / 搜索变化 → 清空重拉第 1 页
|
||||
// tab / 搜索 / 静音变化 → 清空重拉第 1 页(等偏好就绪,避免先拉全量再排除)
|
||||
useEffect(() => {
|
||||
if (!prefsReady) return;
|
||||
setItems([]);
|
||||
setSelectedId("");
|
||||
setHasMore(false);
|
||||
void load(1, true);
|
||||
}, [load]);
|
||||
}, [load, prefsReady]);
|
||||
|
||||
// 滚到接近底部就拉下一批
|
||||
const onScroll = useCallback(() => {
|
||||
@@ -201,9 +261,7 @@ 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 visibleItems = mutedTypes.size === 0 ? items : items.filter((n) => !mutedTypes.has(n.notification_type));
|
||||
const selected = visibleItems.find((n) => n.id === selectedId) || visibleItems[0] || null;
|
||||
const selected = items.find((n) => n.id === selectedId) || items[0] || null;
|
||||
|
||||
// 标记单条已读:同步后端/侧边栏徽标 + 本地乐观更新(列表与未读计数)
|
||||
function markOne(id: string) {
|
||||
@@ -240,15 +298,33 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
showToast("已归档", n.title);
|
||||
}
|
||||
|
||||
// 静音同类:把该 notification_type 记进本地静音集,列表即时隐藏同类;再点对应 chip / 此处可恢复
|
||||
// 静音同类:写入 mute-* 偏好;再点同一按钮或对应分类即可恢复
|
||||
function unmuteType(type: string) {
|
||||
if (!type || !mutedTypes.has(type)) return;
|
||||
const next = new Set(mutedTypes);
|
||||
next.delete(type);
|
||||
setMutedTypes(next);
|
||||
setLastMutedType((cur) => (cur === type ? "" : cur));
|
||||
persistMutedTypes(next);
|
||||
showToast("已取消静音", `${ZH_TYPE[type] || type} 类提醒已恢复显示`);
|
||||
}
|
||||
|
||||
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} 类提醒已隐藏 · 可在通知设置恢复`);
|
||||
const type = n.notification_type;
|
||||
if (!type) return;
|
||||
if (mutedTypes.has(type) || (lastMutedType && mutedTypes.has(lastMutedType))) {
|
||||
unmuteType(mutedTypes.has(type) ? type : lastMutedType);
|
||||
return;
|
||||
}
|
||||
const next = new Set(mutedTypes);
|
||||
next.add(type);
|
||||
setMutedTypes(next);
|
||||
setLastMutedType(type);
|
||||
persistMutedTypes(next);
|
||||
setSelectedId("");
|
||||
if (tab === type) setTab("all");
|
||||
const label = ZH_TYPE[type] || type;
|
||||
showToast("已静音同类", `${label} 类已从「全部」隐藏 · 点「${label}」仍可查看,点取消静音才恢复`);
|
||||
}
|
||||
|
||||
// 详情底栏派生动作的统一分发
|
||||
@@ -279,91 +355,136 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
const detailActions = selected ? inferActions(selected) : [];
|
||||
// 「标为已读」与派生的「我已了解」(ack)同义,二者择一,避免底栏重复
|
||||
const hasAck = detailActions.some((a) => a.kind === "ack");
|
||||
const bootLoading = !prefsReady || (loading && items.length === 0);
|
||||
const tabMuted = TYPE_TABS.has(tab) && mutedTypes.has(tab);
|
||||
const unmuteTarget = (selected && mutedTypes.has(selected.notification_type) && selected.notification_type)
|
||||
|| (tabMuted ? tab : "")
|
||||
|| (lastMutedType && mutedTypes.has(lastMutedType) ? lastMutedType : "");
|
||||
|
||||
return (
|
||||
<div className="msg-page">
|
||||
<div className="page-head">
|
||||
<button className="msg-back" type="button" onClick={() => navigate("dashboard")} aria-label="返回">
|
||||
<ArrowLeft size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
<div>
|
||||
<h1>消息中心</h1>
|
||||
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {counts.all} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
|
||||
<div className="sub">{counts.unread} 条未读 · {counts.all} 条总计 · 任务提醒、团队协作、计费与系统公告</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>
|
||||
<button className="msg-ghost" type="button" onClick={() => void markAll()} disabled={counts.unread === 0}>
|
||||
<CheckCheck size={18} />全部标已读
|
||||
</button>
|
||||
<button className="msg-ghost" type="button" onClick={() => navigate("settingsNotify")}>
|
||||
<Settings2 size={18} />通知设置
|
||||
</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">// 显示 {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)}>
|
||||
{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="搜索项目、来源、内容" />
|
||||
<section className="msg-inbox">
|
||||
<div className="msg-toolbar">
|
||||
<div className="msg-filters">
|
||||
{filters.map(([id, label, ct]) => {
|
||||
const muted = TYPE_TABS.has(id) && mutedTypes.has(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
className={`msg-filter${tab === id ? " active" : ""}${muted ? " is-muted" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label} {ct}{muted ? " · 静音" : ""}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="msg-search">
|
||||
<Search size={16} />
|
||||
<input type="text" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源或内容" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="msg-list" ref={listRef} onScroll={onScroll}>
|
||||
{visibleItems.length === 0 && !loading ? (
|
||||
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
|
||||
{bootLoading ? (
|
||||
<div className="msg-skel" aria-busy="true" aria-live="polite">
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<div className="msg-skel-row" key={i}>
|
||||
<span className="msg-skel-ico" />
|
||||
<div className="msg-skel-copy">
|
||||
<span className="msg-skel-bar" />
|
||||
<span className="msg-skel-bar msg-skel-bar-sm" />
|
||||
</div>
|
||||
<span className="msg-skel-time" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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">
|
||||
<span className="msg-item-row">
|
||||
<span className="msg-dot"></span>
|
||||
<span className="msg-item-title">{n.title}</span>
|
||||
{tabMuted && (
|
||||
<div className="msg-mute-note">
|
||||
<span>已从「全部」隐藏 · 仅在此分类查看</span>
|
||||
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => unmuteType(tab)}>取消静音</button>
|
||||
</div>
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<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)}>
|
||||
<div className={`msg-item-icon ${n.notification_type}`}>{typeIcon(n.notification_type)}</div>
|
||||
<div>
|
||||
<strong>{n.title}</strong>
|
||||
<p>{n.brief}</p>
|
||||
</div>
|
||||
<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 && visibleItems.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
|
||||
</button>
|
||||
))}
|
||||
{(loading || hasMore) && (
|
||||
<div className="msg-load-more">
|
||||
{loading ? <span className="msg-skel-more" aria-label="加载中"><span /><span /><span /></span> : "滚动加载更多"}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !hasMore && items.length > 0 && <div className="msg-load-more">已全部加载</div>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="msg-panel msg-detail">
|
||||
{selected ? (
|
||||
<section className="msg-detail">
|
||||
{bootLoading ? (
|
||||
<div className="msg-detail-skel" aria-busy="true">
|
||||
<span className="msg-skel-bar msg-skel-title" />
|
||||
<span className="msg-skel-bar msg-skel-lead" />
|
||||
<span className="msg-skel-bar msg-skel-lead-2" />
|
||||
<div className="msg-skel-summary">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div className="msg-skel-kv" key={i}>
|
||||
<span className="msg-skel-bar msg-skel-k" />
|
||||
<span className="msg-skel-bar msg-skel-v" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : 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>
|
||||
<h2>{selected.title}</h2>
|
||||
<p>{selected.body || selected.brief}</p>
|
||||
<div className="msg-summary">
|
||||
<span>来源</span><strong>{selected.source || ZH_TYPE[selected.notification_type] || "—"}</strong>
|
||||
<span>类别</span><strong>{ZH_TYPE[selected.notification_type] || "—"}</strong>
|
||||
<span>项目</span><strong>{selected.project_name || selected.project || "—"}</strong>
|
||||
<span>阶段</span><strong>{selected.stage || "—"}</strong>
|
||||
<span>负责人</span><strong>{selected.owner_label || "—"}</strong>
|
||||
<span>费用</span><strong>{selected.cost_label || "—"}</strong>
|
||||
<span>时间</span><strong>{fmtFull(selected.created_at)}</strong>
|
||||
<span>关联资源</span>
|
||||
{selected.related_url
|
||||
? <button className="msg-related" type="button" onClick={() => navigate(target)}>{routeLabels[target]} →</button>
|
||||
: <strong>无</strong>}
|
||||
</div>
|
||||
{(() => {
|
||||
const timeline = readTimeline(selected.metadata);
|
||||
@@ -379,47 +500,48 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
})()}
|
||||
{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>;
|
||||
: <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>
|
||||
<button className="btn btn-ghost" type="button" onClick={() => muteType(selected)}>
|
||||
<BellOff size={14} />静音同类
|
||||
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => archiveOne(selected)}>归档</button>
|
||||
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => (unmuteTarget ? unmuteType(unmuteTarget) : muteType(selected))}>
|
||||
<BellOff size={16} />{unmuteTarget ? "取消静音" : "静音同类"}
|
||||
</button>
|
||||
{!selected.is_read && !hasAck && <button className="btn btn-ghost" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
|
||||
<span className="spacer"></span>
|
||||
{!selected.is_read && !hasAck && <button className="msg-ghost msg-ghost-sm" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
|
||||
<span className="spacer" />
|
||||
{detailActions.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
className={`btn ${a.primary ? "btn-primary" : ""} ${a.kind === "log" && showLogId === selected.id ? "is-active" : ""}`}
|
||||
className={`${a.primary ? "msg-primary" : "msg-ghost msg-ghost-sm"}${a.kind === "log" && showLogId === selected.id ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => runAction(selected, a)}
|
||||
>
|
||||
{/* nav 主操作未带文案时,用解析出的目标页名兜底 */}
|
||||
{a.label || `进入${routeLabels[target]}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="msg-detail-empty"><div className="ic"><Bell /></div><div>暂无消息</div></div>
|
||||
<div className="msg-detail-empty">
|
||||
<div className="msg-detail-empty-inner">
|
||||
<div className="ic">{tabMuted ? <BellOff /> : <Bell />}</div>
|
||||
<div>{tabMuted ? `已静音${ZH_TYPE[tab] || ""}类` : "暂无消息"}</div>
|
||||
{tabMuted && (
|
||||
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => unmuteType(tab)}>取消静音</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="msg-foot-note">
|
||||
<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 className="txt">{toast.title}<span className="mono">{toast.sub}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user