feat(core): notification inbox infinite scroll + command palette fix (+ pending WIP)
消息中心:全量渲染 → 真·后端分页滚动加载 - backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+ 响应回 type_counts(按收件人绝对计数,不受分页/搜索影响) - frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页; 代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数 - api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts 命令面板(侧边栏搜索):修复点开后 UI 错位 - app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下; 补回基类 + header 类名对齐 .shell-command-h - messages-page.css: 工作台收进视口高度,收件箱在面板内滚动 本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings + accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Bell, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
|
||||
import type { Notification } from "../types";
|
||||
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"]);
|
||||
|
||||
// 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: "系统" };
|
||||
|
||||
@@ -41,8 +53,7 @@ function fmtFull(iso: string): string {
|
||||
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
||||
notifications: Notification[];
|
||||
export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
||||
unreadCount: number;
|
||||
onMarkRead: (id: string) => void | Promise<unknown>;
|
||||
onMarkAllRead: () => void | Promise<unknown>;
|
||||
@@ -50,35 +61,92 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: notifications.length,
|
||||
unread: notifications.filter((n) => !n.is_read).length,
|
||||
task: notifications.filter((n) => n.notification_type === "task").length,
|
||||
team: notifications.filter((n) => n.notification_type === "team").length,
|
||||
billing: notifications.filter((n) => n.notification_type === "billing").length,
|
||||
system: notifications.filter((n) => n.notification_type === "system").length
|
||||
}),
|
||||
[notifications]
|
||||
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]);
|
||||
|
||||
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), search: debounced || undefined, page: pageToLoad, pageSize: PAGE_SIZE })
|
||||
.catch(() => null);
|
||||
if (gen !== genRef.current) return; // tab/搜索已切换,丢弃过期结果
|
||||
if (!res) 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]
|
||||
);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return notifications.filter((n) => {
|
||||
if (tab === "unread" && n.is_read) return false;
|
||||
if (!["all", "unread"].includes(tab) && n.notification_type !== tab) return false;
|
||||
if (q && ![n.title, n.brief, n.body, n.source, n.project_name, n.stage].join(" ").toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [notifications, tab, query]);
|
||||
// tab / 搜索变化 → 清空重拉第 1 页
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setSelectedId("");
|
||||
setHasMore(false);
|
||||
void load(1, true);
|
||||
}, [load]);
|
||||
|
||||
const selected = notifications.find((n) => n.id === selectedId) || visible[0] || notifications[0] || null;
|
||||
// 滚到接近底部就拉下一批
|
||||
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);
|
||||
if (!n.is_read) void onMarkRead(n.id);
|
||||
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 }));
|
||||
}
|
||||
|
||||
const filters: Array<[TabKey, string, number]> = [
|
||||
@@ -97,17 +165,17 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>消息中心</h1>
|
||||
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {notifications.length} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
|
||||
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {counts.all} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
|
||||
</div>
|
||||
<div className="msg-head-actions">
|
||||
<button className="btn" type="button" onClick={() => void onMarkAllRead()} disabled={unreadCount === 0}>全部标已读</button>
|
||||
<button className="btn" type="button" onClick={() => void markAll()} disabled={counts.unread === 0}>全部标已读</button>
|
||||
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</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">// 显示 {visible.length} 条</span></div>
|
||||
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 已加载 {items.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)}>
|
||||
@@ -119,27 +187,31 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
||||
<Search />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||
</div>
|
||||
<div className="msg-list">
|
||||
{visible.length === 0 ? (
|
||||
<div className="msg-list" ref={listRef} onScroll={onScroll}>
|
||||
{items.length === 0 && !loading ? (
|
||||
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
|
||||
) : (
|
||||
visible.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>
|
||||
<span className="msg-time">{fmtTime(n.created_at)}</span>
|
||||
<>
|
||||
{items.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>
|
||||
<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>
|
||||
<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>
|
||||
))
|
||||
</button>
|
||||
))}
|
||||
{(loading || hasMore) && <div className="msg-load-more mono">{loading ? "// 加载中…" : "// 滚动加载更多"}</div>}
|
||||
{!loading && !hasMore && items.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -175,7 +247,7 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
||||
</div>
|
||||
</div>
|
||||
<div className="msg-detail-f">
|
||||
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => void onMarkRead(selected.id)}>标为已读</button>}
|
||||
{!selected.is_read && <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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user