import { useState } from "react"; import { adminListPersonas, adminSaveChannelConfig } from "../../lib/api"; import type { PersonaRecord } from "../../lib/types"; import { useAsyncData } from "../../lib/useAsyncData"; import { useAuditStore } from "../audit"; import { Button, Surface, Tag } from "../../components/primitives"; import { Icon } from "../../components/Icon"; import { withChannelPersona } from "./channels/channelPersona"; import { limitRequestBody } from "./channels/channelUtils"; import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_LABEL } from "./channelFields"; /** Prefill bundle computed by the module's openConfig from the backend record * (masked token is echoed only as a hint, never as a raw value). */ export interface ChannelConfigInitial { accountId: string; tokenHint: string; baseURL: string; platVals: Record; allowGroups: string; denyGroups: string; allowUsers: string; denyUsers: string; groupMsgPerMin: string; msgPerMin: string; maxMessages: string; /** The account's bound role, `''` = 未绑定(跟随默认角色)(design/22 §4.4). */ personaId: string; } interface ChannelConfigFormProps { platform: string; initial: ChannelConfigInitial; busy: string | null; setBusy: (v: string | null) => void; onSaved: (msg: string) => void; onError: (msg: string) => void; onClose: () => void; } /** The channel account config form. Owns its own field state so the module stays * under the 500-line bound; remounts per platform via key={platform}. */ export function ChannelConfigForm({ platform, initial, busy, setBusy, onSaved, onError, onClose, }: ChannelConfigFormProps): React.ReactNode { const log = useAuditStore((s) => s.log); const [cfgId, setCfgId] = useState(initial.accountId); const [token, setToken] = useState(""); const [baseURL, setBaseURL] = useState(initial.baseURL); const [platVals, setPlatVals] = useState>(initial.platVals); const [allowGroups, setAllowGroups] = useState(initial.allowGroups); const [denyGroups, setDenyGroups] = useState(initial.denyGroups); const [allowUsers, setAllowUsers] = useState(initial.allowUsers); const [denyUsers, setDenyUsers] = useState(initial.denyUsers); const [groupMsgPerMin, setGroupMsgPerMin] = useState(initial.groupMsgPerMin); const [msgPerMin, setMsgPerMin] = useState(initial.msgPerMin); const [maxMessages, setMaxMessages] = useState(initial.maxMessages); const [extraConfig, setExtraConfig] = useState(""); const [advOpen, setAdvOpen] = useState(false); const [polOpen, setPolOpen] = useState(false); const [tokenHint, setTokenHint] = useState(initial.tokenHint); // 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. const [personaId, setPersonaId] = useState(initial.personaId); const [personaDirty, setPersonaDirty] = useState(false); const personas = useAsyncData(adminListPersonas, []); const save = () => { if (!platform) return; 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; } let configMap: Record | undefined; if (extraConfig.trim()) { let extra: unknown; try { extra = JSON.parse(extraConfig); } catch { onError("额外配置 JSON 格式有误"); setBusy(null); return; } if (typeof extra !== "object" || extra === null || Array.isArray(extra)) { onError("额外配置 JSON 需为对象(如 {\"receive_mode\":\"ws\"})"); setBusy(null); return; } configMap = { ...specificMap, ...(extra as Record) }; } else if (Object.keys(specificMap).length) { configMap = specificMap; } // 限流三键:留空的**不进请求体**(后端 int 契约下省略 = 0 = 未设置),非法输入整单 // 拒绝 —— 已设限额只可能被「没回显出来」或「NaN → null」清掉,两条路都在这里封死。 const limits = limitRequestBody({ groupMsgPerMin, msgPerMin, maxMessages }); if (!limits.ok) { onError(limits.error); setBusy(null); return; } adminSaveChannelConfig( platform, withChannelPersona( { id: cfgId.trim() || undefined, token: token.trim() || undefined, baseURL: baseURL.trim() || undefined, config: configMap, allowGroups: toList(allowGroups), denyGroups: toList(denyGroups), allowUsers: toList(allowUsers), denyUsers: toList(denyUsers), ...limits.body, }, { id: personaId, dirty: personaDirty }, ), ) .then((r) => { log("channel.save", "ok"); onSaved(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`); setCfgId(""); setToken(""); setBaseURL(""); setPlatVals({}); setAllowGroups(""); setDenyGroups(""); setAllowUsers(""); setDenyUsers(""); setGroupMsgPerMin(""); setMsgPerMin(""); setMaxMessages(""); setExtraConfig(""); setPersonaId(""); setPersonaDirty(false); }) .catch((e: unknown) => { log("channel.save", "error"); onError(e instanceof Error ? e.message : "保存渠道配置失败"); }) .finally(() => setBusy(null)); }; return (

配置账号 → {PLATFORM_LABEL[platform] ?? platform}

保存后写入 channels.json(本地、已 gitignore)并热更新 {platform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。

{/* 核心必填:最小必备字段 + 醒目 Callout / 高亮 Tag,不平铺全量 */}
核心必填
{PLATFORM_GUIDE[platform].guide}
{PLATFORM_GUIDE[platform].hint ? (

{PLATFORM_GUIDE[platform].hint}

) : null}
{ setPersonaId(id); setPersonaDirty(true); }} personas={personas.data ?? []} loadError={personas.error} /> {/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
{advOpen ? (
{(PLATFORM_FIELDS[platform] ?? []).map((f) => ( setPlatVals((prev) => ({ ...prev, [f.key]: v }))} placeholder={f.placeholder} secret={f.secret} /> ))}