feat(web): 渠道配置面板重做(按模式动态显隐 + 下拉 + 真鉴权键 + 掩码不回填)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

新增: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)。
This commit is contained in:
AskaEth committed 2026-10-05 10:15:19 +08:00
1 parent 8c437866e7
commit c4ea02a887
12 files changed
+1358 -132

No files matched your search

@@ -2,6 +2,7 @@ import type { AdminChannelAccount } from "../../../lib/types";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
import { PLATFORM_FIELDS, PLATFORM_LABEL } from "../channelFields";
import { authKeyOf } from "../channelFieldRules";
/** 一条只读字段。 */
interface Field {
@@ -9,8 +10,11 @@ interface Field {
value: string;
}
/** 通用连接键(各平台都有;平台专属键见 channelFields 的 PLATFORM_FIELDS)。 */
const COMMON_CONNECTION_KEYS = ["bot_token", "base_url"];
/** 通用连接键:该平台**真正读取**的凭据键(qq-obv11 = access_token、webhook = secret,
* 不是硬编码的 bot_token)+ base_url。平台专属键见 channelFields 的 PLATFORM_FIELDS。 */
function commonConnectionKeys(platform: string): string[] {
return [authKeyOf(platform), "base_url"];
}
/** 限流 / 会话额度**不在 config 里**:后端把它们作为账号顶层字段下发
* (`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/MaxMessages`,见
@@ -38,19 +42,25 @@ function fmtList(values?: readonly string[]): string {
return values && values.length > 0 ? values.join("、") : "未设置";
}
/** 该平台连接字段的键:通用键 + 平台专属键(channelFields 是字段元数据的单一
* 真源,此处不另造一份字段表)。 */
/** 该平台连接字段的键:凭据键 + base_url + 平台专属键(channelFields 是字段元数据的单一
* 真源,此处不另造一份字段表)。凭据键同时也是平台专属键时只算一次,避免同一行出现两遍。 */
function connectionKeys(platform: string): string[] {
return [...COMMON_CONNECTION_KEYS, ...(PLATFORM_FIELDS[platform] ?? []).map((f) => f.key)];
return [...new Set([...commonConnectionKeys(platform), ...(PLATFORM_FIELDS[platform] ?? []).map((f) => f.key)])];
}
function connectionFields(platform: string, account: AdminChannelAccount): Field[] {
const cfg = account.config ?? {};
const defs = PLATFORM_FIELDS[platform] ?? [];
const labelOf = (key: string) => defs.find((f) => f.key === key)?.label ?? key;
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]) });
}
const seen = new Set<string>();
const push = (key: string) => {
if (seen.has(key)) return;
seen.add(key);
out.push({ label: labelOf(key), value: fmtValue(cfg[key]) });
};
for (const key of commonConnectionKeys(platform)) push(key);
for (const f of defs) push(f.key);
return out;
}
@@ -6,6 +6,7 @@ import { useAutoReload } from "../../hooks";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../../components";
import { Button, Tag } from "../../../components/primitives";
import { PLATFORM_LABEL } from "../channelFields";
import { authKeyOf } from "../channelFieldRules";
import { ChannelConfigForm } from "../channelConfigForm";
import { ChannelLogPanel } from "./channelLogPanel";
import { ChannelIdentityTest } from "./channelIdentityTest";
@@ -29,10 +30,10 @@ export function ListPage({ navigate }: { navigate: (n: AdapterNav) => void }): R
// Never read as "no configs saved" when we in fact could not read at all.
const hardFail = loadErr != null && data == null;
const openConfig = (name: string) => {
const openConfig = (name: string, accountId?: string) => {
setError(null);
setNotice(null);
setCfg(buildCfgTarget(name, records));
setCfg(buildCfgTarget(name, records, accountId));
};
const ctx: ListCtx = {
@@ -96,7 +97,8 @@ interface ListCtx {
data: AdminChannelRecord[] | null;
error: string | null;
busy: string | null;
openConfig: (name: string) => void;
/** `accountId` 指定要编辑的账号(多账号平台逐条编辑);缺省 = 该平台第一条。 */
openConfig: (name: string, accountId?: string) => void;
toggle: (name: string, connect: boolean) => void;
del: (platform: string, id: string) => void;
openDetail: (adapter: string) => void;
@@ -117,12 +119,17 @@ function AccountsCell({ record, ctx }: { record: AdminChannelRecord; ctx: ListCt
if (!record.accounts || record.accounts.length === 0) {
return <span className="text-xs text-[var(--mu-text-lo)]">—</span>;
}
// 「凭据是否已配」必须看该平台**真正读取**的键:qq-obv11 读 access_token、webhook 读
// secret,用 bot_token 去判断会让配好的渠道永远显示「未填」。
const authKey = authKeyOf(platformOf(record.name));
const authLabel = authKey === "bot_token" ? "token" : authKey;
return (
<span className="flex flex-wrap items-center gap-2">
{record.accounts.map((a) => {
const adapter = adapterNameOf(platformOf(record.name), a.id);
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
const tokenMasked = typeof a.config?.bot_token === "string" && a.config.bot_token;
const raw = a.config?.[authKey];
const tokenMasked = typeof raw === "string" && raw.trim() !== "";
return (
<span key={a.id || "default"} className="flex flex-wrap items-center gap-1">
<button
@@ -133,11 +140,14 @@ function AccountsCell({ record, ctx }: { record: AdminChannelRecord; ctx: ListCt
<Tag tone="accent">{label}</Tag>
</button>
<Tag tone={tokenMasked ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
{tokenMasked ? "token 已配置" : "token 未填"}
{tokenMasked ? `${authLabel} 已配置` : `${authLabel} 未填`}
</Tag>
<Button size="sm" variant="ghost" disabled={ctx.busy !== null} onClick={() => ctx.openLog(adapter)}>
消息日志
</Button>
<Button size="sm" variant="ghost" disabled={ctx.busy !== null || !a.id} onClick={() => ctx.openConfig(record.name, a.id)}>
配置
</Button>
{a.id ? (
<Button size="sm" variant="ghost" disabled={ctx.busy !== null} onClick={() => ctx.del(platformOf(record.name), a.id)}>
删除
@@ -0,0 +1,69 @@
import type { PersonaRecord } from "../../../lib/types";
import { Tag } from "../../../components/primitives";
/** Channel-level role binding (design/22 §4.4, 批次 2). The copy is deliberately
* explicit that this cannot reach an isolated conversation: 群聊/陌生人 always
* run as the default role, and a session's own binding outranks the account's.
*
* 放在 `channels/` 下与 `channelPersona.ts`(三态写回逻辑)同目录:一个管读/写契约,
* 一个管呈现,配置表单只负责挂上去。 */
export function ChannelPersonaField({
value,
onChange,
personas,
loadError,
}: {
value: string;
onChange: (personaId: string) => void;
personas: PersonaRecord[];
loadError: string | null;
}): React.ReactNode {
const bound = value.trim();
const known = personas.find((p) => p.id === bound);
const unknown = bound !== "" && known === undefined;
return (
<div className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">角色(渠道级绑定)</span>
{bound === "" ? (
<Tag tone="neutral">未绑定 · 跟随默认角色</Tag>
) : (
<Tag tone="accent">已绑定 · {known?.name ?? bound}</Tag>
)}
</div>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">
该渠道账号在非隔离会话(与主人的私聊等)中使用的角色
</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label="渠道账号绑定的角色"
className={inputCls}
>
<option value="">跟随默认角色(未绑定)</option>
{unknown ? <option value={bound}>{bound}(不在角色列表)</option> : null}
{personas.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.isDefault ? " · 默认" : ""}
</option>
))}
</select>
</label>
<p className="text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
角色解析顺序:会话绑定 &gt; 渠道绑定 &gt; 全局默认。
渠道绑定<strong className="font-medium">只影响该账号与主人的私聊等非隔离会话</strong>;
群聊 / 陌生人会话始终使用默认角色,此处的绑定覆盖不了它们。
</p>
{loadError ? (
<p className="text-[11px] text-[var(--mu-err)]">
角色列表读取失败({loadError}):不改动该下拉时保存会原样保留当前绑定;请确认后端在线后重试再改。
</p>
) : null}
</div>
);
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
@@ -24,7 +24,8 @@
// 只从项目内导入、不 import node:*。
import type { AdminChannelAccount, AdminChannelRecord } from "../../../lib/types";
import { buildCfgTarget, limitFieldValue, limitRequestBody } from "./channelUtils";
import { buildCfgTarget, isMaskedSecret, limitFieldValue, limitRequestBody } from "./channelUtils";
import { platformConfigPatch } from "../channelFieldRules";
let checks = 0;
@@ -148,4 +149,48 @@ if (!bad.ok) {
ok(bad.error.includes("groupMsgPerMin"), `错误文案点名出错的字段 — got ${bad.error}`);
}
// ── ⑤ 平台字段预填:掩码秘密绝不回填(回填 = 一次「打开就保存」把 ****abcd 写成真 token) ──
// 后端 `runtimeconfig.Masked` 把 token/secret/key 类键脱敏成「****」+ 末 4 位后下发,
// 面板若原样回填并在保存时写回,就静默毁掉已配置的凭据。
function qqAccount(config: Record<string, unknown>): AdminChannelRecord[] {
return [{ name: "qq-obv11:conf0", connected: false, accounts: [acct({ id: "conf0", config })] }];
}
const masked = buildCfgTarget("qq-obv11:conf0", qqAccount({ transport: "http", mode: "client", api_base_url: "http://127.0.0.1:3000", access_token: "****cdef" }));
same(masked.initial.platVals.access_token, undefined, "掩码 access_token 不回填到表单");
ok(masked.initial.configuredKeys.includes("access_token"), "但记进 configuredKeys(面板显示「已配置」)");
same(masked.initial.platVals.api_base_url, "http://127.0.0.1:3000", "非秘密字段照常回填");
same(masked.initial.savedId, "conf0", "savedId 记录本次编辑的盘上账号 id");
// 打开表单后什么都不碰就保存:补丁里绝不能出现 access_token(否则掩码/空值会覆盖真 token)。
{
const patch = platformConfigPatch("qq-obv11", masked.initial.platVals, masked.initial.platVals);
ok(!("access_token" in patch), "没动过就不写 access_token(缺位 = 后端保留原文)");
}
ok(isMaskedSecret("****cdef"), "识别后端掩码(**** 前缀)");
ok(isMaskedSecret("sk-xxx(已配置)"), "兼容旧文字掩码");
ok(!isMaskedSecret("real-token"), "真实 token 不算掩码");
ok(!isMaskedSecret(undefined), "缺失值不算掩码");
// 选择器缺省:盘上没写 transport/mode 时,表单按适配器默认(ws + server)打开,
// 免得用户看到的组合和适配器实际用的是两个。
const defaults = buildCfgTarget("qq-obv11:conf0", qqAccount({})).initial.platVals;
same([defaults.transport, defaults.mode], ["ws", "server"], "选择器缺省 = 适配器默认 ws + server");
// 多账号:指定 accountId 时预填该账号(不再永远落到第一条)。
{
const twoAccts: AdminChannelRecord[] = [
{
name: "qq-obv11",
connected: false,
accounts: [
acct({ id: "conf0", config: { listen_addr: "127.0.0.1:5700" } }),
acct({ id: "conf1", config: { remote_url: "ws://127.0.0.1:3001" } }),
],
},
];
same(buildCfgTarget("qq-obv11", twoAccts, "conf1").initial.savedId, "conf1", "指定账号时预填该账号");
same(buildCfgTarget("qq-obv11", twoAccts, "conf1").initial.platVals.remote_url, "ws://127.0.0.1:3001", "预填的是该账号的字段值");
same(buildCfgTarget("qq-obv11", twoAccts).initial.savedId, "conf0", "未指定时退回第一条(旧行为)");
}
console.log(`channel limits logic test: ${checks} checks passed`);
@@ -1,5 +1,6 @@
import type { AdminChannelRecord } from "../../../lib/types";
import { PLATFORM_FIELDS } from "../channelFields";
import { defaultSelection } from "../channelFieldRules";
import { channelPersonaOf } from "./channelPersona";
import type { ChannelConfigInitial } from "../channelConfigForm";
@@ -22,27 +23,64 @@ export interface CfgTarget {
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. */
export function buildCfgTarget(name: string, records: AdminChannelRecord[]): CfgTarget {
* 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 acct = records.find((c) => platformOf(c.name) === plat)?.accounts?.[0];
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 && !acctCfg.bot_token.includes("(已配置");
const hasToken = typeof acctCfg.bot_token === "string" && acctCfg.bot_token && !isMaskedSecret(acctCfg.bot_token);
const fieldDefs = PLATFORM_FIELDS[plat] ?? [];
const platVals: Record<string, string> = {};
for (const f of PLATFORM_FIELDS[plat] ?? []) {
const v = acctCfg[f.key];
platVals[f.key] = v == null ? "" : String(v);
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(","),