diff --git a/frontend/web/src/admin/modules/channelAccountId.tsx b/frontend/web/src/admin/modules/channelAccountId.tsx
new file mode 100644
index 0000000..7e5f1dc
--- /dev/null
+++ b/frontend/web/src/admin/modules/channelAccountId.tsx
@@ -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 (
+
+ );
+}
+
+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)]";
diff --git a/frontend/web/src/admin/modules/channelConfigForm.render.test.ts b/frontend/web/src/admin/modules/channelConfigForm.render.test.ts
new file mode 100644
index 0000000..d78965c
--- /dev/null
+++ b/frontend/web/src/admin/modules/channelConfigForm.render.test.ts
@@ -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, 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, ">必填") === 1, `qq-obv11 面板只有 1 个「必填」标记,got ${count(html, ">必填")}`);
+}
+
+// ── ② 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, ">必填") === 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, ">必填") === 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`);
diff --git a/frontend/web/src/admin/modules/channelConfigForm.tsx b/frontend/web/src/admin/modules/channelConfigForm.tsx
index a001583..9825a93 100644
--- a/frontend/web/src/admin/modules/channelConfigForm.tsx
+++ b/frontend/web/src/admin/modules/channelConfigForm.tsx
@@ -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;
@@ -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>(initial.platVals);
+ // 打开表单时的原始平台字段值:留空 ≠ 清除(后端 patch 缺位 = 保留),要判断「用户把
+ // 盘上已有值清空了」必须知道它原本是什么。
+ const [initialVals] = useState>(() => ({ ...initial.platVals }));
+ const [clearedSecrets, setClearedSecrets] = useState([]);
+ const [missingKeys, setMissingKeys] = useState([]);
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 = {};
- for (const f of PLATFORM_FIELDS[platform] ?? []) {
- const v = (platVals[f.key] ?? "").trim();
- if (v) specificMap[f.key] = v;
- }
+ const specificMap: Record = platformConfigPatch(platform, platVals, initialVals, clearedSecrets);
let configMap: Record | 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 只在写文件时出现,列表/响应不回显裸值。
- {/* 核心必填:最小必备字段 + 醒目 Callout / 高亮 Tag,不平铺全量 */}
+ {/* 账号与凭据(token)区:token 是各平台真正的入口凭据;qq-obv11 的 token 在连接
+ 配置里(access_token),这里只放账号 id 与「本渠道不读 bot_token」的说明。 */}
- 核心必填
+ {guide.tokenRequired === false ? "核心配置(账号与鉴权)" : "核心必填"}
{PLATFORM_GUIDE[platform].guide}
-
-
+
+ {authKey === "bot_token" ? (
+
+ ) : (
+ // 本渠道不读 bot_token:与其让主人对着一个「必填」框填一份没人读的凭据,
+ // 不如直说凭据在哪个键、去哪填(qq-obv11 只认 access_token)。
+
+
+ 本渠道不读 bot_token
+ 鉴权键:{authKey}
+
+
+ 该适配器读取的鉴权键是 {authKey}(写在下方「连接配置」里)。
+ 这里没有 bot_token 输入框,也就不会有一份填了没人读的凭据;已配置的值不会被这里覆盖。
+
+
+ )}
- {PLATFORM_GUIDE[platform].hint ? (
-
{PLATFORM_GUIDE[platform].hint}
+ {guide.hint ? (
+
{guide.hint}
) : null}
+ {/* 连接配置:该平台**能不能连上**就在这里。平台字段只渲染当前 transport × mode 下
+ 真正生效的那些,并逐字段给说明与必填校验(不再藏在高级选项里等人自己找)。 */}
+ {(PLATFORM_FIELDS[platform] ?? []).length > 0 ? (
+
+
+ {connOpen ? (
+
+
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),
+ )
+ }
+ />
+
+ ) : null}
+
+ ) : null}
+
{
@@ -193,7 +303,7 @@ export function ChannelConfigForm({
loadError={personas.error}
/>
- {/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
+ {/* 高级选项:base_url / 额外 config。平台连接字段已在上面单独一区,不再重复。 */}
{advOpen ? (
- {(PLATFORM_FIELDS[platform] ?? []).map((f) => (
- setPlatVals((prev) => ({ ...prev, [f.key]: v }))} placeholder={f.placeholder} secret={f.secret} />
- ))}
) : null}
@@ -255,8 +365,34 @@ export function ChannelConfigForm({
) : null}
+ {/* 改 id 保存 = 后端新增一条(原条目保留):先弹一次明确确认,绝不悄悄多出一个适配器。 */}
+ {confirmNewId && idCreatesNew ? (
+
+
+ 请确认
+ 账号 ID 从「{savedId}」改成了「{typedId}」
+
+
+ 后端按 id 写入:这个 id 不存在,所以会新增一条账号
+ ,原账号「{savedId}」保留并继续运行 —— 同一平台会同时存在两个适配器。
+ 若本意是修改当前那条,请把 ID 改回「{savedId}」或留空。
+
+
+
+
+
+
+
+ ) : null}
+
-
+
@@ -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 (
-
-
- 角色(渠道级绑定)
- {bound === "" ? (
- 未绑定 · 跟随默认角色
- ) : (
- 已绑定 · {known?.name ?? bound}
- )}
-
-
-
- 角色解析顺序:会话绑定 > 渠道绑定 > 全局默认。
- 渠道绑定只影响该账号与主人的私聊等非隔离会话;
- 群聊 / 陌生人会话始终使用默认角色,此处的绑定覆盖不了它们。
-
- {loadError ? (
-
- 角色列表读取失败({loadError}):不改动该下拉时保存会原样保留当前绑定;请确认后端在线后重试再改。
-
- ) : null}
-
- );
-}
-
/** Compact labeled input for the config form; `secret` masks the field. */
interface FieldProps {
label: string;
diff --git a/frontend/web/src/admin/modules/channelConnFields.tsx b/frontend/web/src/admin/modules/channelConnFields.tsx
new file mode 100644
index 0000000..e646788
--- /dev/null
+++ b/frontend/web/src/admin/modules/channelConnFields.tsx
@@ -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>;
+ 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 (
+
+ {hint ? (
+
+
+ {hint}
+
+ ) : null}
+
+ {fields.map((f) => (
+ 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)}
+ />
+ ))}
+
+
+ );
+}
+
+/** 一个字段:标题 + 必填/可选标记 + 输入(下拉或文本框)+ 一行中文说明 + 状态行。 */
+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 (
+
+
+ {field.label}
+ {required ? (
+
+ 必填
+
+ ) : (
+
+ 可选
+
+ )}
+
+ {options.length > 0 ? (
+
+ ) : (
+ onChange(e.target.value)}
+ placeholder={field.placeholder}
+ disabled={clearRequested}
+ className={missing && required ? inputClsError : inputCls}
+ />
+ )}
+ {field.guide}
+ {missing && required ? (
+
+ 当前接入方式({summary})下必填,留空适配器起不来。
+
+ ) : null}
+ {maskedSecret ? (
+
+
+ 已配置(后端只下发掩码,面板不持有原文):留空 = 不改动。
+
+
+
+ ) : null}
+
+ );
+}
+
+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)]";
diff --git a/frontend/web/src/admin/modules/channelFieldRules.test.ts b/frontend/web/src/admin/modules/channelFieldRules.test.ts
new file mode 100644
index 0000000..2a6c3de
--- /dev/null
+++ b/frontend/web/src/admin/modules/channelFieldRules.test.ts
@@ -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[] => 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`);
diff --git a/frontend/web/src/admin/modules/channelFieldRules.ts b/frontend/web/src/admin/modules/channelFieldRules.ts
new file mode 100644
index 0000000..c109209
--- /dev/null
+++ b/frontend/web/src/admin/modules/channelFieldRules.ts
@@ -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>): 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>): PlatformField[] {
+ const current = selectionOf(platform, values);
+ return fieldDefinitions(platform).filter((f) => fieldVisible(platform, f, current));
+}
+
+/** 当前组合下必填的字段键(面板的「必填」标记与错误文案都从这条路径取,`fieldRequired`
+ * 是同一份判定的单字段版本)。 */
+export function requiredFieldKeys(platform: string, values: Readonly>): 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>,
+ 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>,
+ initial: Readonly>,
+ clearedKeys: readonly string[] = [],
+): Record {
+ const selection = selectionOf(platform, current);
+ const cleared = new Set(clearedKeys);
+ const out: Record = {};
+ 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>): 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>,
+ 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>): string {
+ const sel = values ? selectionOf(platform, values) : defaultSelection(platform);
+ const parts = [sel.transport, sel.mode].filter((x) => x !== "");
+ return parts.join(" + ");
+}
diff --git a/frontend/web/src/admin/modules/channelFields.ts b/frontend/web/src/admin/modules/channelFields.ts
index 22e0985..1a39a0e 100644
--- a/frontend/web/src/admin/modules/channelFields.ts
+++ b/frontend/web/src/admin/modules/channelFields.ts
@@ -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 = {
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>;
}
export const PLATFORM_GUIDE: Record = {
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",
},
};
diff --git a/frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx b/frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx
index dd3a6b3..7536864 100644
--- a/frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx
+++ b/frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx
@@ -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();
+ 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;
}
diff --git a/frontend/web/src/admin/modules/channels/ListPage.tsx b/frontend/web/src/admin/modules/channels/ListPage.tsx
index b515fe5..d7b0f6a 100644
--- a/frontend/web/src/admin/modules/channels/ListPage.tsx
+++ b/frontend/web/src/admin/modules/channels/ListPage.tsx
@@ -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 —;
}
+ // 「凭据是否已配」必须看该平台**真正读取**的键:qq-obv11 读 access_token、webhook 读
+ // secret,用 bot_token 去判断会让配好的渠道永远显示「未填」。
+ const authKey = authKeyOf(platformOf(record.name));
+ const authLabel = authKey === "bot_token" ? "token" : authKey;
return (
{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 (
- {tokenMasked ? "token 已配置" : "token 未填"}
+ {tokenMasked ? `${authLabel} 已配置` : `${authLabel} 未填`}
+
{a.id ? (