大量修改UI

This commit is contained in:
Azmat@qq.com
2026-08-19 17:44:56 +08:00
parent 18b3927817
commit a343f47f0c
76 changed files with 11655 additions and 6104 deletions
+230 -108
View File
@@ -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>