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

@@ -0,0 +1,52 @@
import { Tag } from "../../components/primitives";
/** 渠道账号 ID 字段(配置表单的「核心配置」区)。
*
* 为什么单独成一个组件加一段说明:空 id 在后端语义里是「生成新 id 追加一条」
* (`internal/config/runtimeconfig/channels.go` 的 `UpsertChannel`:空白 ID 会被赋一个
* 新 id)。曾经面板把「留空」原样发给后端,于是「打开一张已配置的账号卡 → 保存」就在同
* 一平台上多出一个适配器。现在表单自己回写当前条目的 id(留空 = 覆盖),改 id 会先要求
* 确认,并且这里的 Tag 始终说明本次保存会落到哪一条。 */
export function AccountIdField({
value,
onChange,
savedId,
createsNew,
}: {
value: string;
onChange: (v: string) => void;
/** 本次编辑的盘上账号 id;`''` = 新建。 */
savedId: string;
createsNew: boolean;
}): React.ReactNode {
const typed = value.trim();
const status = createsNew
? { tone: "warn" as const, text: `将新增账号「${typed}」,原「${savedId}」保留` }
: savedId === ""
? { tone: "neutral" as const, text: "新建账号(留空由后端生成 id)" }
: typed === ""
? { tone: "ok" as const, text: `留空 = 覆盖当前账号「${savedId}」` }
: { tone: "accent" as const, text: `覆盖账号「${savedId}」` };
return (
<label className="block space-y-1">
<span className="flex flex-wrap items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
账号 ID
<Tag tone={status.tone}>{status.text}</Tag>
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={savedId || "conf0"}
className={inputCls}
/>
<span className="block text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
同一平台可配多个账号,id 是区分它们的唯一键。
{savedId ? "留空即修改当前这条;填新 id 会另开一条(会先让你确认)。" : "新账号留空即可,保存后由后端生成 id。"}
</span>
</label>
);
}
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)]";
@@ -0,0 +1,196 @@
// 渠道配置表单的**真实渲染**测试:把「面板到底有没有把关键字段画出来」变成可执行的断言。
//
// 为什么需要它:纯逻辑测试(channelFieldRules.test.ts)只能证明规则算得对,证明不了
// 「字段真的出现在表单里」。这次修的原始问题正是渲染层的——`transport` / `api_base_url` /
// `report_path` 三件套当时**根本没在面板上**,用户只能去「高级选项 → 额外 config」手写
// JSON,写错键名就静默不生效。所以这里用 react-dom/server 渲染真组件、断言真 HTML。
//
// 断言一律用**字段标签**(`key(中文名)`)而不是裸键名:说明文案里也会提到 `remote_url`
// 之类的键,裸键名匹配会把「提到」当成「渲染了」。
//
// 锁四件事:
// ① qq-obv11 的字段确实呈现,且**当前组合下用不上的字段不渲染**;
// ② 顶部不再出现「bot_token 必填」——qq-obv11 只读 access_token(adapter.go:71-72);
// ③ 后端下发的**掩码**绝不出现在 HTML 里(回填掩码 = 一次保存把 `****abcd` 写成真 token);
// ④ 账号 id 的「留空 = 覆盖当前条目」/「留空 = 新建」在界面上说清楚。
//
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channelConfigForm.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-channel-config-form.test.cjs \
// && node /tmp/cyrene-channel-config-form.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminChannelRecord } from "../../lib/types";
import { ChannelConfigForm } from "./channelConfigForm";
import { buildCfgTarget } from "./channels/channelUtils";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
function count(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
/** 字段标签前缀(`channelFields.ts` 的 label 是「键(中文名)」)——只在字段真被
* 渲染时才出现,说明文案里出现键名不会误判。 */
const LABEL = {
transport: "transport(传输方式)",
mode: "mode(谁连谁)",
remote_url: "remote_url(对方 ws 地址",
listen_addr: "listen_addr(本地监听地址",
api_base_url: "api_base_url(OneBot HTTP API 基址)",
report_path: "report_path(上报接收路径)",
access_token: "access_token(OneBot 鉴权 token)",
self_id: "self_id(机器人自身 QQ 号)",
} as const;
/** 渲染真表单(props 与面板里 CreatePage / ListPage 传的一模一样)。 */
function renderForm(adapter: string, records: AdminChannelRecord[], accountId?: string): string {
const target = buildCfgTarget(adapter, records, accountId);
return renderToStaticMarkup(
createElement(ChannelConfigForm, {
platform: target.platform,
initial: target.initial,
busy: null,
setBusy: () => {},
onSaved: () => {},
onError: () => {},
onClose: () => {},
}),
);
}
const qqRecords = (config: Record<string, unknown>, id = "conf0"): AdminChannelRecord[] => [
{ name: `qq-obv11:${id}`, connected: false, accounts: [{ id, config }] },
];
// ── ① 默认组合 ws+server:该显示的显示、用不上的不渲染 ────────────────────────────
{
const html = renderForm("qq-obv11", qqRecords({}));
for (const label of [LABEL.transport, LABEL.mode, LABEL.listen_addr, LABEL.access_token, LABEL.self_id]) {
ok(has(html, label), `ws+server 渲染 ${label}`);
}
ok(!has(html, LABEL.remote_url), "ws+server 不渲染 remote_url(对方来连我们,没有目标地址)");
ok(!has(html, LABEL.api_base_url), "ws+server 不渲染 api_base_url(ws 传输不读它)");
ok(!has(html, LABEL.report_path), "ws+server 不渲染 report_path(HTTP 上报专用)");
// 下拉候选来自元数据:ws/http、server/client 都是选项而不是自由输入。
ok(has(html, "ws(WebSocket,默认)") && has(html, "http(HTTP API + 上报)"), "transport 渲染成带候选的下拉");
ok(has(html, "server(对方来连我们)") && has(html, "client(我们去连对方)"), "mode 渲染成下拉(谁连谁写在选项里)");
// 每个字段都有一行中文说明。
ok(has(html, "对方来连我们,端口必须在这里显式给出"), "listen_addr 有「谁连谁」说明");
ok(has(html, "空 = 不校验"), "access_token 说明写清「空 = 不校验」");
ok(has(html, "只会记录/展示用") || has(html, "只作记录/展示用"), "self_id 说明写清它不参与连接");
// 当前组合的一句话提示。
ok(has(html, "当前 ws + server"), "显示当前组合摘要");
ok(has(html, "等对方用「反向 WS」连上来"), "显示当前模式需要什么的一句话提示");
// 「必填」标记只落在一个字段上(listen_addr)——qq-obv11 顶部不再有 bot_token 必填。
ok(count(html, ">必填</span>") === 1, `qq-obv11 面板只有 1 个「必填」标记,got ${count(html, ">必填</span>")}`);
}
// ── ② token 误导已修:qq-obv11 不出现 bot_token 输入框 / 必填 ──────────────────────
{
const html = renderForm("qq-obv11", qqRecords({}));
ok(has(html, "本渠道不读 bot_token"), "明说本渠道不读 bot_token");
ok(has(html, "鉴权键:access_token"), "点名真正的鉴权键 access_token");
ok(!has(html, "Token(bot_token)"), "不再渲染 bot_token 输入框(填了也没人读)");
ok(!has(html, "机器人 token"), "不再渲染 bot_token 占位符");
}
// 对照:kook 仍然照旧(bot_token 是它真正读取的键)。
{
const html = renderForm("kook", [{ name: "kook", connected: false, accounts: [{ id: "conf0", config: {} }] }]);
ok(has(html, "Token(bot_token)"), "kook 照常保留 bot_token 输入框");
ok(count(html, ">必填</span>") === 1, "kook 的 bot_token 仍标必填");
ok(!has(html, "本渠道不读 bot_token"), "kook 不出现「不读 bot_token」提示");
}
// webhook 不读 bot_token,凭据是 secret:同样不渲染无用的输入框。
{
const html = renderForm("webhook", [{ name: "webhook", connected: false, accounts: [{ id: "conf0", config: {} }] }]);
ok(has(html, "本渠道不读 bot_token") && has(html, "鉴权键:secret"), "webhook 指向 secret 而不是 bot_token");
}
// ── ③ 掩码不回填、更不出现在 HTML ──────────────────────────────────────────────
{
const html = renderForm(
"qq-obv11",
qqRecords({ transport: "http", mode: "client", api_base_url: "http://127.0.0.1:3000", access_token: "****cdef" }),
);
ok(!has(html, "****cdef"), "后端下发的掩码不出现在 HTML 里");
ok(has(html, "已配置(后端只下发掩码,面板不持有原文)"), "改说「已配置,留空 = 不改动」");
ok(has(html, "清除已配置的 access_token"), "提供显式清除开关(留空 ≠ 清除)");
ok(has(html, LABEL.api_base_url), "http+client 渲染 api_base_url");
ok(has(html, "http://127.0.0.1:3000"), "已存值回显到输入框");
ok(has(html, "当前 http + client"), "提示随组合切换");
ok(!has(html, LABEL.remote_url), "http+client 不渲染 remote_url");
ok(has(html, LABEL.listen_addr), "http+client 仍渲染可选的 listen_addr(填了才收上报)");
ok(count(html, ">必填</span>") === 1, "http+client 的必填标记只有 api_base_url 一个");
}
// 四种组合的字段集(渲染层实证,不只算规则)。
{
const combos: Array<[string, string, string[], string[]]> = [
["ws", "server", [LABEL.listen_addr], [LABEL.remote_url, LABEL.api_base_url, LABEL.report_path]],
["ws", "client", [LABEL.remote_url], [LABEL.listen_addr, LABEL.api_base_url, LABEL.report_path]],
["http", "server", [LABEL.listen_addr, LABEL.api_base_url, LABEL.report_path], [LABEL.remote_url]],
["http", "client", [LABEL.api_base_url, LABEL.report_path], [LABEL.remote_url]],
];
for (const [transport, mode, shown, hidden] of combos) {
const html = renderForm("qq-obv11", qqRecords({ transport, mode }));
for (const label of shown) ok(has(html, label), `${transport}+${mode} 渲染 ${label}`);
for (const label of hidden) ok(!has(html, label), `${transport}+${mode} 不渲染 ${label}`);
ok(has(html, `当前 ${transport} + ${mode}`), `${transport}+${mode} 有组合提示(谁连谁)`);
}
}
// ── ④ 空 id 的后果在界面上说清楚(不再悄悄新建) ────────────────────────────────
{
const editing = renderForm("qq-obv11", qqRecords({}), "conf0");
ok(has(editing, "覆盖账号「conf0」"), "编辑已有条目时标明本次保存覆盖哪一条");
ok(has(editing, "留空即修改当前这条"), "说明留空 = 修改当前这条(不是新建)");
ok(has(editing, "填新 id 会另开一条(会先让你确认)"), "说明改 id 会新增并需要确认");
const fresh = renderForm("qq-obv11", []);
ok(has(fresh, "新建账号(留空由后端生成 id)"), "未配置平台说明「留空 = 新建」");
ok(!has(fresh, "覆盖账号"), "新建时不出现覆盖字眼");
// 多账号:指定 accountId 时表单指向那一条(不再永远编辑第一条)。
const two = [
{
name: "qq-obv11",
connected: false,
accounts: [
{ id: "conf0", config: { listen_addr: "127.0.0.1:5700" } },
{ id: "conf1", config: { listen_addr: "127.0.0.1:5701" } },
],
},
];
const second = renderForm("qq-obv11", two, "conf1");
ok(has(second, "覆盖账号「conf1」"), "逐条编辑时指向该账号");
ok(has(second, 'value="127.0.0.1:5701"'), "回显的是该账号的值");
ok(!has(second, 'value="127.0.0.1:5700"'), "不会串到另一条账号的值");
}
// ── ⑤ 其他平台不受影响:webhook 五个字段照常平铺,且没有模式提示 ─────────────────
{
const html = renderForm("webhook", [{ name: "webhook", connected: false, accounts: [{ id: "conf0", config: {} }] }]);
for (const key of ["endpointPath", "channelId", "channelName", "secret", "signatureHeader"]) {
ok(has(html, key), `webhook 渲染 ${key}`);
}
ok(!has(html, "当前 ws") && !has(html, "当前 http"), "webhook 没有 transport×mode 组合提示");
ok(has(html, "连接配置(Webhook 专用字段)"), "webhook 的连接字段进「连接配置」区");
ok(has(html, "留空 = 不校验来源"), "webhook secret 的说明写清空值的后果");
}
console.log(`channel config form render test: ${checks} checks passed`);
@@ -5,7 +5,17 @@ import { useAsyncData } from "../../lib/useAsyncData";
import { useAuditStore } from "../audit";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { PlatformConnFields } from "./channelConnFields";
import { AccountIdField } from "./channelAccountId";
import {
authKeyOf,
missingRequiredFields,
missingRequiredMessage,
modeSummary,
platformConfigPatch,
} from "./channelFieldRules";
import { withChannelPersona } from "./channels/channelPersona";
import { ChannelPersonaField } from "./channels/channelPersonaField";
import { limitRequestBody } from "./channels/channelUtils";
import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_LABEL } from "./channelFields";
@@ -13,6 +23,11 @@ import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_LABEL } from "./channelFields
* (masked token is echoed only as a hint, never as a raw value). */
export interface ChannelConfigInitial {
accountId: string;
/** 本次表单编辑的**盘上账号 id**(`''` = 新建):空 id 保存时用它回写,避免
* 「留空 = 新建一条」把同一个渠道喂成两个适配器。 */
savedId: string;
/** 盘上已有值(含只下发掩码的秘密)的平台字段键:显示「已配置」并算作必填已满足。 */
configuredKeys: string[];
tokenHint: string;
baseURL: string;
platVals: Record<string, string>;
@@ -49,10 +64,19 @@ export function ChannelConfigForm({
onClose,
}: ChannelConfigFormProps): React.ReactNode {
const log = useAuditStore((s) => s.log);
const guide = PLATFORM_GUIDE[platform];
// 该平台真正读取鉴权值的 config 键:不等于 bot_token 时就不用 bot_token 输入框,
// 否则「必填的 bot_token 填了也没人读」,用户按提示填完却连不上。
const authKey = authKeyOf(platform);
const [cfgId, setCfgId] = useState(initial.accountId);
const [token, setToken] = useState("");
const [baseURL, setBaseURL] = useState(initial.baseURL);
const [platVals, setPlatVals] = useState<Record<string, string>>(initial.platVals);
// 打开表单时的原始平台字段值:留空 ≠ 清除(后端 patch 缺位 = 保留),要判断「用户把
// 盘上已有值清空了」必须知道它原本是什么。
const [initialVals] = useState<Record<string, string>>(() => ({ ...initial.platVals }));
const [clearedSecrets, setClearedSecrets] = useState<string[]>([]);
const [missingKeys, setMissingKeys] = useState<string[]>([]);
const [allowGroups, setAllowGroups] = useState(initial.allowGroups);
const [denyGroups, setDenyGroups] = useState(initial.denyGroups);
const [allowUsers, setAllowUsers] = useState(initial.allowUsers);
@@ -63,7 +87,12 @@ export function ChannelConfigForm({
const [extraConfig, setExtraConfig] = useState("");
const [advOpen, setAdvOpen] = useState(false);
const [polOpen, setPolOpen] = useState(false);
// 平台连接字段是该平台能不能连上的关键,默认展开(不再藏在「高级选项」里等用户自己找)。
const [connOpen, setConnOpen] = useState((PLATFORM_FIELDS[platform] ?? []).length > 0);
const [tokenHint, setTokenHint] = useState(initial.tokenHint);
// 改成另一个 id 保存会在后端**新增**一条账号(原条目保留)⇒ 必须先确认一次;
// savedId 为空(新建)或 id 未改动时不需要。
const [confirmNewId, setConfirmNewId] = useState(false);
// Channel-level role binding (design/22 §4.4). `dirty` tells an explicit clear
// (owner picked「跟随默认角色」) from a value the form simply never had, so an
// untouched unbound account does not write an empty personaId over a stored one.
@@ -71,15 +100,34 @@ export function ChannelConfigForm({
const [personaDirty, setPersonaDirty] = useState(false);
const personas = useAsyncData(adminListPersonas, []);
const save = () => {
// 本次表单认作「盘上那条」的 id。保存成功后用后端回的 accountId 刷新:否则一次新建
// 保存之后表单里的 id 是空的,再点一次保存又会新增一条。
const [liveSavedId, setLiveSavedId] = useState(initial.savedId.trim());
const savedId = liveSavedId;
const typedId = cfgId.trim();
// 留空 = 覆盖当前编辑的条目(不再让后端按空 id 生成新条目);新建时留空 = 由后端生成 id。
const effectiveId = typedId || savedId;
const idCreatesNew = savedId !== "" && typedId !== "" && typedId !== savedId;
const save = (confirmedNewId = false) => {
if (!platform) return;
if (idCreatesNew && !confirmedNewId) {
setConfirmNewId(true);
return;
}
setConfirmNewId(false);
// 平台字段的必填校验先跑:server 模式少 listen_addr、ws+client 少 remote_url 都会让
// 适配器 Connect 直接失败,别等用户保存完再对着日志猜。
const missing = missingRequiredFields(platform, platVals, initial.configuredKeys);
if (missing.length > 0) {
setMissingKeys(missing.map((f) => f.key));
onError(missingRequiredMessage(platform, platVals, missing));
return;
}
setMissingKeys([]);
setBusy("__save__");
const toList = (s: string) => s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
const specificMap: Record<string, unknown> = {};
for (const f of PLATFORM_FIELDS[platform] ?? []) {
const v = (platVals[f.key] ?? "").trim();
if (v) specificMap[f.key] = v;
}
const specificMap: Record<string, unknown> = platformConfigPatch(platform, platVals, initialVals, clearedSecrets);
let configMap: Record<string, unknown> | undefined;
if (extraConfig.trim()) {
let extra: unknown;
@@ -111,7 +159,8 @@ export function ChannelConfigForm({
platform,
withChannelPersona(
{
id: cfgId.trim() || undefined,
// 留空时回写**当前条目的 id**(覆盖),只有真正的新建才让后端生成 id。
id: effectiveId || undefined,
token: token.trim() || undefined,
baseURL: baseURL.trim() || undefined,
config: configMap,
@@ -127,10 +176,14 @@ export function ChannelConfigForm({
.then((r) => {
log("channel.save", "ok");
onSaved(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
// 记住这次写的是哪条:紧接着再点一次保存是「覆盖本条」而不是「再新建一条」。
setLiveSavedId((r.accountId ?? "").trim());
setCfgId("");
setToken("");
setBaseURL("");
setPlatVals({});
setClearedSecrets([]);
setMissingKeys([]);
setAllowGroups("");
setDenyGroups("");
setAllowUsers("");
@@ -156,33 +209,90 @@ export function ChannelConfigForm({
保存后写入 channels.json(本地、已 gitignore)并热更新 {platform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
</p>
{/* 核心必填:最小必备字段 + 醒目 Callout / 高亮 Tag,不平铺全量 */}
{/* 账号与凭据(token)区:token 是各平台真正的入口凭据;qq-obv11 的 token 在连接
配置里(access_token),这里只放账号 id 与「本渠道不读 bot_token」的说明。 */}
<div className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-accent)]/25 bg-[var(--mu-surface-4)] p-3">
<div className="flex items-center gap-1.5 text-sm font-semibold text-[var(--mu-accent-hi)]">
<Icon name="check" size={16} className="text-[var(--mu-accent)]" />
核心必填
{guide.tokenRequired === false ? "核心配置(账号与鉴权)" : "核心必填"}
</div>
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-accent)]/15 px-3 py-2 text-xs leading-relaxed text-[var(--mu-accent-hi)]">
{PLATFORM_GUIDE[platform].guide}
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Field label="账号 ID(留空自动生成)" value={cfgId} onChange={setCfgId} placeholder="conf0" />
<label className="block space-y-1">
<span className="flex items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
Token(bot_token)
{PLATFORM_GUIDE[platform].tokenRequired !== false ? (
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">必填</span>
) : null}
</span>
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="机器人 token" className={`${inputCls} border border-[var(--mu-accent)]/25`} />
{tokenHint ? <span className="block text-[11px] text-[var(--mu-text-lo)]">{tokenHint}</span> : null}
</label>
<AccountIdField
value={cfgId}
onChange={setCfgId}
savedId={savedId}
createsNew={idCreatesNew}
/>
{authKey === "bot_token" ? (
<label className="block space-y-1">
<span className="flex items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
Token(bot_token)
{guide.tokenRequired !== false ? (
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">必填</span>
) : null}
</span>
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="机器人 token" className={`${inputCls} border border-[var(--mu-accent)]/25`} />
{tokenHint ? <span className="block text-[11px] text-[var(--mu-text-lo)]">{tokenHint}</span> : null}
</label>
) : (
// 本渠道不读 bot_token:与其让主人对着一个「必填」框填一份没人读的凭据,
// 不如直说凭据在哪个键、去哪填(qq-obv11 只认 access_token)。
<div className="space-y-1 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] px-3 py-2">
<span className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-[var(--mu-text-mid)]">
本渠道不读 bot_token
<Tag tone="accent">鉴权键:{authKey}</Tag>
</span>
<span className="block text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
该适配器读取的鉴权键是 <code>{authKey}</code>(写在下方「连接配置」里)。
这里没有 bot_token 输入框,也就不会有一份填了没人读的凭据;已配置的值不会被这里覆盖。
</span>
</div>
)}
</div>
{PLATFORM_GUIDE[platform].hint ? (
<p className="text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[platform].hint}</p>
{guide.hint ? (
<p className="text-xs leading-relaxed text-[var(--mu-text-lo)]">{guide.hint}</p>
) : null}
</div>
{/* 连接配置:该平台**能不能连上**就在这里。平台字段只渲染当前 transport × mode 下
真正生效的那些,并逐字段给说明与必填校验(不再藏在高级选项里等人自己找)。 */}
{(PLATFORM_FIELDS[platform] ?? []).length > 0 ? (
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-accent)]/25 bg-[var(--mu-surface-2)]">
<button
type="button"
onClick={() => setConnOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs font-medium text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
>
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${connOpen ? "rotate-90" : ""}`}>
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
</span>
连接配置({PLATFORM_LABEL[platform] ?? platform} 专用字段)
{guide.selectors ? <Tag tone="accent">{modeSummary(platform, platVals)}</Tag> : null}
{missingKeys.length > 0 ? <Tag tone="err">缺少必填</Tag> : null}
</button>
{connOpen ? (
<div className="px-3 pb-3 pt-1">
<PlatformConnFields
platform={platform}
values={platVals}
onChange={(key, v) => setPlatVals((prev) => ({ ...prev, [key]: v }))}
configuredKeys={initial.configuredKeys}
missingKeys={missingKeys}
clearedKeys={clearedSecrets}
onToggleClear={(key, cleared) =>
setClearedSecrets((prev) =>
cleared ? [...new Set([...prev, key])] : prev.filter((k) => k !== key),
)
}
/>
</div>
) : null}
</div>
) : null}
<ChannelPersonaField
value={personaId}
onChange={(id) => {
@@ -193,7 +303,7 @@ export function ChannelConfigForm({
loadError={personas.error}
/>
{/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
{/* 高级选项:base_url / 额外 config。平台连接字段已在上面单独一区,不再重复。 */}
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
<button
type="button"
@@ -203,19 +313,19 @@ export function ChannelConfigForm({
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${advOpen ? "rotate-90" : ""}`}>
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
</span>
高级选项(base_url / 平台特定字段 / 额外 config)
高级选项(base_url / 额外 config)
</button>
{advOpen ? (
<div className="space-y-3 px-3 pb-3 pt-1">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Field label="baseURL(地址)" value={baseURL} onChange={setBaseURL} placeholder="https://…" />
{(PLATFORM_FIELDS[platform] ?? []).map((f) => (
<Field key={f.key} label={f.label} value={platVals[f.key] ?? ""} onChange={(v) => setPlatVals((prev) => ({ ...prev, [f.key]: v }))} placeholder={f.placeholder} secret={f.secret} />
))}
</div>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">额外 config(可选 JSON,覆盖平台特有字段如 webhook_url/endpointPath/secret)</span>
<textarea value={extraConfig} onChange={(e) => setExtraConfig(e.target.value)} rows={2} placeholder='{"webhook_url":"…"}' className="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)]" />
<span className="text-xs text-[var(--mu-text-lo)]">额外 config(可选 JSON,覆盖上面的字段或写适配器读的其他键)</span>
<textarea value={extraConfig} onChange={(e) => setExtraConfig(e.target.value)} rows={2} placeholder='{"receive_mode":"ws"}' className="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)]" />
<span className="block text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
这里的键**逐字**对应适配器的 config 键名,写错不生效;平台字段已在上面「连接配置」里,不必手写 JSON。
</span>
</label>
</div>
) : null}
@@ -255,8 +365,34 @@ export function ChannelConfigForm({
) : null}
</div>
{/* 改 id 保存 = 后端新增一条(原条目保留):先弹一次明确确认,绝不悄悄多出一个适配器。 */}
{confirmNewId && idCreatesNew ? (
<div className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-warn)]/40 bg-[var(--mu-warn)]/10 px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-[var(--mu-text-hi)]">
<Tag tone="warn">请确认</Tag>
账号 ID 从「{savedId}」改成了「{typedId}」
</div>
<p className="text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
后端按 id 写入:这个 id 不存在,所以会<strong className="font-medium">新增一条账号</strong>
,原账号「{savedId}」保留并继续运行 —— 同一平台会同时存在两个适配器。
若本意是修改当前那条,请把 ID 改回「{savedId}」或留空。
</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="danger" disabled={busy !== null} onClick={() => save(true)}>
确认新增独立账号
</Button>
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => setConfirmNewId(false)}>
取消,我再改改
</Button>
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => { setCfgId(savedId); setConfirmNewId(false); }}>
改回 {savedId}
</Button>
</div>
</div>
) : null}
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy !== null} onClick={save}>保存配置</Button>
<Button variant="primary" disabled={busy !== null} onClick={() => save()}>保存配置</Button>
<Button variant="ghost" disabled={busy !== null} onClick={onClose}>取消</Button>
</div>
</Surface>
@@ -266,67 +402,6 @@ export function ChannelConfigForm({
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)]";
/** 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. */
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>
);
}
/** Compact labeled input for the config form; `secret` masks the field. */
interface FieldProps {
label: string;
@@ -0,0 +1,163 @@
import { Icon } from "../../components/Icon";
import { Switch } from "../../components/primitives";
import { type PlatformField } from "./channelFields";
import { modeHint, modeSummary, requiredFieldKeys, visibleFields } from "./channelFieldRules";
/** qq-obv11 这类「字段含义随 transport × mode 变化」的平台,字段不能平铺 —— 平铺会
* 同时制造「填了用不上」和「没填起不来」两种事故。这里只渲染当前组合下真正生效的字段,
* 并逐字段给出「谁连谁 / 空值代表什么 / 现在是否必填」。
*
* 校验规则(必填、清空、写回补丁)全在 `channelFieldRules.ts` 的纯函数里,本组件不判断
* 规则、只呈现结果,单测因此不需要渲染 React。 */
export interface PlatformConnFieldsProps {
platform: string;
values: Readonly<Record<string, string>>;
onChange: (key: string, value: string) => void;
/** 盘上已有值的键(含只有掩码可看的秘密):用来显示「已配置」并算作必填已满足。 */
configuredKeys: readonly string[];
/** 本次保存被拦下时点名的必填字段键(红框 + 一句话)。 */
missingKeys: readonly string[];
/** 主人显式要求清除的掩码秘密键(面板不持有原文,留空 ≠ 清除)。 */
clearedKeys: readonly string[];
onToggleClear: (key: string, cleared: boolean) => void;
}
export function PlatformConnFields({
platform,
values,
onChange,
configuredKeys,
missingKeys,
clearedKeys,
onToggleClear,
}: PlatformConnFieldsProps): React.ReactNode {
const fields = visibleFields(platform, values);
if (fields.length === 0) return null;
const hint = modeHint(platform, values);
const summary = modeSummary(platform, values);
// 必填清单一次算完(当前组合下哪些字段必填),避免逐字段重复判定。
const required = new Set(requiredFieldKeys(platform, values));
const missing = new Set(missingKeys);
const configured = new Set(configuredKeys);
const cleared = new Set(clearedKeys);
return (
<div className="space-y-3">
{hint ? (
<div className="flex items-start gap-1.5 rounded-[var(--mu-radius)] bg-[var(--mu-accent)]/15 px-3 py-2 text-xs leading-relaxed text-[var(--mu-accent-hi)]">
<Icon name="check" size={14} className="mt-0.5 shrink-0 text-[var(--mu-accent)]" />
<span>{hint}</span>
</div>
) : null}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{fields.map((f) => (
<ConnField
key={f.key}
field={f}
value={values[f.key] ?? ""}
onChange={(v) => onChange(f.key, v)}
required={required.has(f.key)}
missing={missing.has(f.key)}
summary={summary}
alreadyConfigured={configured.has(f.key)}
clearRequested={cleared.has(f.key)}
onToggleClear={(next) => onToggleClear(f.key, next)}
/>
))}
</div>
</div>
);
}
/** 一个字段:标题 + 必填/可选标记 + 输入(下拉或文本框)+ 一行中文说明 + 状态行。 */
function ConnField({
field,
value,
onChange,
required,
missing,
summary,
alreadyConfigured,
clearRequested,
onToggleClear,
}: {
field: PlatformField;
value: string;
onChange: (v: string) => void;
required: boolean;
missing: boolean;
/** 当前 transport + mode 摘要,只用于错误文案。 */
summary: string;
alreadyConfigured: boolean;
clearRequested: boolean;
onToggleClear: (next: boolean) => void;
}): React.ReactNode {
// 掩码秘密:面板只看到「已配置」,原文拿不回来 —— 留空保持原值,要删掉必须显式开关。
const maskedSecret = field.secret === true && alreadyConfigured;
const options = field.options ?? [];
const unknownOption = value !== "" && options.length > 0 && !options.some((o) => o.value === value);
return (
<div className="space-y-1">
<span className="flex flex-wrap items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
{field.label}
{required ? (
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">
必填
</span>
) : (
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-surface-5)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-lo)]">
可选
</span>
)}
</span>
{options.length > 0 ? (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={field.label}
className={missing && required ? inputClsError : inputCls}
>
{unknownOption ? <option value={value}>{value}(不在候选内,保存时原样保留)</option> : null}
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
type={field.secret ? "password" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={clearRequested}
className={missing && required ? inputClsError : inputCls}
/>
)}
<span className="block text-[11px] leading-relaxed text-[var(--mu-text-lo)]">{field.guide}</span>
{missing && required ? (
<span className="block text-[11px] text-[var(--mu-err)]">
当前接入方式({summary})下必填,留空适配器起不来。
</span>
) : null}
{maskedSecret ? (
<span className="block space-y-1">
<span className="block text-[11px] text-[var(--mu-text-lo)]">
已配置(后端只下发掩码,面板不持有原文):留空 = 不改动。
</span>
<Switch
checked={clearRequested}
onChange={onToggleClear}
label={`清除已配置的 ${field.key}(保存后生效)`}
/>
</span>
) : 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)]";
const inputClsError =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none ring-1 ring-[var(--mu-err)] focus:ring-[var(--mu-err)]";
@@ -0,0 +1,227 @@
// 渠道配置字段「按 transport × mode 显隐 + 必填 + 写回补丁」的逻辑测试(design/08 §4 四端形态)。
//
// 锁三件事:
// ① **显隐**:qq-obv11 的字段含义随 transport(ws|http) × mode(client|server) 变化。
// 平铺会造成两类真实事故——「填了用不上」(ws 下填 api_base_url)与「没填起不来」
// (server 模式留空 listen_addr,适配器 Connect 直接失败,见
// plugins/channels/qq_obv11/ws_server.go / http_server.go 的显式报错);
// ② **必填**:ws+client 缺 remote_url 是致命配置错(ws_client.go 的 errClientConfig),
// http+client 缺 api_base_url、server 端缺 listen_addr 同理;
// ③ **写回**:后端 `runtimeconfig.PatchChannel` 是三态语义(缺位 = 保留 / 非空 = 覆盖 /
// `""` = 清除)。面板必须只发「当前组合下真正显示、且主人动过或清空」的键 —— 否则
// 一次保存会静默抹掉盘上凭据,或者把隐藏字段的旧值悄悄改掉。
//
// 项目没有前端测试框架(表单是 React 组件,纯 node 下跑不了),所以这是一个零依赖的
// plain-node 检查:它驱动真实的纯模块 channelFieldRules.ts(表单校验与写回走的是同一份
// 函数),并在第一个失败处抛出。
//
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channelFieldRules.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-channel-field-rules.test.mjs \
// && node /tmp/cyrene-channel-field-rules.test.mjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。
import {
fieldRequired,
missingRequiredFields,
missingRequiredMessage,
modeHint,
modeSummary,
platformConfigPatch,
requiredFieldKeys,
selectionOf,
visibleFields,
} from "./channelFieldRules";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(g === w, `${what} — got ${g}, want ${w}`);
}
const keys = (vals: Record<string, string>): string[] => visibleFields("qq-obv11", vals).map((f) => f.key);
const has = (list: readonly string[], key: string): boolean => list.includes(key);
// ── ① 选择器归一化:与适配器 normalizeTransport / normalizeMode 同口径 ────────────
same(selectionOf("qq-obv11", {}), { transport: "ws", mode: "server" }, "空配置回落 ws + server(适配器默认)");
same(
selectionOf("qq-obv11", { transport: "", mode: "" }),
{ transport: "ws", mode: "server" },
"空串同样回落默认",
);
same(selectionOf("qq-obv11", { transport: "WS", mode: "Client" }), { transport: "ws", mode: "client" }, "大小写归一");
same(selectionOf("qq-obv11", { transport: "tcp", mode: "peer" }), { transport: "ws", mode: "server" }, "未知值回落默认(后端也是这个口径)");
// 非选择器平台的组合为空 —— 用它判定「有没有当前组合」。
same(selectionOf("kook", {}), { transport: "", mode: "" }, "没有选择器的平台不产生组合");
// ── ② 显隐:四种组合各自只显示用得到的字段 ───────────────────────────────────────
const wsServer = keys({ transport: "ws", mode: "server" });
ok(has(wsServer, "listen_addr") && has(wsServer, "access_token") && has(wsServer, "self_id"), "ws+server 显示 listen_addr/access_token/self_id");
ok(!has(wsServer, "remote_url"), "ws+server 不显示 remote_url(对方来连我们,没有目标地址)");
ok(!has(wsServer, "api_base_url"), "ws+server 不显示 api_base_url(ws 传输不读它)");
ok(!has(wsServer, "report_path"), "ws+server 不显示 report_path(HTTP 上报专用)");
const wsClient = keys({ transport: "ws", mode: "client" });
ok(has(wsClient, "remote_url"), "ws+client 显示 remote_url(拨号目标)");
ok(!has(wsClient, "listen_addr"), "ws+client 不显示 listen_addr(ws client 不接受入站监听)");
ok(!has(wsClient, "api_base_url"), "ws+client 不显示 api_base_url(拨号只认 remote_url,不会回退它)");
const httpServer = keys({ transport: "http", mode: "server" });
ok(has(httpServer, "listen_addr") && has(httpServer, "report_path") && has(httpServer, "api_base_url"), "http+server 显示 listen_addr/report_path/api_base_url");
ok(!has(httpServer, "remote_url"), "http+server 不显示 remote_url");
const httpClient = keys({ transport: "http", mode: "client" });
ok(has(httpClient, "api_base_url") && has(httpClient, "listen_addr") && has(httpClient, "report_path"), "http+client 显示 api_base_url,并可选显示 listen_addr/report_path");
ok(!has(httpClient, "remote_url"), "http+client 不显示 remote_url");
// 下拉型字段始终可见(不然没法切换组合)。
for (const combo of [wsServer, wsClient, httpServer, httpClient]) {
ok(has(combo, "transport") && has(combo, "mode"), "transport / mode 在任何组合下都可见");
}
// ── ③ 必填:各组合的「没填起不来」清单 ───────────────────────────────────────────
same(requiredFieldKeys("qq-obv11", { transport: "ws", mode: "server" }), ["listen_addr"], "ws+server 必填 listen_addr");
same(requiredFieldKeys("qq-obv11", { transport: "ws", mode: "client" }), ["remote_url"], "ws+client 必填 remote_url");
same(requiredFieldKeys("qq-obv11", { transport: "http", mode: "server" }), ["listen_addr"], "http+server 必填 listen_addr(api_base_url 只影响能否回消息)");
same(requiredFieldKeys("qq-obv11", { transport: "http", mode: "client" }), ["api_base_url"], "http+client 必填 api_base_url");
same(requiredFieldKeys("qq-obv11", { transport: "", mode: "" }), ["listen_addr"], "默认组合(ws+server)的必填清单");
// 非选择器平台:字段照常显示,但不产生必填(kook 无字段、webhook 的必填由 guide 提示,
// 后端对它并不硬失败 —— 面板不该拦下能保存的配置)。
same(requiredFieldKeys("webhook", {}), [], "无选择器的平台不产生必填");
same(visibleFields("webhook", {}).length, 5, "webhook 五个字段全部显示");
// ── ④ 必填缺失检测:已配置(掩码秘密)算已满足,否则会逼用户重打秘密 ───────────────
{
const missing = missingRequiredFields("qq-obv11", { transport: "ws", mode: "client", remote_url: "" });
same(missing.map((f) => f.key), ["remote_url"], "ws+client 缺 remote_url 被判缺失");
same(missingRequiredFields("qq-obv11", { transport: "ws", mode: "client", remote_url: "ws://x" }), [], "填了 remote_url 就不再缺失");
same(
missingRequiredFields("qq-obv11", { transport: "ws", mode: "client", remote_url: " " }).map((f) => f.key),
["remote_url"],
"全空白等于没填(会被拦下)",
);
// 盘上已有值(后端只下发掩码):算已满足。
same(
missingRequiredFields("qq-obv11", { transport: "ws", mode: "server", listen_addr: "" }, ["listen_addr"]),
[],
"盘上已配置的必填字段不再要求重填",
);
// 隐藏字段不参与必填判断。
same(missingRequiredFields("qq-obv11", { transport: "http", mode: "client", api_base_url: "http://a" }), [], "http+client 填了 api_base_url 即满足");
}
// 字段级判断(组件渲染「必填 / 可选」标记用的就是它)。
{
const fields = visibleFields("qq-obv11", { transport: "http", mode: "server" });
const pick = (key: string) => fields.find((f) => f.key === key)!;
const sel = selectionOf("qq-obv11", { transport: "http", mode: "server" });
ok(fieldRequired("qq-obv11", pick("listen_addr"), sel), "http+server 的 listen_addr 标必填");
ok(!fieldRequired("qq-obv11", pick("api_base_url"), sel), "http+server 的 api_base_url 标可选");
ok(!fieldRequired("qq-obv11", pick("access_token"), sel), "access_token 永远可选(空 = 不校验)");
}
// ── ⑤ 写回补丁:三态语义(缺位 = 保留 / 非空 = 覆盖 / "" = 清除) ──────────────────
{
// 新建 ws+server:填了 listen_addr,其余留空 ⇒ 只写有值的键(外加下拉的当前值)。
const patch = platformConfigPatch(
"qq-obv11",
{ transport: "ws", mode: "server", listen_addr: "127.0.0.1:5700" },
{ transport: "ws", mode: "server" },
);
same(
patch,
{ transport: "ws", mode: "server", listen_addr: "127.0.0.1:5700" },
"只把主人真填了的键写进 config",
);
}
{
// 可见字段被清空 = 显式清除(写 ""),后端据此删键 => 适配器回落自己的零值。
const patch = platformConfigPatch(
"qq-obv11",
{ transport: "ws", mode: "client", remote_url: "" },
{ transport: "ws", mode: "client", remote_url: "ws://127.0.0.1:3001" },
);
same(patch.remote_url, "", "清空一个原本有值的可见字段 ⇒ 发空串(显式清除)");
}
{
// 切到别的组合后字段被隐藏:它的盘上旧值必须**保留**,不能被抹掉。
const current = { transport: "ws", mode: "server", listen_addr: "127.0.0.1:5700", api_base_url: "http://127.0.0.1:3000" };
const initial = { transport: "http", mode: "server", listen_addr: "127.0.0.1:5700", api_base_url: "http://127.0.0.1:3000" };
const patch = platformConfigPatch("qq-obv11", current, initial);
ok(!("api_base_url" in patch), "隐藏字段不进补丁(缺位 = 后端保留盘上旧值)");
same(patch.listen_addr, "127.0.0.1:5700", "仍可见的字段照常写回");
}
{
// 掩码秘密:面板不持有原文,留空 ≠ 清除;只有显式清除才写 ""。
same(
platformConfigPatch("qq-obv11", { transport: "ws", mode: "server", access_token: "" }, {}),
{ transport: "ws", mode: "server" },
"掩码秘密留空 ⇒ 不进补丁(不会用空值覆盖已配置的 token)",
);
same(
platformConfigPatch("qq-obv11", { transport: "ws", mode: "server", access_token: "" }, {}, ["access_token"]),
{ transport: "ws", mode: "server", access_token: "" },
"显式清除掩码秘密 ⇒ 发空串删键",
);
same(
platformConfigPatch("qq-obv11", { transport: "ws", mode: "server", access_token: "new-token" }, {}, ["access_token"]),
{ transport: "ws", mode: "server", access_token: "" },
"清除开关优先(用户改了主意又留着开关,以显式清除为准)",
);
}
{
// 非选择器平台:字段值原样写回,清空同样走显式清除。
same(
platformConfigPatch("webhook", { endpointPath: "/hook", channelId: "c1" }, { endpointPath: "/webhook" }),
{ endpointPath: "/hook", channelId: "c1" },
"webhook 字段照常写回",
);
same(
platformConfigPatch("webhook", { endpointPath: "" }, { endpointPath: "/webhook" }).endpointPath,
"",
"webhook 清空 endpointPath ⇒ 显式清除(后端回落默认 /webhook)",
);
}
// ── ⑥ 提示文案:切换模式必须说清「当前模式需要什么」 ─────────────────────────────
{
const combos: Array<[string, string]> = [
["ws", "server"],
["ws", "client"],
["http", "server"],
["http", "client"],
];
for (const [transport, mode] of combos) {
const hint = modeHint("qq-obv11", { transport, mode });
ok(hint.length > 0, `${transport}+${mode} 必须有一句话提示`);
same(modeSummary("qq-obv11", { transport, mode }), `${transport} + ${mode}`, `${transport}+${mode} 的摘要`);
}
same(modeHint("kook", {}), "", "没有模式概念的平台不给提示");
// 提示里必须点名「谁连谁」的关键词,避免写成泛泛的「请填写配置」。
ok(modeHint("qq-obv11", { transport: "ws", mode: "server" }).includes("listen_addr"), "ws+server 的提示点名 listen_addr");
ok(modeHint("qq-obv11", { transport: "ws", mode: "client" }).includes("remote_url"), "ws+client 的提示点名 remote_url");
ok(modeHint("qq-obv11", { transport: "http", mode: "client" }).includes("api_base_url"), "http+client 的提示点名 api_base_url");
}
// ── ⑦ 错误文案 ────────────────────────────────────────────────────────────────
{
const missing = missingRequiredFields("qq-obv11", { transport: "ws", mode: "client" });
const msg = missingRequiredMessage("qq-obv11", { transport: "ws", mode: "client" }, missing);
ok(msg.includes("ws + client"), `错误文案写明当前组合 — got ${msg}`);
ok(msg.includes("remote_url"), `错误文案点名缺的字段 — got ${msg}`);
}
console.log(`channel field rules test: ${checks} checks passed`);
@@ -0,0 +1,194 @@
/** 渠道配置字段的**显隐 / 必填 / 写回**规则(纯函数,无 React、无 IO)。
*
* 为什么单独一个文件:qq-obv11 的字段含义随 `transport` × `mode` 四种组合变化——
* `remote_url` 只在 ws+client 有意义且必填,`listen_addr` 只在 server 端(及
* http+client 的可选收报端)有意义,`api_base_url` / `report_path` 只在 http 传输下
* 被读。面板平铺全部字段会造成两类事故:
* ① **填了用不上**(在 ws 下填 api_base_url,以为能连上);
* ② **没填起不来**(server 模式留空 listen_addr,适配器直接 Connect 失败)。
* 所以规则集中在这里、由单测锁住,组件只负责渲染。
*
* 字段元数据(label / guide / showIn / requiredIn / options)在 `channelFields.ts`。
* 选择器的归一化口径**必须与适配器一致**:`plugins/channels/qq_obv11/adapter.go` 的
* normalizeTransport / normalizeMode(空值或未知值回落 ws / server)。 */
import { PLATFORM_FIELDS, PLATFORM_GUIDE, type PlatformField, type FieldSelector } from "./channelFields";
/** 选择器取值:resolved 之后一定是适配器认得的字符串(无选择器的平台为空串)。 */
export interface ModeSelection {
transport: string;
mode: string;
}
/** 该平台的选择器字段名(无则 undefined)。 */
function selectorKeys(platform: string): { transport?: string; mode?: string } {
return PLATFORM_GUIDE[platform]?.selectors ?? {};
}
function fieldDefinitions(platform: string): readonly PlatformField[] {
return PLATFORM_FIELDS[platform] ?? [];
}
function normalizeOption(raw: string): string {
return raw.trim().toLowerCase();
}
/** 选择器缺省值(与适配器默认一致);未登记的维度返回空串。 */
function selectorDefault(platform: string, dim: "transport" | "mode"): string {
return normalizeOption(PLATFORM_GUIDE[platform]?.selectorDefaults?.[dim] ?? "");
}
/** 面板初值:选择器缺省(让「当前模式」从打开表单的第一眼就是对的)。 */
export function defaultSelection(platform: string): ModeSelection {
return { transport: selectorDefault(platform, "transport"), mode: selectorDefault(platform, "mode") };
}
/** 解析当前 transport / mode:取表单值,空值或**未知值**都回落默认——与适配器归一化同口径
* (`normalizeTransport`/`normalizeMode` 把未知值也当成默认),否则「面板按 client 校验必填」
* 而「后端按 server 起」,用户看到的是「都填了还起不来」;更糟的是盘上一个手写错的
* `transport` 会让所有字段都匹配不到条件,面板变得什么都填不了。 */
export function selectionOf(platform: string, values: Readonly<Record<string, string>>): ModeSelection {
const keys = selectorKeys(platform);
const pick = (dim: "transport" | "mode", key?: string): string => {
if (!key) return "";
const raw = normalizeOption(values[key] ?? "");
if (raw === "") return selectorDefault(platform, dim);
const declared = (fieldDefinitions(platform).find((f) => f.key === key)?.options ?? []).map((o) =>
normalizeOption(o.value),
);
if (declared.length > 0 && !declared.includes(raw)) return selectorDefault(platform, dim);
return raw;
};
return {
transport: pick("transport", keys.transport),
mode: pick("mode", keys.mode),
};
}
/** 选择器条件是否命中当前组合:未给出的维度 = 任意。 */
function matches(sel: FieldSelector, current: ModeSelection): boolean {
if (sel.transport && normalizeOption(sel.transport) !== current.transport) return false;
if (sel.mode && normalizeOption(sel.mode) !== current.mode) return false;
return true;
}
/** 该字段在当前组合下是否显示。平台没有登记选择器时一律显示——没有选择器就没有
* 「当前组合」可比,把字段藏起来只会让值无从填写。 */
export function fieldVisible(platform: string, field: PlatformField, current: ModeSelection): boolean {
const hasDrivers = Object.keys(selectorKeys(platform)).length > 0;
if (!hasDrivers || !field.showIn || field.showIn.length === 0) return true;
return field.showIn.some((sel) => matches(sel, current));
}
/** 该字段在当前组合下是否必填。 */
export function fieldRequired(platform: string, field: PlatformField, current: ModeSelection): boolean {
const hasDrivers = Object.keys(selectorKeys(platform)).length > 0;
if (!hasDrivers || !field.requiredIn || field.requiredIn.length === 0) return false;
return field.requiredIn.some((sel) => matches(sel, current));
}
/** 当前组合下应显示的字段(保持元数据里的声明顺序)。 */
export function visibleFields(platform: string, values: Readonly<Record<string, string>>): PlatformField[] {
const current = selectionOf(platform, values);
return fieldDefinitions(platform).filter((f) => fieldVisible(platform, f, current));
}
/** 当前组合下必填的字段键(面板的「必填」标记与错误文案都从这条路径取,`fieldRequired`
* 是同一份判定的单字段版本)。 */
export function requiredFieldKeys(platform: string, values: Readonly<Record<string, string>>): string[] {
const current = selectionOf(platform, values);
return fieldDefinitions(platform)
.filter((f) => fieldVisible(platform, f, current) && fieldRequired(platform, f, current))
.map((f) => f.key);
}
/** 当前组合的必填字段里**还没值**的那些。
*
* `configuredKeys` 是盘上已有值的键(后端只下发掩码,面板拿不到原文):它们算已满足——
* 否则「已配置 secret」的必填字段会在每次打开表单时被误报缺失,逼用户把秘密重打一遍。 */
export function missingRequiredFields(
platform: string,
values: Readonly<Record<string, string>>,
configuredKeys: readonly string[] = [],
): PlatformField[] {
const current = selectionOf(platform, values);
const configured = new Set(configuredKeys);
return fieldDefinitions(platform).filter(
(f) =>
fieldVisible(platform, f, current) &&
fieldRequired(platform, f, current) &&
(values[f.key] ?? "").trim() === "" &&
!configured.has(f.key),
);
}
/** 保存时写进 config 的补丁(后端 `runtimeconfig.PatchChannel` 的三态语义):
*
* - 字段有值 → 写入该值(覆盖盘上旧值);
* - 字段留空且盘上原本有值 → 写入 `""`(**显式清除**:可见字段被清空是用户意图,不许
* 静默保留旧值);
* - 当前组合下隐藏的字段 → **一律不出现在补丁里**(先判可见性):切到别的组合后字段看不
* 见了,它的盘上旧值由「缺位 = 保留」保住,切回去还在,绝不因为一次保存被抹掉,也不会
* 在用户看不见的情况下被重写。面板的口径是「所见即所写」;
* - 字段留空且盘上原本没值 → **不出现在补丁里**(缺位 = 保留,也避免把 `""` 写进
* 一个从没配过的键)。
*
* `clearedKeys` 是用户显式勾选清除的键(掩码秘密:面板不持有原文,留空 ≠ 清除,
* 必须由用户明确要求)。 */
export function platformConfigPatch(
platform: string,
current: Readonly<Record<string, string>>,
initial: Readonly<Record<string, string>>,
clearedKeys: readonly string[] = [],
): Record<string, string> {
const selection = selectionOf(platform, current);
const cleared = new Set(clearedKeys);
const out: Record<string, string> = {};
for (const f of fieldDefinitions(platform)) {
if (!fieldVisible(platform, f, selection)) continue;
const cur = (current[f.key] ?? "").trim();
if (cleared.has(f.key)) {
out[f.key] = "";
continue;
}
if (cur !== "") {
out[f.key] = cur;
continue;
}
if ((initial[f.key] ?? "").trim() === "") continue;
out[f.key] = "";
}
return out;
}
/** 当前组合的一句话「谁连谁」提示(随 transport / mode 下拉实时变化)。 */
export function modeHint(platform: string, values: Readonly<Record<string, string>>): string {
const hints = PLATFORM_GUIDE[platform]?.modeHints;
if (!hints) return "";
const { transport, mode } = selectionOf(platform, values);
return hints[`${transport}+${mode}`] ?? "";
}
/** 该平台**真正读取**鉴权值的 config 键(缺省 bot_token)。配置表单的 token 区块与渠道
* 列表的「已配置 / 未填」标记共用这一份口径:拿 bot_token 去判断 qq-obv11 会永远显示
* 「token 未填」,即使 access_token 早就配好了。 */
export function authKeyOf(platform: string): string {
return PLATFORM_GUIDE[platform]?.authKey ?? "bot_token";
}
/** 必填字段的缺失文案(保存前拦一次,把「起不来」变成面板上一句话)。 */
export function missingRequiredMessage(
platform: string,
values: Readonly<Record<string, string>>,
missing: readonly PlatformField[],
): string {
const names = missing.map((f) => f.label.split("(")[0]).join("、");
return `当前接入方式(${modeSummary(platform, values)})缺少必填字段:${names}`;
}
/** 「ws + server」这样的一行组合摘要,用于提示与错误文案。 */
export function modeSummary(platform: string, values?: Readonly<Record<string, string>>): string {
const sel = values ? selectionOf(platform, values) : defaultSelection(platform);
const parts = [sel.transport, sel.mode].filter((x) => x !== "");
return parts.join(" + ");
}
+166 -19
View File
@@ -1,6 +1,21 @@
/** Channel config field metadata + per-platform guidance (design/16 §4.4-4.6).
/** Channel config field metadata + per-platform guidance (design/16 §4.4-4.6, design/08 §4).
* Separated from Channels.tsx so the module stays under the 500-line bound and a
* platform only needs a new entry here. */
* platform only needs a new entry here.
*
* 显隐 / 必填 / 预填的**判定逻辑**在 `channelFieldRules.ts`:本文件只放数据,规则变化
* 不必改这里。字段的 `key` 必须是适配器 Config 的 json tag 原文(后端把面板给的键原样
* 并进 config map,键名写错 = 配了不生效)。 */
/** 选择器条件(transport / mode);未给出的维度 = 任意,空数组/缺省 = 不参与判定。 */
export interface FieldSelector {
transport?: string;
mode?: string;
}
export interface FieldOption {
value: string;
label: string;
}
/** One curated config input; `key` is the adapter Config json tag the backend decodes. */
export interface PlatformField {
@@ -9,6 +24,18 @@ export interface PlatformField {
placeholder?: string;
/** Masked [type=password] input (token / verify / encrypt / secret). */
secret?: boolean;
/** 一行中文说明:写清「谁连谁」「空值代表什么」「哪个组合下必填」。
* 面板上每个可填字段都必须有它——没有说明的字段等于让主人猜。 */
guide: string;
/** 有候选就渲染下拉:值域受后端归一化约束(如 transport 只认 ws/http),
* 自由输入会把用户送进「填了个未知值 → 后端静默回落默认」的坑。 */
options?: readonly FieldOption[];
/** 下拉缺省选中值;必须与适配器 normalize* 的默认口径一致(qq-obv11: ws / server)。 */
defaultValue?: string;
/** 显示条件(命中任一即显示);缺省 = 始终显示。 */
showIn?: readonly FieldSelector[];
/** 必填条件(命中任一即必填);缺省 = 可留空(如「空 = 不校验」的 access_token)。 */
requiredIn?: readonly FieldSelector[];
}
/** Platform-specific config fields only (the COMMON fields are rendered separately).
@@ -16,22 +43,117 @@ export interface PlatformField {
export const PLATFORM_FIELDS: Record<string, PlatformField[]> = {
kook: [], // KOOK is "token 即用、其余自动获取"—advanced values go in the extra JSON.
"qq-obv11": [
{ key: "mode", label: "mode(client/server)", placeholder: "server" },
{ key: "listen_addr", label: "listen_addr(server 监听)", placeholder: "0.0.0.0:6700" },
{ key: "remote_url", label: "remote_url(client 连接)", placeholder: "ws://…" },
{ key: "access_token", label: "access_token(OneBot 访问 token)", secret: true },
{ key: "self_id", label: "self_id(机器人 QQ 号)", placeholder: "123456789" },
{
key: "transport",
label: "transport(传输方式)",
guide: "ws = 长连接 WebSocket;http = OneBot 的 HTTP API(发)+ 上报(收)。不确定就用 ws——默认值,与旧部署行为一致。",
options: [
{ value: "ws", label: "ws(WebSocket,默认)" },
{ value: "http", label: "http(HTTP API + 上报)" },
],
defaultValue: "ws",
},
{
key: "mode",
label: "mode(谁连谁)",
guide: "server = 对方(NapCat / go-cqhttp)来连我们,本地要开端口;client = 我们去连对方,对方没起也不阻塞。",
options: [
{ value: "server", label: "server(对方来连我们)" },
{ value: "client", label: "client(我们去连对方)" },
],
defaultValue: "server",
},
{
key: "remote_url",
label: "remote_url(对方 ws 地址,我们去连)",
placeholder: "ws://127.0.0.1:3001",
guide: "client 必填:对方的 ws 端点(如 ws://127.0.0.1:3001)。对方没起不影响启动——Cyrene 按指数退避在后台自动重试。ws 拨号只认这个键,不会去读 api_base_url。",
showIn: [{ transport: "ws", mode: "client" }],
requiredIn: [{ transport: "ws", mode: "client" }],
},
{
key: "listen_addr",
label: "listen_addr(本地监听地址,等对方来连)",
placeholder: "127.0.0.1:5700",
guide: "server 必填:对方来连我们,端口必须在这里显式给出(空着不会开监听)。ws 下对方连 ws://{listen_addr}/,http 下上报打到 http://{listen_addr}{report_path}。client 模式下可选:填了才同时收对方上报。",
showIn: [{ mode: "server" }, { transport: "http", mode: "client" }],
requiredIn: [{ mode: "server" }],
},
{
key: "api_base_url",
label: "api_base_url(OneBot HTTP API 基址)",
placeholder: "http://127.0.0.1:3000",
guide: "http+client 必填:Cyrene 调对方的 HTTP API 发消息(如 http://127.0.0.1:3000)。http+server 可选:填了才有「回消息」能力(只收上报也能收,但发不出去)。ws 传输不读这个键。",
showIn: [{ transport: "http" }],
requiredIn: [{ transport: "http", mode: "client" }],
},
{
key: "report_path",
label: "report_path(上报接收路径)",
placeholder: "/",
guide: "只有我们开了监听才用得上(http+server,或 http+client 也填了 listen_addr):OneBot 把事件 POST 到这个路径,留空 = /。",
showIn: [{ transport: "http" }],
},
{
key: "access_token",
label: "access_token(OneBot 鉴权 token)",
secret: true,
guide: "空 = 不校验(server 模式下任何对方都能连上来,仅本机部署可接受)。填了以后:server 端要求对方带 Authorization: Bearer 或 ?access_token=,反之 403;client 端我们主动带上。四种组合共用一个值。",
},
{
key: "self_id",
label: "self_id(机器人自身 QQ 号)",
placeholder: "123456789",
guide: "可选:只作记录/展示用,连接不依赖它;真实身份以对方上报的 self_id 为准。",
},
],
telegram: [
{
key: "webhook_url",
label: "webhook_url(公网回调地址)",
placeholder: "https://…",
guide: "可选:填了走 Webhook(Telegram 主动回调这个公网地址),留空走长轮询——本地/内网部署留空即可。",
},
],
telegram: [{ key: "webhook_url", label: "webhook_url(公网回调地址)", placeholder: "https://…" }],
"weixin-ilink": [
{ key: "cdn_base_url", label: "cdn_base_url(媒体 CDN,留空自动)", placeholder: "https://…" },
{
key: "cdn_base_url",
label: "cdn_base_url(媒体 CDN,留空自动)",
placeholder: "https://…",
guide: "可选:收发图片/文件的 CDN 基址,留空用平台默认地址;只有自建代理时才需要改。",
},
],
webhook: [
{ key: "endpointPath", label: "endpointPath(挂载路由)", placeholder: "/webhook" },
{ key: "channelId", label: "channelId(渠道 id)", placeholder: "…" },
{ key: "channelName", label: "channelName(渠道名)", placeholder: "…" },
{ key: "secret", label: "secret(HMAC 签名密钥)", secret: true },
{ key: "signatureHeader", label: "signatureHeader(签名头)", placeholder: "X-Webhook-Signature" },
{
key: "endpointPath",
label: "endpointPath(挂载路由)",
placeholder: "/webhook",
guide: "外部系统 POST 到 http://{后端地址}/api/v1{endpointPath},是外部系统连我们的入口;留空 = /webhook。",
},
{
key: "channelId",
label: "channelId(渠道 id)",
placeholder: "…",
guide: "建议填:这条入站消息归属的渠道标识,写进消息元数据(身份识别靠它,不靠提示词);留空时该渠道不会出现在渠道列表里。",
},
{
key: "channelName",
label: "channelName(渠道名)",
placeholder: "…",
guide: "可选:渠道显示名,只影响面板与日志可读性。",
},
{
key: "secret",
label: "secret(HMAC 签名密钥)",
secret: true,
guide: "强烈建议填:校验外部系统带来的签名头。留空 = 不校验来源,任何人知道路由就能往渠道里推消息。",
},
{
key: "signatureHeader",
label: "signatureHeader(签名头)",
placeholder: "X-Webhook-Signature",
guide: "签名放在哪个请求头,留空 = X-Webhook-Signature;与 secret 配对使用。",
},
],
};
@@ -40,29 +162,54 @@ export interface PlatformGuide {
guide: string;
hint?: string;
tokenRequired?: boolean;
/** 适配器**真正读取**鉴权值的 config 键(缺省 bot_token)。与 bot_token 不同时面板不再把
* 顶部 bot_token 输入标成必填,并明说「本渠道不读该键」——曾出现主人按「必填」在
* bot_token 里填了 token,而 qq-obv11 只认 access_token,于是填了也连不上。 */
authKey?: string;
/** 驱动字段显隐/必填的选择器键(qq-obv11 是 transport × mode)。 */
selectors?: { transport?: string; mode?: string };
/** 选择器缺省值:必须与适配器 normalize* 的默认一致(否则面板按 client 校验必填,
* 后端实际按 server 起,用户看到的是「都填了还起不来」)。 */
selectorDefaults?: { transport?: string; mode?: string };
/** 当前 transport+mode 组合的一句话提示(key = `${transport}+${mode}`),随下拉实时切换。 */
modeHints?: Readonly<Record<string, string>>;
}
export const PLATFORM_GUIDE: Record<string, PlatformGuide> = {
kook: {
guide: "只需填 bot_token(必填),其余自动获取",
hint: "base_url 留空即用官方地址;receive_mode 默认 ws、webhook_path 默认 /webhook/kook,均无需填写;连接后渠道列表自动显示该 bot 身份。如需 webhook(receive_mode=webhook)或 webhook_verify_token / webhook_encrypt_key,在「高级选项 → 额外 config」里以 JSON 设置。",
authKey: "bot_token",
},
"qq-obv11": {
guide: "填 token(必填)并选择接入方式(mode:client / server)",
hint: "mode=server 填 listen_addr(本地监听端口);mode=client 填 remote_url(连接上游)。mode / listen_addr / remote_url / access_token 在「高级选项」里配置。",
guide: "先选接入方式(transport × mode),再填该组合必填的地址;token 键是 access_token(不是 bot_token)",
hint: "四种组合:ws+server 等对方反连(填 listen_addr)、ws+client 去连对方(填 remote_url)、http+server 收对方 HTTP 上报(填 listen_addr,可选 api_base_url 才能回消息)、http+client 调对方 HTTP API(填 api_base_url)。面板会按当前组合只显示用得到的字段并校验必填。",
tokenRequired: false,
authKey: "access_token",
selectors: { transport: "transport", mode: "mode" },
selectorDefaults: { transport: "ws", mode: "server" },
modeHints: {
"ws+server": "当前 ws + server:Cyrene 监听 listen_addr,等对方用「反向 WS」连上来——listen_addr 必填,空着不会开端口。",
"ws+client": "当前 ws + client:Cyrene 主动拨号 remote_url;对方没起也会后台自动重试,不影响启动。",
"http+server": "当前 http + server:Cyrene 监听 listen_addr 收 OneBot 的 HTTP 上报(report_path);要能回消息还需 api_base_url。",
"http+client": "当前 http + client:Cyrene 调对方的 api_base_url 发消息;不填 listen_addr 就收不到对方上报。",
},
},
telegram: {
guide: "只需填 bot_token(必填),其余自动获取",
hint: "webhook_url(公网回调,可选)在「高级选项」里配置;留空则走长轮询。",
hint: "webhook_url(公网回调,可选)留空则走长轮询;需要时在下方「连接配置」里填写。",
authKey: "bot_token",
},
"weixin-ilink": {
guide: "只需填 bot_token(必填,或连接时触发扫码登录自动获取),其余自动获取",
hint: "连接后自动获取 bot 身份(ilink_bot_id / bot_id)并持续维护 get_updates_buf 游标;需要媒体 CDN 时可在「高级选项」配 cdn_base_url(留空自动)。",
hint: "连接后自动获取 bot 身份(ilink_bot_id / bot_id)并持续维护 get_updates_buf 游标;需要媒体 CDN 时可在下方「连接配置」配 cdn_base_url(留空自动)。",
authKey: "bot_token",
},
webhook: {
guide: "填 endpointPath 与 secret(必填)",
hint: "endpointPath / secret / channelId / signatureHeader 在「高级选项」里配置,用于接收平台回调。",
hint: "字段在下方「连接配置」里填写,用于接收平台回调;外部系统按你的 endpointPath 推消息进来。",
tokenRequired: false,
authKey: "secret",
},
};
@@ -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(","),