550 lines
24 KiB
TypeScript
550 lines
24 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ReactNode } from "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";
|
|
import { routeLabels } from "./route-config";
|
|
|
|
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"]);
|
|
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={16} />;
|
|
if (type === "team") return <Users size={16} />;
|
|
if (type === "billing") return <CreditCard size={16} />;
|
|
return <Info size={16} />;
|
|
}
|
|
|
|
// 通知 related_url(.html 风格)→ 应用内 Page
|
|
function targetPage(n: Notification): Page {
|
|
const url = n.related_url || "";
|
|
if (url.includes("pipeline")) return "pipeline";
|
|
if (url.includes("account")) return "account";
|
|
if (url.includes("library")) return "library";
|
|
if (url.includes("settings")) return "settingsNotify";
|
|
if (url.includes("product")) return "products";
|
|
if (url.includes("team")) return "team";
|
|
return "dashboard";
|
|
}
|
|
|
|
function fmtTime(iso: string): string {
|
|
const diff = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
|
|
if (diff < 1) return "刚刚";
|
|
if (diff < 60) return `${diff}m`;
|
|
if (diff < 1440) return `${Math.floor(diff / 60)}h`;
|
|
return `${Math.floor(diff / 1440)}d`;
|
|
}
|
|
function fmtFull(iso: string): string {
|
|
const d = new Date(iso);
|
|
const z = (n: number) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
|
|
}
|
|
|
|
// 处理记录:从 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);
|
|
}
|
|
|
|
// 失败/异常日志:后端只有 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>;
|
|
onMarkAllRead: () => void | Promise<unknown>;
|
|
onArchive: (id: string) => void | Promise<unknown>;
|
|
navigate: (page: Page) => void;
|
|
}) {
|
|
const [tab, setTab] = useState<TabKey>("all");
|
|
const [query, setQuery] = useState("");
|
|
const [debounced, setDebounced] = useState("");
|
|
const [selectedId, setSelectedId] = useState("");
|
|
// 静音同类:记被静音的 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 注入,故就地渲染)
|
|
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 }));
|
|
const [total, setTotal] = useState(0); // 当前筛选(tab/搜索)下的总条数,作「已加载 X / Y」的分母
|
|
const [page, setPage] = useState(1); // 下一个要拉的页码
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const loadingRef = useRef(false); // 防滚动重复触发追加
|
|
const genRef = useRef(0); // 代号:tab/搜索一变就 +1,丢弃旧请求的回包
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 搜索去抖 300ms 再打服务端
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setDebounced(query.trim()), 300);
|
|
return () => clearTimeout(t);
|
|
}, [query]);
|
|
|
|
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);
|
|
toastTimer.current = setTimeout(() => setToast(null), 2800);
|
|
}
|
|
|
|
const load = useCallback(
|
|
async (pageToLoad: number, replace: boolean) => {
|
|
// 追加(滚动)要防并发;重拉(replace)不阻塞,靠代号作废在途旧请求
|
|
if (!replace && loadingRef.current) return;
|
|
const gen = replace ? (genRef.current += 1) : genRef.current;
|
|
loadingRef.current = true;
|
|
setLoading(true);
|
|
try {
|
|
const res = await api
|
|
.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) {
|
|
if (!replace) setHasMore(false);
|
|
return;
|
|
}
|
|
setItems((prev) => (replace ? res.results : [...prev, ...res.results]));
|
|
setHasMore(Boolean(res.next));
|
|
setPage(pageToLoad + 1);
|
|
setTotal(res.count);
|
|
if (res.type_counts) setCounts(res.type_counts);
|
|
} finally {
|
|
if (gen === genRef.current) {
|
|
loadingRef.current = false;
|
|
setLoading(false);
|
|
}
|
|
}
|
|
},
|
|
[tab, debounced, mutedKey]
|
|
);
|
|
|
|
// tab / 搜索 / 静音变化 → 清空重拉第 1 页(等偏好就绪,避免先拉全量再排除)
|
|
useEffect(() => {
|
|
if (!prefsReady) return;
|
|
setItems([]);
|
|
setSelectedId("");
|
|
setHasMore(false);
|
|
void load(1, true);
|
|
}, [load, prefsReady]);
|
|
|
|
// 滚到接近底部就拉下一批
|
|
const onScroll = useCallback(() => {
|
|
const el = listRef.current;
|
|
if (!el || !hasMore || loadingRef.current) return;
|
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < 120) void load(page, false);
|
|
}, [hasMore, page, load]);
|
|
|
|
// 首批撑不满面板(没出现滚动条)却还有更多 → 自动续拉,保证可触达
|
|
useEffect(() => {
|
|
const el = listRef.current;
|
|
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;
|
|
|
|
// 标记单条已读:同步后端/侧边栏徽标 + 本地乐观更新(列表与未读计数)
|
|
function markOne(id: string) {
|
|
void onMarkRead(id);
|
|
setItems((prev) => prev.map((x) => (x.id === id ? { ...x, is_read: true, unread: false } : x)));
|
|
setCounts((c) => ({ ...c, unread: Math.max(0, c.unread - 1) }));
|
|
}
|
|
|
|
function selectItem(n: Notification) {
|
|
setSelectedId(n.id);
|
|
setShowLogId(""); // 切换消息时收起上一条的日志
|
|
if (!n.is_read) markOne(n.id);
|
|
}
|
|
|
|
async function markAll() {
|
|
await onMarkAllRead();
|
|
setItems((prev) => prev.map((x) => ({ ...x, is_read: true, unread: false })));
|
|
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;
|
|
});
|
|
showToast("已归档", n.title);
|
|
}
|
|
|
|
// 静音同类:写入 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) {
|
|
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}」仍可查看,点取消静音才恢复`);
|
|
}
|
|
|
|
// 详情底栏派生动作的统一分发
|
|
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]> = [
|
|
["all", "全部", counts.all],
|
|
["unread", "未读", counts.unread],
|
|
["task", "任务", counts.task],
|
|
["team", "团队", counts.team],
|
|
["billing", "计费", counts.billing],
|
|
["system", "系统", counts.system]
|
|
];
|
|
|
|
const target = selected ? targetPage(selected) : "dashboard";
|
|
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">{counts.unread} 条未读 · {counts.all} 条总计 · 任务提醒、团队协作、计费与系统公告</div>
|
|
</div>
|
|
<div className="msg-head-actions">
|
|
<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-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}>
|
|
{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>
|
|
) : (
|
|
<>
|
|
{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>
|
|
</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-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">
|
|
<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);
|
|
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>
|
|
);
|
|
})()}
|
|
{showLogId === selected.id && (() => {
|
|
const log = readLog(selected.metadata);
|
|
return log
|
|
? <pre className="msg-log">{log}</pre>
|
|
: <pre className="msg-log msg-log-empty">暂无日志详情</pre>;
|
|
})()}
|
|
</div>
|
|
<div className="msg-detail-f">
|
|
<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="msg-ghost msg-ghost-sm" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
|
|
<span className="spacer" />
|
|
{detailActions.map((a) => (
|
|
<button
|
|
key={a.id}
|
|
className={`${a.primary ? "msg-primary" : "msg-ghost msg-ghost-sm"}${a.kind === "log" && showLogId === selected.id ? " is-active" : ""}`}
|
|
type="button"
|
|
onClick={() => runAction(selected, a)}
|
|
>
|
|
{a.label || `进入${routeLabels[target]}`}
|
|
</button>
|
|
))}
|
|
</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>
|
|
|
|
{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>
|
|
);
|
|
}
|