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
@@ -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