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

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