Files
Cyrene_v2/frontend/web/src/admin/modules/channels/channelUtils.ts
T
AskaEth c4ea02a887
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): 渠道配置面板重做(按模式动态显隐 + 下拉 + 真鉴权键 + 掩码不回填)
新增: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)。
2026-10-05 10:15:19 +08:00

152 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}