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} /> - ))}