perf(admin-web): 长列表虚拟滚动 + 专属页只读配置卡(裁决 16.4.3/16.4.6)+ 修限流读写成对的数据丢失

- 16.4.3(效率最高):AdminTable 超阈值走窗口化(DOM 行数与数据量解耦,上下占位行撑滚动条);
  新增可复用 VirtualList;适配器消息日志换成窗口化版
- 16.4.6(操作更便利):保留列表页内联编辑,同时把「专属页只读展示该平台完整字段」真正实现
  (AdapterConfigSummary,此前专属页一个配置字段都不显示)
- 🔴 顺带修一条静默数据丢失:列表页限流三项原从 acct.config.* 读,而后端把它们存**账号顶层**
  ⇒ 表单永远回显空、保存时 undefined→0 会把已设限流清空。现改从顶层读、留空则不发该键、非法输入报错中止;
  并顺手改正用户可见的错误文案(0 = 保护性默认 6条/分,负值才是关闭)
- 复核:tsc 0 错 + vite build 成功;channel limits 27 / admin virtual scroll 8023 项断言;3 条变异反证
This commit is contained in:
AskaEth committed 2026-10-04 13:41:08 +08:00
1 parent b480b04dd0
commit c1a98d7c01
9 files changed
+976 -22

No files matched your search

@@ -0,0 +1,93 @@
import { useMemo } from "react";
import { adminChannelMessages } from "../../../lib/api";
import type { AdminChannelMessage } from "../../../lib/types";
import { useAsyncData } from "../../../lib/useAsyncData";
import { useAppStore } from "../../../store/useAppStore";
import { Button, Tag } from "../../../components/primitives";
import { Notice, VirtualList, VIRTUAL_THRESHOLD } from "../../components";
import { useAutoReload } from "../../hooks";
import { PLATFORM_LABEL } from "../channelFields";
import { platformOf } from "./channelUtils";
import { ChannelLoadError } from "./channelStatusView";
import { ChannelLogRow } from "./channelLogList";
/** 一行消息的估算高度(px):标签行 + 正文数行,实测后会被真实值取代。 */
const MESSAGE_ROW_ESTIMATE = 76;
/** 历史(后端最新在前)与本端 WS 实时尾(已按 id 去重)合并,翻成「旧 → 新」的
* 阅读顺序。与 `ChannelLogList` 同一份口径,保证两个面看到的是同一串消息。 */
function mergeLog(
history: readonly AdminChannelMessage[] | null,
live: readonly AdminChannelMessage[],
adapter: string,
): AdminChannelMessage[] {
const base = history ? [...history].reverse() : [];
const seen = new Set(base.map((m) => m.id));
const tail = live.filter((m) => m.adapterId === adapter).reverse();
return [...base, ...tail.filter((m) => !seen.has(m.id))];
}
/** 单适配器消息日志(design/17 §2「实时日志」)。数据面与 `ChannelLogList` 相同
* (历史分页 + `channel_persisted` WS 追帧 + 10s 兜底轮询),区别只在渲染:消息是
* 本模块最长的列表,也是 WS 每来一帧就要整表重渲染的面,因此走窗口化——只挂载
* 视口内的行(裁决 16.4.3「按效率最高的做」)。加载 / 空 / 失联三种状态照旧分开。 */
export function AdapterMessageLog({
adapter,
limit = 200,
}: {
adapter: string;
limit?: number;
}): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(
() => adminChannelMessages(adapter, limit),
[adapter, limit],
);
useAutoReload(reload, 10000);
const channelEvents = useAppStore((s) => s.channelEvents);
const msgs = useMemo(() => mergeLog(data, channelEvents, adapter), [data, channelEvents, adapter]);
const platform = platformOf(adapter);
return (
<div>
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--mu-glass-border)] px-1 pb-2">
<Tag tone="accent">{PLATFORM_LABEL[platform] ?? platform}</Tag>
<Tag tone="neutral">{adapter}</Tag>
<h3 className="text-sm font-semibold">消息日志</h3>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/channels/messages</span>
<Button size="sm" variant="ghost" onClick={reload}>
刷新
</Button>
</div>
<div className="pt-3">
{loading ? (
<Notice>加载中…</Notice>
) : error && !data && msgs.length === 0 ? (
<ChannelLoadError error={error} reload={reload} what="消息日志" />
) : (
<>
<ChannelLoadError error={error} reload={reload} stale={data != null} what="消息日志" />
{msgs.length === 0 ? (
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
) : (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<span>共 {msgs.length} 条 · 上面更旧、下面更新</span>
{msgs.length > VIRTUAL_THRESHOLD ? <Tag tone="neutral">虚拟滚动 · 仅挂载视口内的行</Tag> : null}
</div>
<VirtualList
items={msgs}
rowKey={(m) => m.id}
renderRow={(m) => <ChannelLogRow m={m} />}
rowClassName="pb-2"
rowHeight={MESSAGE_ROW_ESTIMATE}
maxHeight="min(60vh, 640px)"
/>
</>
)}
</>
)}
</div>
</div>
);
}