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,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>
);
}