新增:channelFieldRules.ts(纯函数规则层:选择器归一化 / 按 transport×mode 显隐与必填 /
写回补丁 / 模式提示文案)、channelConnFields.tsx(「连接配置」区:只渲染当前组合生效的字段,
逐字段「谁连谁」说明 + 必填/可选标记 + 掩码清除开关)、channelAccountId.tsx(账号 id 与
「覆盖/新建」状态 Tag)、channelPersonaField.tsx(从表单抽出,逻辑逐字保留)。
qq-obv11:transport / mode 改下拉;remote_url 仅 ws+client 显示且必填;listen_addr 按
server / http+client 条件显示;api_base_url 仅 http(http+client 必填);report_path 仅 http;
access_token 掩码;self_id 仅记录。每种组合顶部一句 modeHints;保存前拦截必填缺失并红框点名。
⚠️ 修掉一处数据毁伤:后端把 access_token 掩码成 ****abcd 下发,旧代码回填进表单 ⇒
「打开就保存」会把掩码写成真 token。现在掩码不回填、只记 configuredKeys,并提示
「已配置,留空 = 不改动」;要清空必须显式打开「清除已配置的 access_token」开关
(顺带给掩码秘密补上了以前没有的「清除」能力)。可见字段清空发 ""、隐藏字段不进补丁(缺位 = 保留)。
Token 误导修掉:PLATFORM_GUIDE.authKey 声明平台真正读取的键 —— qq-obv11 不再渲染 bot_token
输入框、不再标必填(改指向 access_token);渠道列表与只读配置卡的凭据行同步改用真实 authKey。
空 id:结论是后端契约(UpsertChannel 空 id = 新建),前端不碰后端修好:记住 savedId
(留空 = 覆盖当前条目)+ 改新 id 时内联二次确认 + 保存后用返回的 accountId 刷新 + 状态 Tag。
测试:新增真组件 SSR 渲染测试(68 checks)+ 规则单测(58 checks);17 个前端测试文件逐跑全绿;
tsc --noEmit 与 npm run build 通过(产物 index-CK_2RXKo.js)。
152 lines
7.7 KiB
TypeScript
152 lines
7.7 KiB
TypeScript
import type { AdminChannelRecord } from "../../../lib/types";
|
||
import { PLATFORM_FIELDS } from "../channelFields";
|
||
import { defaultSelection } from "../channelFieldRules";
|
||
import { channelPersonaOf } from "./channelPersona";
|
||
import type { ChannelConfigInitial } from "../channelConfigForm";
|
||
|
||
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
|
||
export function platformOf(name: string): string {
|
||
return name.split(":")[0];
|
||
}
|
||
|
||
/** Adapter (platform:account) name for one account config. The bridge registers a
|
||
* channels.json account as "kook:conf0" (env single-account = bare platform), so a
|
||
* config id of "default"/"" maps to the bare platform name. */
|
||
export function adapterNameOf(platform: string, accountId?: string): string {
|
||
const id = accountId?.trim();
|
||
return id && id !== "default" ? `${platform}:${id}` : platform;
|
||
}
|
||
|
||
/** Open-config target: the platform + the prefill bundle for its form. */
|
||
export interface CfgTarget {
|
||
platform: string;
|
||
initial: ChannelConfigInitial;
|
||
}
|
||
|
||
/** 后端对 token/secret/key 类键只下发掩码(`runtimeconfig.Masked`:`****` + 末 4 位)。
|
||
* 掩码值**绝不能回填到表单**:一次「打开就保存」会把 `****abcd` 当成真实 token 写回,
|
||
* 静默毁掉已配置的凭据。旧后端还用过「…(已配置)」这种文字掩码,一并容忍。 */
|
||
export function isMaskedSecret(value: unknown): boolean {
|
||
if (typeof value !== "string") return false;
|
||
return value.startsWith("****") || value.includes("(已配置");
|
||
}
|
||
|
||
/** 盘上该键是否已有非空值(不回填掩码原文,但仍要让面板知道「已配置」)。 */
|
||
function hasStoredValue(raw: unknown): boolean {
|
||
if (raw === null || raw === undefined) return false;
|
||
if (typeof raw === "string") return raw.trim() !== "";
|
||
return true;
|
||
}
|
||
|
||
/** Build the config-form prefill from a platform name and the channel records.
|
||
* Do NOT put the masked placeholder into `token`: it would be written back as
|
||
* the real bot_token (overwriting the stored secret with a demo string). Keep
|
||
* token empty (save omits it → keeps the existing secret) and show a hint.
|
||
*
|
||
* 平台字段同理:掩码值不回填,只记进 `configuredKeys`(表单据此显示「已配置、留空不
|
||
* 改动」并把它当作需求已满足);下拉型选择器(transport/mode)在盘上缺值时填默认值,
|
||
* 这样打开表单第一眼看到的组合就是适配器真正会用的那个。 */
|
||
export function buildCfgTarget(name: string, records: AdminChannelRecord[], accountId?: string): CfgTarget {
|
||
const plat = platformOf(name);
|
||
const accts = records.find((c) => platformOf(c.name) === plat)?.accounts ?? [];
|
||
// 指定账号优先(多账号平台逐条编辑);未指定时退回第一个,与旧行为一致。
|
||
const acct = (accountId ? accts.find((a) => a.id === accountId) : undefined) ?? accts[0];
|
||
const acctCfg = acct?.config ?? {};
|
||
const hasToken = typeof acctCfg.bot_token === "string" && acctCfg.bot_token && !isMaskedSecret(acctCfg.bot_token);
|
||
const fieldDefs = PLATFORM_FIELDS[plat] ?? [];
|
||
const platVals: Record<string, string> = {};
|
||
const configuredKeys: string[] = [];
|
||
for (const f of fieldDefs) {
|
||
const raw = acctCfg[f.key];
|
||
if (!hasStoredValue(raw)) continue;
|
||
configuredKeys.push(f.key);
|
||
if (isMaskedSecret(raw)) continue;
|
||
platVals[f.key] = String(raw);
|
||
}
|
||
const fallback = defaultSelection(plat);
|
||
for (const f of fieldDefs) {
|
||
if (platVals[f.key] !== undefined) continue;
|
||
const def = f.defaultValue ?? "";
|
||
if (def !== "") platVals[f.key] = def;
|
||
}
|
||
// 选择器缺省兜底:即使平台没给字段声明缺省值,也让「当前模式」有值可判定。
|
||
if (fallback.transport && platVals.transport === undefined) platVals.transport = fallback.transport;
|
||
if (fallback.mode && platVals.mode === undefined) platVals.mode = fallback.mode;
|
||
return {
|
||
platform: plat,
|
||
initial: {
|
||
accountId: acct?.id ?? "",
|
||
savedId: acct?.id ?? "",
|
||
tokenHint: hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : "",
|
||
baseURL: typeof acctCfg.base_url === "string" ? acctCfg.base_url : "",
|
||
platVals,
|
||
configuredKeys,
|
||
allowGroups: (acct?.allowGroups ?? []).join(","),
|
||
denyGroups: (acct?.denyGroups ?? []).join(","),
|
||
allowUsers: (acct?.allowUsers ?? []).join(","),
|
||
denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "",
|
||
// 限流三个字段读**账号顶层**,不是 `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);
|
||
}
|
||
|
||
/** Total configured accounts across all platforms. */
|
||
export function configuredAccountCount(records: AdminChannelRecord[]): number {
|
||
return configuredPlatforms(records).reduce((n, c) => n + (c.accounts?.length ?? 0), 0);
|
||
}
|