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:
1 parent
b480b04dd0
commit
c1a98d7c01
9 files changed
+976
-22
No files matched your search
@@ -1,6 +1,13 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { EmptyState, Tag, type TagTone } from "../components/primitives";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { useVirtualRows, VIRTUAL_ROW_ESTIMATE } from "./hooks";
|
||||
|
||||
/** 行数超过它就启用虚拟滚动;小表保持原样全量渲染(窗口化对短表没有收益,
|
||||
* 还会平白多出一个滚动条)。与 `VirtualList` 的默认阈值一致。 */
|
||||
export const VIRTUAL_THRESHOLD = 60;
|
||||
/** 窗口化容器的默认最大高度(CSS 长度)。 */
|
||||
export const VIRTUAL_MAX_HEIGHT = "min(70vh, 720px)";
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
@@ -76,6 +83,10 @@ export interface AdminTableProps<T> {
|
||||
emptyTitle?: string;
|
||||
rowKey: (row: T) => string;
|
||||
onRowClick?: (row: T) => void;
|
||||
/** 行数超过它才走虚拟滚动(见 `VIRTUAL_THRESHOLD`)。 */
|
||||
virtualThreshold?: number;
|
||||
/** 虚拟滚动时滚动容器的最大高度(CSS 长度)。 */
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
/** A strict, generic data table. Loading/error/empty states are handled here so
|
||||
@@ -85,7 +96,11 @@ export interface AdminTableProps<T> {
|
||||
* backend returns them — the same data always renders in the same order (no
|
||||
* jumping on refresh). Stable keys let React reuse the row DOM and only update
|
||||
* changed cells. An error is shown only when there is no data to keep on
|
||||
* screen, so a failed silent refresh keeps the stale list visible. */
|
||||
* screen, so a failed silent refresh keeps the stale list visible.
|
||||
*
|
||||
* 超过 `virtualThreshold` 行时改走窗口化(裁决 16.4.3):只挂载视口内的行,其余
|
||||
* 用上下两条占位行撑住滚动条,因此 DOM 行数不再随数据量增长。阈值以下的行数、
|
||||
* 表头与单元格结构保持与全量渲染完全一致。 */
|
||||
export function AdminTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
@@ -94,6 +109,8 @@ export function AdminTable<T>({
|
||||
emptyTitle = "暂无数据",
|
||||
rowKey,
|
||||
onRowClick,
|
||||
virtualThreshold = VIRTUAL_THRESHOLD,
|
||||
maxHeight = VIRTUAL_MAX_HEIGHT,
|
||||
}: AdminTableProps<T>): ReactNode {
|
||||
const sorted = useMemo(() => {
|
||||
if (!rows) return rows;
|
||||
@@ -104,6 +121,14 @@ export function AdminTable<T>({
|
||||
});
|
||||
}, [rows, rowKey]);
|
||||
|
||||
const count = sorted?.length ?? 0;
|
||||
const windowed = count > virtualThreshold;
|
||||
const { range, containerRef, rowAttrs } = useVirtualRows({
|
||||
count,
|
||||
enabled: windowed,
|
||||
rowHeight: VIRTUAL_ROW_ESTIMATE,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
|
||||
}
|
||||
@@ -114,8 +139,16 @@ export function AdminTable<T>({
|
||||
if (!hasRows) {
|
||||
return <EmptyState icon={<Icon name="folder" size={32} />} title={emptyTitle} />;
|
||||
}
|
||||
|
||||
const list = sorted ?? [];
|
||||
const from = windowed ? range.start : 0;
|
||||
const visible = windowed ? list.slice(from, range.end) : list;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={windowed ? "overflow-auto" : "overflow-x-auto"}
|
||||
style={windowed ? { maxHeight } : undefined}
|
||||
>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--mu-glass-border)] text-left">
|
||||
@@ -130,9 +163,11 @@ export function AdminTable<T>({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row) => (
|
||||
{windowed && range.padTop > 0 ? <tr aria-hidden="true" style={{ height: range.padTop }} /> : null}
|
||||
{visible.map((row, i) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
{...(windowed ? rowAttrs(from + i) : {})}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={`border-b border-[var(--mu-glass-border)]/60 last:border-0 hover:bg-[var(--mu-surface-3)]/60 ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
>
|
||||
@@ -143,12 +178,71 @@ export function AdminTable<T>({
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{windowed && range.padBottom > 0 ? <tr aria-hidden="true" style={{ height: range.padBottom }} /> : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface VirtualListProps<T> {
|
||||
items: readonly T[];
|
||||
rowKey: (item: T) => string;
|
||||
renderRow: (item: T) => ReactNode;
|
||||
/** 滚动容器的最大高度(CSS 长度)。窗口化必须有确定视口。 */
|
||||
maxHeight?: string;
|
||||
/** 行高估算值(px);挂载后按实测高度修正。 */
|
||||
rowHeight?: number;
|
||||
/** 每行包裹元素的 class——行间距请用 padding,它会被实测进行高。 */
|
||||
rowClassName?: string;
|
||||
/** 少于该行数时全量渲染(与 `AdminTable` 同口径)。 */
|
||||
threshold?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 窗口化的长列表:日志 / 消息流这类「条数多、还会被 WS 持续追帧」的面用它,
|
||||
* DOM 行数与总条数解耦(裁决 16.4.3)。行渲染交给调用方,本组件只管挂载窗口与
|
||||
* 上下占位,因此每行内容、状态与全量渲染逐字一致。 */
|
||||
export function VirtualList<T>({
|
||||
items,
|
||||
rowKey,
|
||||
renderRow,
|
||||
maxHeight = VIRTUAL_MAX_HEIGHT,
|
||||
rowHeight = VIRTUAL_ROW_ESTIMATE,
|
||||
rowClassName = "",
|
||||
threshold = VIRTUAL_THRESHOLD,
|
||||
className = "",
|
||||
}: VirtualListProps<T>): ReactNode {
|
||||
const count = items.length;
|
||||
const windowed = count > threshold;
|
||||
const { range, containerRef, rowAttrs } = useVirtualRows({ count, enabled: windowed, rowHeight });
|
||||
|
||||
if (!windowed) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{items.map((item) => (
|
||||
<div key={rowKey(item)} className={rowClassName}>
|
||||
{renderRow(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const from = range.start;
|
||||
return (
|
||||
<div ref={containerRef} className={`overflow-auto ${className}`} style={{ maxHeight }}>
|
||||
<div style={{ height: range.padTop }} aria-hidden="true" />
|
||||
{items.slice(from, range.end).map((item, i) => (
|
||||
<div key={rowKey(item)} {...rowAttrs(from + i)} className={rowClassName}>
|
||||
{renderRow(item)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: range.padBottom }} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface KeyValueRowProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
|
||||
/** Re-run `reload` on a fixed interval (ms). Pass null to disable. Backend data
|
||||
* tables refresh this way; WS-backed surfaces use the app store instead. */
|
||||
@@ -9,3 +17,167 @@ export function useAutoReload(reload: () => void, intervalMs: number | null): vo
|
||||
return () => window.clearInterval(id);
|
||||
}, [reload, intervalMs]);
|
||||
}
|
||||
|
||||
// --- 虚拟滚动(裁决 16.4.3「按效率最高的做」)--------------------------------
|
||||
//
|
||||
// 长列表(表格 / 日志 / 消息流)此前把每一行都渲染成 DOM:行数一多,布局、绘制
|
||||
// 与「WS 每帧重渲染整个列表」的开销都随行数线性上涨。窗口化只挂载视口内的行,
|
||||
// 其余用上下占位撑住滚动条,DOM 数量与总行数解耦——这是运行效率最高的一档,且
|
||||
// 不引入第三方依赖(package.json 不可改)。
|
||||
//
|
||||
// 行高:未挂载过的行按估算值占位,挂载后实测真实高度并缓存(表格里单元格换行,
|
||||
// 行高并不整齐,固定行高的做法会把行叠在一起)。
|
||||
|
||||
/** 未实测行的行高估算(px);挂载一次后就被真实高度取代。 */
|
||||
export const VIRTUAL_ROW_ESTIMATE = 44;
|
||||
/** 视口上下各多挂载几行,避免快速滚动时露出空白。 */
|
||||
export const VIRTUAL_OVERSCAN = 6;
|
||||
/** 行高变化小于这个像素数就不重算,避免「实测 → 重渲染 → 再实测」空转。 */
|
||||
const HEIGHT_EPSILON = 0.5;
|
||||
/** 关闭窗口化时的「无边界视口」:全部行都算可见。 */
|
||||
const UNBOUNDED_VIEWPORT = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export interface VirtualWindow {
|
||||
/** 首个挂载的行下标(含)。 */
|
||||
start: number;
|
||||
/** 末个挂载的行下标(不含)。 */
|
||||
end: number;
|
||||
/** 窗口上方占位高度(px)——撑住滚动条,让未挂载的行不占 DOM。 */
|
||||
padTop: number;
|
||||
/** 窗口下方占位高度(px)。 */
|
||||
padBottom: number;
|
||||
/** 全部行的合计高度(px)。 */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 纯函数:由「各行高度 + 滚动位置 + 视口高度」算出要挂载的窗口与上下占位。
|
||||
* 虚拟滚动的数学全在这里,UI 只负责把 padTop / padBottom 铺成占位行。 */
|
||||
export function computeVirtualWindow(
|
||||
heights: readonly number[],
|
||||
scrollTop: number,
|
||||
viewport: number,
|
||||
overscan: number = VIRTUAL_OVERSCAN,
|
||||
): VirtualWindow {
|
||||
const count = heights.length;
|
||||
if (count === 0) return { start: 0, end: 0, padTop: 0, padBottom: 0, total: 0 };
|
||||
|
||||
// 前缀和:offsets[i] = 前 i 行的累计高度。
|
||||
const offsets = new Array<number>(count + 1);
|
||||
offsets[0] = 0;
|
||||
for (let i = 0; i < count; i += 1) offsets[i + 1] = offsets[i] + Math.max(0, heights[i]);
|
||||
const total = offsets[count];
|
||||
|
||||
// 滚动位置越界(列表变短、数据被替换)时夹回有效区间,否则会渲染出空白窗口。
|
||||
const top = Math.min(Math.max(0, scrollTop), Math.max(0, total - Math.max(0, viewport)));
|
||||
const bottom = top + Math.max(0, viewport);
|
||||
|
||||
// 二分:第一行「下沿越过 top」的行 = 首个可见行。
|
||||
let lo = 0;
|
||||
let hi = count;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (offsets[mid + 1] <= top) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const first = Math.min(lo, count - 1);
|
||||
|
||||
// 二分:第一行「上沿到达 bottom」的行 = 末个可见行(不含)。
|
||||
let loEnd = first;
|
||||
let hiEnd = count;
|
||||
while (loEnd < hiEnd) {
|
||||
const mid = (loEnd + hiEnd) >> 1;
|
||||
if (offsets[mid] < bottom) loEnd = mid + 1;
|
||||
else hiEnd = mid;
|
||||
}
|
||||
const last = Math.max(first + 1, loEnd);
|
||||
|
||||
const start = Math.max(0, first - Math.max(0, overscan));
|
||||
const end = Math.min(count, last + Math.max(0, overscan));
|
||||
return { start, end, padTop: offsets[start], padBottom: total - offsets[end], total };
|
||||
}
|
||||
|
||||
export interface UseVirtualRowsOptions {
|
||||
/** 总行数。 */
|
||||
count: number;
|
||||
/** false = 关闭窗口化(行数少时全量渲染,也不挂滚动监听)。 */
|
||||
enabled?: boolean;
|
||||
/** 未实测行的行高估算值(px)。 */
|
||||
rowHeight?: number;
|
||||
overscan?: number;
|
||||
}
|
||||
|
||||
export interface VirtualRows {
|
||||
range: VirtualWindow;
|
||||
/** 绑到滚动容器上——容器必须有确定高度,否则没有可滚动视口。 */
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
/** 逐行铺开的属性;`data-vrow` 是实测行高用的下标标记,别漏。 */
|
||||
rowAttrs: (index: number) => { "data-vrow": number };
|
||||
}
|
||||
|
||||
/** 窗口化一个滚动容器里的若干行:只挂载视口内的行。 */
|
||||
export function useVirtualRows({
|
||||
count,
|
||||
enabled = true,
|
||||
rowHeight = VIRTUAL_ROW_ESTIMATE,
|
||||
overscan = VIRTUAL_OVERSCAN,
|
||||
}: UseVirtualRowsOptions): VirtualRows {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const measured = useRef<number[]>([]);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewport, setViewport] = useState(0);
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
const heights = useMemo(() => {
|
||||
const out = new Array<number>(count);
|
||||
for (let i = 0; i < count; i += 1) out[i] = measured.current[i] ?? rowHeight;
|
||||
return out;
|
||||
}, [count, rowHeight, revision]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root || !enabled) return;
|
||||
const sync = (): void => {
|
||||
setScrollTop(root.scrollTop);
|
||||
setViewport(root.clientHeight);
|
||||
};
|
||||
sync();
|
||||
root.addEventListener("scroll", sync, { passive: true });
|
||||
// 容器尺寸变化(窗口缩放、面板折叠)同样改变视口高度。
|
||||
const observer = new ResizeObserver(sync);
|
||||
observer.observe(root);
|
||||
return () => {
|
||||
root.removeEventListener("scroll", sync);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
// 每次渲染后实测已挂载行的真实高度:估算换成实测,上下占位才准。
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
let changed = false;
|
||||
root.querySelectorAll<HTMLElement>("[data-vrow]").forEach((el) => {
|
||||
const index = Number(el.dataset.vrow);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= count) return;
|
||||
const height = el.offsetHeight;
|
||||
if (height > 0 && Math.abs((measured.current[index] ?? rowHeight) - height) > HEIGHT_EPSILON) {
|
||||
measured.current[index] = height;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) setRevision((r) => r + 1);
|
||||
});
|
||||
|
||||
const range = useMemo(
|
||||
() =>
|
||||
enabled
|
||||
? computeVirtualWindow(heights, scrollTop, viewport, overscan)
|
||||
: computeVirtualWindow(heights, 0, UNBOUNDED_VIEWPORT, 0),
|
||||
[enabled, heights, scrollTop, viewport, overscan],
|
||||
);
|
||||
|
||||
const rowAttrs = useCallback((index: number) => ({ "data-vrow": index }), []);
|
||||
|
||||
return { range, containerRef, rowAttrs };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useAuditStore } from "../audit";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { Icon } from "../../components/Icon";
|
||||
import { withChannelPersona } from "./channels/channelPersona";
|
||||
import { limitRequestBody } from "./channels/channelUtils";
|
||||
import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_LABEL } from "./channelFields";
|
||||
|
||||
/** Prefill bundle computed by the module's openConfig from the backend record
|
||||
@@ -98,6 +99,14 @@ export function ChannelConfigForm({
|
||||
} else if (Object.keys(specificMap).length) {
|
||||
configMap = specificMap;
|
||||
}
|
||||
// 限流三键:留空的**不进请求体**(后端 int 契约下省略 = 0 = 未设置),非法输入整单
|
||||
// 拒绝 —— 已设限额只可能被「没回显出来」或「NaN → null」清掉,两条路都在这里封死。
|
||||
const limits = limitRequestBody({ groupMsgPerMin, msgPerMin, maxMessages });
|
||||
if (!limits.ok) {
|
||||
onError(limits.error);
|
||||
setBusy(null);
|
||||
return;
|
||||
}
|
||||
adminSaveChannelConfig(
|
||||
platform,
|
||||
withChannelPersona(
|
||||
@@ -110,9 +119,7 @@ export function ChannelConfigForm({
|
||||
denyGroups: toList(denyGroups),
|
||||
allowUsers: toList(allowUsers),
|
||||
denyUsers: toList(denyUsers),
|
||||
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
|
||||
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
|
||||
maxMessages: maxMessages ? Number(maxMessages) : undefined,
|
||||
...limits.body,
|
||||
},
|
||||
{ id: personaId, dirty: personaDirty },
|
||||
),
|
||||
@@ -227,14 +234,23 @@ export function ChannelConfigForm({
|
||||
业务策略(黑白名单 / 群聊限流——不参与连接配置)
|
||||
</button>
|
||||
{polOpen ? (
|
||||
<div className="grid grid-cols-1 gap-2 px-3 pb-3 pt-1 sm:grid-cols-2">
|
||||
<Field label="allowGroups(逗号分隔白名单)" value={allowGroups} onChange={setAllowGroups} />
|
||||
<Field label="denyGroups(黑名单)" value={denyGroups} onChange={setDenyGroups} />
|
||||
<Field label="allowUsers(白名单)" value={allowUsers} onChange={setAllowUsers} />
|
||||
<Field label="denyUsers(黑名单)" value={denyUsers} onChange={setDenyUsers} />
|
||||
<Field label="groupMsgPerMin(群聊限流,≤0 关闭)" value={groupMsgPerMin} onChange={setGroupMsgPerMin} inputMode="numeric" />
|
||||
<Field label="msgPerMin" value={msgPerMin} onChange={setMsgPerMin} inputMode="numeric" />
|
||||
<Field label="maxMessages" value={maxMessages} onChange={setMaxMessages} inputMode="numeric" />
|
||||
<div className="space-y-2 px-3 pb-3 pt-1">
|
||||
{/* 数值语义按 internal/services/channelbridge/flowgate.go 的 flowOrDefault:
|
||||
0 = 保护性默认(群 6 条/分、待处理上限 50),负值 = 关闭限制;msgPerMin 只认正值。 */}
|
||||
<p className="text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
|
||||
留空 = 未设置(不动盘上的值);<code>groupMsgPerMin</code>/<code>maxMessages</code> 填
|
||||
<strong className="font-medium"> 0 = 该渠道的保护性默认</strong>(群 6 条/分、待处理 50 条),
|
||||
<strong className="font-medium">负值 = 关闭该限制</strong>;<code>msgPerMin</code> 只认正值(留空/0 = 不限)。
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Field label="allowGroups(逗号分隔白名单)" value={allowGroups} onChange={setAllowGroups} />
|
||||
<Field label="denyGroups(黑名单)" value={denyGroups} onChange={setDenyGroups} />
|
||||
<Field label="allowUsers(白名单)" value={allowUsers} onChange={setAllowUsers} />
|
||||
<Field label="denyUsers(黑名单)" value={denyUsers} onChange={setDenyUsers} />
|
||||
<Field label="groupMsgPerMin(每群每分钟上限)" value={groupMsgPerMin} onChange={setGroupMsgPerMin} inputMode="numeric" />
|
||||
<Field label="msgPerMin(每分钟总上限)" value={msgPerMin} onChange={setMsgPerMin} inputMode="numeric" />
|
||||
<Field label="maxMessages(待处理消息上限)" value={maxMessages} onChange={setMaxMessages} inputMode="numeric" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { AdminChannelAccount } from "../../../lib/types";
|
||||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||||
import { Notice } from "../../components";
|
||||
import { PLATFORM_FIELDS, PLATFORM_LABEL } from "../channelFields";
|
||||
|
||||
/** 一条只读字段。 */
|
||||
interface Field {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** 通用连接键(各平台都有;平台专属键见 channelFields 的 PLATFORM_FIELDS)。 */
|
||||
const COMMON_CONNECTION_KEYS = ["bot_token", "base_url"];
|
||||
|
||||
/** 限流 / 会话额度**不在 config 里**:后端把它们作为账号顶层字段下发
|
||||
* (`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/MaxMessages`,见
|
||||
* `internal/services/gateway/admin_write_channel_config.go`),且 `omitempty`
|
||||
* ⇒ 缺省 / 0 就是「不限」。读错来源会让面板把已设的限额显示成未设置。 */
|
||||
function fmtLimit(value?: number): string {
|
||||
return value === undefined || value === null || value === 0 ? "未设置(不限)" : String(value);
|
||||
}
|
||||
|
||||
/** 只读展示用的值格式化:空值明确写成「未设置」,对象/数组走 JSON 串,其余原样。
|
||||
* 绝不在这里造值——后端给什么显示什么(后端已对 token 类键做掩码)。 */
|
||||
function fmtValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "未设置";
|
||||
if (typeof value === "string") return value.trim() === "" ? "未设置" : value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "(无法序列化)";
|
||||
}
|
||||
}
|
||||
|
||||
/** 列表类字段:空列表写成「未设置」而不是一个空白的逗号串。 */
|
||||
function fmtList(values?: readonly string[]): string {
|
||||
return values && values.length > 0 ? values.join("、") : "未设置";
|
||||
}
|
||||
|
||||
/** 该平台连接字段的键:通用键 + 平台专属键(channelFields 是字段元数据的单一
|
||||
* 真源,此处不另造一份字段表)。 */
|
||||
function connectionKeys(platform: string): string[] {
|
||||
return [...COMMON_CONNECTION_KEYS, ...(PLATFORM_FIELDS[platform] ?? []).map((f) => f.key)];
|
||||
}
|
||||
|
||||
function connectionFields(platform: string, account: AdminChannelAccount): Field[] {
|
||||
const cfg = account.config ?? {};
|
||||
const out: Field[] = [{ label: "账号 id", value: account.id.trim() || "default" }];
|
||||
for (const key of COMMON_CONNECTION_KEYS) out.push({ label: key, value: fmtValue(cfg[key]) });
|
||||
for (const f of PLATFORM_FIELDS[platform] ?? []) {
|
||||
out.push({ label: f.label, value: fmtValue(cfg[f.key]) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 业务字段:角色绑定 + 限流(账号顶层字段)+ 白/黑名单。 */
|
||||
function businessFields(account: AdminChannelAccount): Field[] {
|
||||
return [
|
||||
{ label: "personaId(绑定角色)", value: account.personaId?.trim() ? account.personaId : "跟随默认角色" },
|
||||
{ label: "msgPerMin(私聊每分钟上限)", value: fmtLimit(account.msgPerMin) },
|
||||
{ label: "groupMsgPerMin(群每分钟上限)", value: fmtLimit(account.groupMsgPerMin) },
|
||||
{ label: "maxMessages(会话消息窗口)", value: fmtLimit(account.maxMessages) },
|
||||
{ label: "allowGroups(群白名单)", value: fmtList(account.allowGroups) },
|
||||
{ label: "denyGroups(群黑名单)", value: fmtList(account.denyGroups) },
|
||||
{ label: "allowUsers(用户白名单)", value: fmtList(account.allowUsers) },
|
||||
{ label: "denyUsers(用户黑名单)", value: fmtList(account.denyUsers) },
|
||||
];
|
||||
}
|
||||
|
||||
/** 平台回填的身份(连接后由适配器从平台取回)。 */
|
||||
function identityFields(account: AdminChannelAccount): Field[] {
|
||||
return [
|
||||
{ label: "bot_id", value: fmtValue(account.botId) },
|
||||
{ label: "bot_username", value: fmtValue(account.botUsername) },
|
||||
{ label: "bot_nickname", value: fmtValue(account.botNickname) },
|
||||
];
|
||||
}
|
||||
|
||||
/** config 里其余的字段(如 kook 的 receive_mode / webhook_path,或「高级选项 →
|
||||
* 额外 config」写下的一切),原样列出,一个都不吞。 */
|
||||
function extraFields(platform: string, account: AdminChannelAccount): Field[] {
|
||||
const skip = new Set(connectionKeys(platform));
|
||||
const cfg = account.config ?? {};
|
||||
return Object.keys(cfg)
|
||||
.filter((key) => !skip.has(key))
|
||||
.sort()
|
||||
.map((key) => ({ label: key, value: fmtValue(cfg[key]) }));
|
||||
}
|
||||
|
||||
function FieldGroup({ title, fields }: { title: string; fields: Field[] }): React.ReactNode {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-[var(--mu-text-lo)]">{title}</div>
|
||||
{fields.length === 0 ? (
|
||||
<div className="text-xs text-[var(--mu-text-lo)]">无</div>
|
||||
) : (
|
||||
<dl className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
|
||||
{fields.map((f) => (
|
||||
<div
|
||||
key={f.label}
|
||||
className="flex items-start justify-between gap-3 border-b border-[var(--mu-glass-border)]/50 py-1"
|
||||
>
|
||||
<dt className="shrink-0 text-xs text-[var(--mu-text-lo)]">{f.label}</dt>
|
||||
<dd className="min-w-0 break-all text-right font-mono text-xs text-[var(--mu-text-hi)]">
|
||||
{f.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 该配置的完整字段(只读展示,design/17 §2)。裁决 16.4.6 取「操作更便利」的一侧:
|
||||
* 配置字段在列表页内联展开编辑(少跳一层),所以专属页不再复制一份表单,只把后端
|
||||
* 给出的真实字段摊开,并给一个回到列表页编辑的入口。token 类键由后端掩码后下发,
|
||||
* 前端只转述、不还原。 */
|
||||
export function AdapterConfigSummary({
|
||||
platform,
|
||||
account,
|
||||
unknown,
|
||||
onEdit,
|
||||
}: {
|
||||
platform: string;
|
||||
account?: AdminChannelAccount;
|
||||
/** 渠道列表读取失败:账号字段无从确认,不能显示成「没配置」。 */
|
||||
unknown: boolean;
|
||||
/** 列表页的内联表单是唯一编辑入口,这里只提供跳转。 */
|
||||
onEdit: () => void;
|
||||
}): React.ReactNode {
|
||||
const label = PLATFORM_LABEL[platform] ?? platform;
|
||||
return (
|
||||
<Surface gap="sm" className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">配置字段(只读)</h3>
|
||||
<Tag tone="neutral">{label}</Tag>
|
||||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">编辑在列表页内联展开</span>
|
||||
<Button size="sm" variant="soft" onClick={onEdit}>
|
||||
去列表页编辑
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{unknown ? (
|
||||
<Notice tone="err">读取失败(与后端失联),无法确认该配置的字段;请先确认后端在线后重试。</Notice>
|
||||
) : !account ? (
|
||||
<Notice tone="warn">
|
||||
channels.json 中没有该账号(可能已被删除或改名);请回到「活动适配器列表」确认后再进入本页。
|
||||
</Notice>
|
||||
) : (
|
||||
<>
|
||||
<FieldGroup title="连接字段" fields={connectionFields(platform, account)} />
|
||||
<FieldGroup title="平台回填身份(连接后自动获取)" fields={identityFields(account)} />
|
||||
<FieldGroup title="业务字段" fields={businessFields(account)} />
|
||||
<FieldGroup title="其他 config 字段" fields={extraFields(platform, account)} />
|
||||
<Notice>字段取自后端的实时渠道配置(GET /api/v1/admin/channels);token 类键只下发掩码值,面板不持有原文。</Notice>
|
||||
</>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { useAutoReload } from "../../hooks";
|
||||
import { Notice, PageHeader } from "../../components";
|
||||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||||
import { PLATFORM_LABEL } from "../channelFields";
|
||||
import { ChannelLogList } from "./channelLogList";
|
||||
import { AdapterConfigSummary } from "./AdapterConfigSummary";
|
||||
import { AdapterMessageLog } from "./AdapterMessageLog";
|
||||
import { ChannelIdentityTest } from "./channelIdentityTest";
|
||||
import { KookPanel } from "./KookPanel";
|
||||
import { WeixinLogin } from "./WeixinLogin";
|
||||
@@ -15,8 +16,9 @@ import { channelStatus, type ChannelStatus } from "./channelStatus";
|
||||
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
|
||||
/** One configured account's own page (design/17 §2). This round: realtime log +
|
||||
* 功能操作 (connect/disconnect/delete/identity). Full platform config fields are P2. */
|
||||
/** One configured account's own page (design/17 §2): 只读展示该配置的完整字段 +
|
||||
* 连接状态 + 功能操作 + 实时日志。配置字段本身在列表页内联展开编辑(裁决 16.4.6
|
||||
* 「后者在操作上方便一些」),本页只给一个回到列表页编辑的入口,不复制一份表单。 */
|
||||
export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack: () => void }): React.ReactNode {
|
||||
const sep = adapter.indexOf(":");
|
||||
const platform = sep >= 0 ? adapter.slice(0, sep) : adapter;
|
||||
@@ -69,6 +71,13 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
|
||||
|
||||
<ChannelIdentityTest defaultPlatform={platform} disabled={busy !== null} />
|
||||
|
||||
<AdapterConfigSummary
|
||||
platform={platform}
|
||||
account={account}
|
||||
unknown={statusUnknown}
|
||||
onEdit={onBack}
|
||||
/>
|
||||
|
||||
{platform === "kook" ? (
|
||||
<KookPanel platform={platform} accountId={accountId} />
|
||||
) : platform === "weixin-ilink" ? (
|
||||
@@ -78,7 +87,7 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
|
||||
)}
|
||||
|
||||
<div className="rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-3)] border border-[var(--mu-glass-border)] shadow-[var(--mu-shadow-1)] p-5">
|
||||
<ChannelLogList adapter={adapter} limit={200} />
|
||||
<AdapterMessageLog adapter={adapter} limit={200} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Logic + render test for the windowed long lists of the admin panel (裁决 16.4.3
|
||||
// 「按效率最高的做」). The bugs this guards against:
|
||||
// 1. the window math and the two spacer heights drift apart → rows shift by whole
|
||||
// row-heights while scrolling, or the visible band falls outside the mounted
|
||||
// window and the list goes blank mid-scroll;
|
||||
// 2. `AdminTable` stops trimming at the threshold → the DOM count grows linearly
|
||||
// again (the thing the ruling asked to fix);
|
||||
// 3. the read-only config card reads 限流 from `config` instead of the account's
|
||||
// top-level fields (where the gateway actually sends them) → imposed limits
|
||||
// render as「未设置」.
|
||||
// The project has no frontend test runner, so this is a plain-node check driving the
|
||||
// real functions/components and throwing on the first failure. Run it with the
|
||||
// esbuild that already ships with vite (no new dependency, no package.json change).
|
||||
// The render section pulls in `react-dom/server`, whose CJS build needs `require`,
|
||||
// so this one must be bundled as CJS (the ESM output breaks on `require("util")`):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/channels/adminVirtualScroll.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-admin-virtual-scroll.test.cjs \
|
||||
// && node /tmp/cyrene-admin-virtual-scroll.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||
// imports nothing from node:*. It lives in this directory because the module owns
|
||||
// the long lists (消息日志) that consume the windowing.
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { AdminTable, VIRTUAL_THRESHOLD, type AdminColumn } from "../../components";
|
||||
import { VIRTUAL_OVERSCAN, computeVirtualWindow } from "../../hooks";
|
||||
import { AdapterConfigSummary } from "./AdapterConfigSummary";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
ok(
|
||||
JSON.stringify(got) === JSON.stringify(want),
|
||||
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function count(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
// ── 1. Deterministic window cases: exact window + exact spacer heights ─────────
|
||||
const uniform = Array.from({ length: 100 }, () => 20);
|
||||
|
||||
same(
|
||||
computeVirtualWindow([], 0, 500, 2),
|
||||
{ start: 0, end: 0, padTop: 0, padBottom: 0, total: 0 },
|
||||
"empty list renders nothing and no spacers",
|
||||
);
|
||||
same(
|
||||
computeVirtualWindow(uniform, 0, 100, 0),
|
||||
{ start: 0, end: 5, padTop: 0, padBottom: 1900, total: 2000 },
|
||||
"top of a uniform list mounts the first screen only",
|
||||
);
|
||||
same(
|
||||
computeVirtualWindow(uniform, 400, 100, 0),
|
||||
{ start: 20, end: 25, padTop: 400, padBottom: 1500, total: 2000 },
|
||||
"mid-list padTop equals the exact accumulated height above the window",
|
||||
);
|
||||
same(
|
||||
computeVirtualWindow(uniform, 400, 100, 2),
|
||||
{ start: 18, end: 27, padTop: 360, padBottom: 1460, total: 2000 },
|
||||
"overscan mounts 2 extra rows on each side",
|
||||
);
|
||||
same(
|
||||
computeVirtualWindow(uniform, 99999, 100, 0),
|
||||
{ start: 95, end: 100, padTop: 1900, padBottom: 0, total: 2000 },
|
||||
"scrolling past the end clamps to the last screen (list shrank / data replaced)",
|
||||
);
|
||||
same(
|
||||
computeVirtualWindow([10, 100, 10], 0, 50, 0),
|
||||
{ start: 0, end: 2, padTop: 0, padBottom: 10, total: 120 },
|
||||
"variable row heights use the real accumulated heights",
|
||||
);
|
||||
same(
|
||||
computeVirtualWindow(uniform, 0, 0, 0),
|
||||
{ start: 0, end: 1, padTop: 0, padBottom: 1980, total: 2000 },
|
||||
"a viewport that is not measured yet still mounts one row",
|
||||
);
|
||||
|
||||
// ── 2. Randomised invariants: height conservation + visible band in the window ─
|
||||
let seed = 42;
|
||||
const rnd = (): number => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
|
||||
for (let iter = 0; iter < 2000; iter += 1) {
|
||||
const n = 1 + Math.floor(rnd() * 240);
|
||||
const heights = Array.from({ length: n }, () => 8 + Math.floor(rnd() * 120));
|
||||
const total = heights.reduce((a, b) => a + b, 0);
|
||||
const viewport = 50 + Math.floor(rnd() * 400);
|
||||
const scrollTop = rnd() * (total + 400) - 200;
|
||||
const overscan = Math.floor(rnd() * 5);
|
||||
const w = computeVirtualWindow(heights, scrollTop, viewport, overscan);
|
||||
|
||||
ok(w.end > w.start, `iter ${iter}: window must never be empty`);
|
||||
ok(Math.abs(w.total - total) < 1e-9, `iter ${iter}: total height must be the sum of the rows`);
|
||||
const mounted = heights.slice(w.start, w.end).reduce((a, b) => a + b, 0);
|
||||
ok(
|
||||
Math.abs(w.padTop + mounted + w.padBottom - w.total) < 1e-9,
|
||||
`iter ${iter}: spacers + mounted rows must equal the full height`,
|
||||
);
|
||||
|
||||
const clampedTop = Math.min(Math.max(0, scrollTop), Math.max(0, total - viewport));
|
||||
let visibleStart = 0;
|
||||
let acc = 0;
|
||||
while (visibleStart < n - 1 && acc + heights[visibleStart] <= clampedTop) {
|
||||
acc += heights[visibleStart];
|
||||
visibleStart += 1;
|
||||
}
|
||||
let visibleEnd = visibleStart;
|
||||
let accEnd = acc;
|
||||
while (visibleEnd < n && accEnd < clampedTop + viewport) {
|
||||
accEnd += heights[visibleEnd];
|
||||
visibleEnd += 1;
|
||||
}
|
||||
ok(
|
||||
visibleStart >= w.start && visibleEnd <= w.end,
|
||||
`iter ${iter}: visible ${visibleStart}..${visibleEnd} must sit inside window ${w.start}..${w.end}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Real render of AdminTable: the threshold actually trims the DOM ─────────
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
const columns: AdminColumn<Row>[] = [
|
||||
{ key: "name", title: "名称", render: (r) => r.id },
|
||||
];
|
||||
|
||||
function renderTable(count: number): string {
|
||||
const rows = Array.from({ length: count }, (_, i) => ({ id: `row-${i}`, name: `行 ${i}` }));
|
||||
return renderToStaticMarkup(
|
||||
createElement(AdminTable<Row>, {
|
||||
columns,
|
||||
rows,
|
||||
loading: false,
|
||||
error: null,
|
||||
rowKey: (r: Row) => r.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 服务器渲染时容器尚未测量(视口 0),因此只挂载「首行 + 上下 overscan」这一窗口。
|
||||
const big = renderTable(VIRTUAL_THRESHOLD + 40);
|
||||
same(count(big, "data-vrow="), 1 + VIRTUAL_OVERSCAN, "long table mounts only the first window");
|
||||
ok(!big.includes("row-99"), "long table must not mount the last row (nothing outside the window)");
|
||||
ok(big.includes("data-vrow=\"0\""), "long table mounts the first row");
|
||||
ok(
|
||||
count(big, "<tr") === 1 + (1 + VIRTUAL_OVERSCAN) + 1,
|
||||
"long table DOM = header + mounted rows + bottom spacer (top spacer is 0px tall, so omitted)",
|
||||
);
|
||||
|
||||
// 阈值以下必须与历史行为完全一致:全量渲染、无占位行、无 data-vrow。
|
||||
const small = renderTable(VIRTUAL_THRESHOLD - 1);
|
||||
same(count(small, "data-vrow="), 0, "short table renders exactly as before (no windowing)");
|
||||
same(count(small, "<tr"), 1 + VIRTUAL_THRESHOLD - 1, "short table mounts every row");
|
||||
ok(small.includes(`row-${VIRTUAL_THRESHOLD - 2}`), "short table keeps the last row in the DOM");
|
||||
|
||||
// 三种状态照旧区分(「后端不可达」≠「列表为空」)。
|
||||
ok(
|
||||
renderToStaticMarkup(createElement(AdminTable<Row>, { columns, rows: null, loading: true, error: null, rowKey: (r: Row) => r.id })).includes("加载中"),
|
||||
"loading is its own state",
|
||||
);
|
||||
ok(
|
||||
renderToStaticMarkup(createElement(AdminTable<Row>, { columns, rows: null, loading: false, error: "与后端失联", rowKey: (r: Row) => r.id })).includes("与后端失联"),
|
||||
"a failed first read shows the failure, not an empty list",
|
||||
);
|
||||
ok(
|
||||
renderToStaticMarkup(createElement(AdminTable<Row>, { columns, rows: [], loading: false, error: null, rowKey: (r: Row) => r.id })).includes("暂无数据"),
|
||||
"an empty but read list shows the empty state",
|
||||
);
|
||||
|
||||
// ── 4. Read-only config card: 限流 comes from the account's top-level fields ────
|
||||
const account = {
|
||||
id: "conf0",
|
||||
config: { bot_token: "****1234", base_url: "https://kook.example", receive_mode: "ws" },
|
||||
msgPerMin: 30,
|
||||
groupMsgPerMin: 5,
|
||||
allowUsers: ["10001"],
|
||||
};
|
||||
const card = renderToStaticMarkup(
|
||||
createElement(AdapterConfigSummary, { platform: "kook", account, unknown: false, onEdit: () => {} }),
|
||||
);
|
||||
ok(card.includes(">30</dd>"), "msgPerMin is read from the account top-level field");
|
||||
ok(card.includes(">5</dd>"), "groupMsgPerMin is read from the account top-level field");
|
||||
ok(card.includes("未设置(不限)"), "an absent limit is shown as 未设置, not invented");
|
||||
ok(card.includes("****1234"), "the masked token the backend sent is shown verbatim");
|
||||
ok(card.includes("receive_mode"), "every other config key is still listed");
|
||||
ok(
|
||||
renderToStaticMarkup(
|
||||
createElement(AdapterConfigSummary, { platform: "kook", account: undefined, unknown: true, onEdit: () => {} }),
|
||||
).includes("与后端失联"),
|
||||
"an unreadable channel list is reported as such, never as「没配置」",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`admin virtual scroll test: ${checks} checks passed(窗口数学 7 组确定性用例 + 2000 轮随机不变量 + AdminTable/配置卡真实渲染)`,
|
||||
);
|
||||
@@ -0,0 +1,151 @@
|
||||
// 渠道配置表单的限流读数 / 写回逻辑测试(design/12 §6 策略三/四;裁决 16.4.1 无关,纯数据安全)。
|
||||
//
|
||||
// 锁两件事:
|
||||
// ① **读数来源** = 账号**顶层**字段 `groupMsgPerMin` / `msgPerMin` / `maxMessages`
|
||||
// (`AdminChannelAccount`,见 `internal/services/gateway/admin_write_channel_config.go`
|
||||
// 的 `adminChannelAccountOf`)——**不是** `config`。读错来源 ⇒ 表单永远回显空;
|
||||
// ② 由 ① 造成的连带数据丢失:回显空 ⇒ 保存时按「未设置」写 0(`omitempty`)
|
||||
// ⇒ **静默清掉主人已设的限流**。所以「没动过的字段」必须原样回传。
|
||||
//
|
||||
// 项目没有前端测试框架(表单是 React 组件,纯 node 下跑不了),所以这是一个零依赖的
|
||||
// plain-node 检查:它驱动真实的纯模块(channelUtils.ts 的 buildCfgTarget /
|
||||
// limitFieldValue / limitRequestBody,表单 save 走的就是同一个 limitRequestBody),
|
||||
// 并在第一个失败处抛出。
|
||||
//
|
||||
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelUtils.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --outfile=/tmp/cyrene-channel-limits.test.mjs \
|
||||
// && node /tmp/cyrene-channel-limits.test.mjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它
|
||||
// 只从项目内导入、不 import node:*。
|
||||
|
||||
import type { AdminChannelAccount, AdminChannelRecord } from "../../../lib/types";
|
||||
import { buildCfgTarget, limitFieldValue, limitRequestBody } from "./channelUtils";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same(got: unknown, want: unknown, what: string): void {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||
}
|
||||
|
||||
const acct = (a: Partial<AdminChannelAccount>): AdminChannelAccount =>
|
||||
({ id: "conf0", config: {}, ...a }) as AdminChannelAccount;
|
||||
|
||||
function records(account: AdminChannelAccount): AdminChannelRecord[] {
|
||||
return [{ name: "kook:conf0", connected: true, accounts: [account] }];
|
||||
}
|
||||
|
||||
/** 表单被打开时的三个限流输入值(= save 时交给 limitRequestBody 的那三个字段)。 */
|
||||
function formLimits(record: AdminChannelRecord[]): Record<string, string> {
|
||||
const { initial } = buildCfgTarget("kook:conf0", record);
|
||||
return {
|
||||
groupMsgPerMin: initial.groupMsgPerMin,
|
||||
msgPerMin: initial.msgPerMin,
|
||||
maxMessages: initial.maxMessages,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ① 读数来源:账号顶层字段回显 ─────────────────────────────────────────────
|
||||
const stored = records(acct({ groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40 }));
|
||||
const target = buildCfgTarget("kook:conf0", stored);
|
||||
same(target.initial.groupMsgPerMin, "20", "groupMsgPerMin(顶层)回显到表单");
|
||||
same(target.initial.msgPerMin, "30", "msgPerMin(顶层)回显到表单");
|
||||
same(target.initial.maxMessages, "40", "maxMessages(顶层)回显到表单");
|
||||
same(target.platform, "kook", "平台名仍按 adapter 名解析");
|
||||
|
||||
// `config` 里的同名键**不是**来源(后端从不把它们放进 config;读它等于永远读空)。
|
||||
// 这条断言就是这次回归的锁:把读数改回 `acct.config.*` 立刻变红。
|
||||
const nestedOnly = records(
|
||||
acct({ config: { groupMsgPerMin: 99, msgPerMin: 98, maxMessages: 97 } })
|
||||
);
|
||||
same(
|
||||
formLimits(nestedOnly),
|
||||
{ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" },
|
||||
"限流不从 config 读(后端把它们放在账号顶层)"
|
||||
);
|
||||
|
||||
// 缺省与 0 都渲染成空(后端 `omitempty` ⇒ 两者在盘上等价;0 的真实语义是「用保护性默认」)。
|
||||
same(
|
||||
formLimits(records(acct({ groupMsgPerMin: 0, msgPerMin: 0, maxMessages: 0 }))),
|
||||
{ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" },
|
||||
"0 与未设置都渲染成空"
|
||||
);
|
||||
same(
|
||||
formLimits(records(acct({}))),
|
||||
{ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" },
|
||||
"未设置渲染成空"
|
||||
);
|
||||
// 负值(= 关闭限制)必须原样回显,否则一次保存就把「关闭」写回默认。
|
||||
same(
|
||||
formLimits(records(acct({ groupMsgPerMin: -1 }))).groupMsgPerMin,
|
||||
"-1",
|
||||
"负值(关闭限制)原样回显"
|
||||
);
|
||||
|
||||
// ── ② 不改动就保存:已设限额原样回传,绝不被清空 ──────────────────────────────
|
||||
const untouched = limitRequestBody(formLimits(stored));
|
||||
ok(untouched.ok, "没动过的表单必须能生成请求体");
|
||||
if (untouched.ok) {
|
||||
same(
|
||||
untouched.body,
|
||||
{ groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40 },
|
||||
"没动过的字段原样回传(不清空已设限额)"
|
||||
);
|
||||
}
|
||||
|
||||
// 只改 token / 白名单(表单挂载后**没碰**限流三个框)→ 限流三键仍在请求体里。
|
||||
{
|
||||
const body: Record<string, unknown> = { id: "conf0", token: "bt", allowUsers: ["u-1"] };
|
||||
const limits = limitRequestBody(formLimits(stored));
|
||||
ok(limits.ok, "限流部分可生成");
|
||||
if (limits.ok) Object.assign(body, limits.body);
|
||||
same(
|
||||
JSON.stringify(body),
|
||||
'{"id":"conf0","token":"bt","allowUsers":["u-1"],"groupMsgPerMin":20,"msgPerMin":30,"maxMessages":40}',
|
||||
"改 token/白名单的一单保存不会把已设限额清空"
|
||||
);
|
||||
}
|
||||
|
||||
// ── ③ 未设置:该键**不进请求体**(不用 0 代替未设置) ────────────────────────
|
||||
const blank = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" });
|
||||
ok(blank.ok, "留空的三个字段是合法输入(= 未设置)");
|
||||
if (blank.ok) {
|
||||
same(JSON.stringify(blank.body), "{}", "未设置的字段不出现在请求体里");
|
||||
}
|
||||
// 只填其中一个:另外两个不出现(显式清空与未设置都不写键 —— 后端 int 契约下
|
||||
// 「键缺席」就是 0,与留空同义;要区分「保留」与「清空」,靠的是没动过的字段原样回传)。
|
||||
const partial = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "12", maxMessages: "" });
|
||||
ok(partial.ok, "部分填写是合法输入");
|
||||
if (partial.ok) {
|
||||
same(JSON.stringify(partial.body), '{"msgPerMin":12}', "只发用户真的填了的那个键");
|
||||
}
|
||||
|
||||
// ── ④ 非法输入:报错中止,绝不发 NaN(NaN → JSON null → 后端 int 静默落 0 清空限额) ──
|
||||
same(limitFieldValue("12"), 12, "整数原样解析");
|
||||
same(limitFieldValue(" 12 "), 12, "两侧空白被修剪");
|
||||
same(limitFieldValue("-1"), -1, "负值(关闭限制)解析为负数");
|
||||
same(limitFieldValue(""), undefined, "留空 = 未设置");
|
||||
same(limitFieldValue(" "), undefined, "全空白 = 未设置");
|
||||
same(limitFieldValue("abc"), null, "非数字 = 非法");
|
||||
same(limitFieldValue("6/min"), null, "带单位 = 非法");
|
||||
same(limitFieldValue("6.5"), null, "小数 = 非法(后端字段是 int)");
|
||||
same(limitFieldValue("1e3"), null, "科学计数法 = 非法(不给后端猜)");
|
||||
|
||||
const bad = limitRequestBody({ groupMsgPerMin: "abc", msgPerMin: "", maxMessages: "" });
|
||||
ok(!bad.ok, "非法值必须让整单失败,而不是发出去清掉已设限额");
|
||||
if (!bad.ok) {
|
||||
ok(bad.error.includes("groupMsgPerMin"), `错误文案点名出错的字段 — got ${bad.error}`);
|
||||
}
|
||||
|
||||
console.log(`channel limits logic test: ${checks} checks passed`);
|
||||
@@ -47,14 +47,61 @@ export function buildCfgTarget(name: string, records: AdminChannelRecord[]): Cfg
|
||||
denyGroups: (acct?.denyGroups ?? []).join(","),
|
||||
allowUsers: (acct?.allowUsers ?? []).join(","),
|
||||
denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "",
|
||||
groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin),
|
||||
msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin),
|
||||
maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages),
|
||||
// 限流三个字段读**账号顶层**,不是 `config`(见 LIMIT_KEYS 的说明)。
|
||||
groupMsgPerMin: limitInitialOf(acct?.groupMsgPerMin),
|
||||
msgPerMin: limitInitialOf(acct?.msgPerMin),
|
||||
maxMessages: limitInitialOf(acct?.maxMessages),
|
||||
personaId: channelPersonaOf(acct),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 限流 / 会话额度的三个字段名(design/12 §6 策略三/四)。回显、写回与表单共用这一份,
|
||||
* 避免两处清单漂移。
|
||||
*
|
||||
* ⚠️ 线上它们是**账号顶层**字段(`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/
|
||||
* MaxMessages`,见 `internal/services/gateway/admin_write_channel_config.go` 的
|
||||
* `adminChannelAccountOf`),**不在 `config` 里** —— 从 `config` 读永远读不到:回显空
|
||||
* ⇒ 保存时按「未设置」写回 0 ⇒ **静默清掉主人已设的限流**。 */
|
||||
export const LIMIT_KEYS = ["groupMsgPerMin", "msgPerMin", "maxMessages"] as const;
|
||||
|
||||
/** 限流字段名。 */
|
||||
export type LimitKey = (typeof LIMIT_KEYS)[number];
|
||||
|
||||
/** 已设限额 → 表单回显:缺省与 `0` 都渲染成空(后端 `omitempty` ⇒ 两者在盘上等价)。
|
||||
* 非零值**原样回传**,这样「没动过的字段」保存时原样送回,不会把已存限额写没。 */
|
||||
function limitInitialOf(v: number | undefined): string {
|
||||
return v === undefined || v === 0 ? "" : String(v);
|
||||
}
|
||||
|
||||
/** 表单限流值 → 请求体取值:
|
||||
* - 留空 → `undefined`:该键**不进请求体**(后端 int 契约下省略 = 0 = 未设置);
|
||||
* - 整数(含负值)→ 该数值;
|
||||
* - 其它(小数 / 非数字)→ `null`,调用方报错并**中止保存** —— `Number("x")` 是 NaN、
|
||||
* 序列化成 `null`,后端 int 收到 `null` 静默落 0,同样会清空已设限额。
|
||||
*
|
||||
* 数值语义以 `internal/services/channelbridge/flowgate.go` 的 `flowOrDefault` 为准:
|
||||
* **0 = 保护性默认**(群 6 条/分、待处理上限 50)、**负值 = 关闭限制**;`msgPerMin` 只认正值。 */
|
||||
export function limitFieldValue(raw: string): number | undefined | null {
|
||||
const t = raw.trim();
|
||||
if (!t) return undefined;
|
||||
return /^-?\d+$/.test(t) ? Number(t) : null;
|
||||
}
|
||||
|
||||
/** 表单三个限流字段 → PUT 请求体片段。任一字段非法即整单拒绝(绝不发半份请求体,
|
||||
* 也绝不用 0 代替「未设置」)。 */
|
||||
export function limitRequestBody(
|
||||
fields: Record<LimitKey, string>
|
||||
): { ok: true; body: Partial<Record<LimitKey, number>> } | { ok: false; error: string } {
|
||||
const body: Partial<Record<LimitKey, number>> = {};
|
||||
for (const key of LIMIT_KEYS) {
|
||||
const value = limitFieldValue(fields[key]);
|
||||
if (value === null) return { ok: false, error: `${key} 需为整数(留空 = 未设置,负值 = 关闭限制)` };
|
||||
if (value !== undefined) body[key] = value;
|
||||
}
|
||||
return { ok: true, body };
|
||||
}
|
||||
|
||||
/** The set of platforms that have at least one configured account. */
|
||||
export function configuredPlatforms(records: AdminChannelRecord[]): AdminChannelRecord[] {
|
||||
return records.filter((c) => (c.accounts?.length ?? 0) > 0);
|
||||
|
||||
Reference in new issue
Block a user