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 { label: string; value: string; } /** 通用连接键:该平台**真正读取**的凭据键(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/DirectMsgPerMin`,见 * `internal/services/gateway/admin_write_channel_config.go`),且 `omitempty` * ⇒ 缺省 / 0 就是「不限」。读错来源会让面板把已设的限额显示成未设置。 */ function fmtLimit(value?: number): string { return value === undefined || value === null || value === 0 ? "未设置(不限)" : String(value); } /** `directMsgPerMin` 的展示:它的 0 **不是**「不限」而是「保护性默认 6 条/分」 * (缺省绝不等于关掉陌生人私聊流控),负值才是主人显式关闭 —— 与其它三项的文案不同, * 所以单独一个格式化器,免得把「未设置」误读成「敞口」。 */ function fmtDirectLimit(value?: number): string { if (value === undefined || value === null || value === 0) return "未设置(保护性默认 6 条/分)"; return value < 0 ? `${value}(已关闭流控)` : String(value); } /** 只读展示用的值格式化:空值明确写成「未设置」,对象/数组走 JSON 串,其余原样。 * 绝不在这里造值——后端给什么显示什么(后端已对 token 类键做掩码)。 */ function fmtValue(value: unknown): string { if (value === null || value === undefined) return "未设置"; if (typeof value === "string") return value.trim() === "" ? "未设置" : value; if (typeof value === "number" || typeof value === "boolean") return String(value); try { return JSON.stringify(value); } catch { return "(无法序列化)"; } } /** 列表类字段:空列表写成「未设置」而不是一个空白的逗号串。 */ function fmtList(values?: readonly string[]): string { return values && values.length > 0 ? values.join("、") : "未设置"; } /** 该平台连接字段的键:凭据键 + base_url + 平台专属键(channelFields 是字段元数据的单一 * 真源,此处不另造一份字段表)。凭据键同时也是平台专属键时只算一次,避免同一行出现两遍。 */ function connectionKeys(platform: string): string[] { 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" }]; 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; } /** 业务字段:角色绑定 + 限流(账号顶层字段)+ 白/黑名单。 */ function businessFields(account: AdminChannelAccount): Field[] { return [ { label: "personaId(绑定角色)", value: account.personaId?.trim() ? account.personaId : "跟随默认角色" }, // msgPerMin 是**账号级非主人消息总量上限**,不是私聊上限 —— 旧文案写成「私聊每分钟 // 上限」会让主人以为它管的是陌生人私聊(真正管私聊的是下面那行 directMsgPerMin)。 { label: "msgPerMin(非主人消息每分钟总上限)", value: fmtLimit(account.msgPerMin) }, { label: "groupMsgPerMin(群每分钟上限)", value: fmtLimit(account.groupMsgPerMin) }, { label: "directMsgPerMin(陌生人对私聊每分钟上限)", value: fmtDirectLimit(account.directMsgPerMin) }, { label: "maxMessages(会话消息窗口)", value: fmtLimit(account.maxMessages) }, { label: "allowGroups(群白名单)", value: fmtList(account.allowGroups) }, { label: "denyGroups(群黑名单)", value: fmtList(account.denyGroups) }, { label: "allowUsers(用户白名单)", value: fmtList(account.allowUsers) }, { label: "denyUsers(用户黑名单)", value: fmtList(account.denyUsers) }, ]; } /** 平台回填的身份(连接后由适配器从平台取回)。 */ function identityFields(account: AdminChannelAccount): Field[] { return [ { label: "bot_id", value: fmtValue(account.botId) }, { label: "bot_username", value: fmtValue(account.botUsername) }, { label: "bot_nickname", value: fmtValue(account.botNickname) }, ]; } /** config 里其余的字段(如 kook 的 receive_mode / webhook_path,或「高级选项 → * 额外 config」写下的一切),原样列出,一个都不吞。 */ function extraFields(platform: string, account: AdminChannelAccount): Field[] { const skip = new Set(connectionKeys(platform)); const cfg = account.config ?? {}; return Object.keys(cfg) .filter((key) => !skip.has(key)) .sort() .map((key) => ({ label: key, value: fmtValue(cfg[key]) })); } function FieldGroup({ title, fields }: { title: string; fields: Field[] }): React.ReactNode { return (
{title}
{fields.length === 0 ? (
无
) : (
{fields.map((f) => (
{f.label}
{f.value}
))}
)}
); } /** 该配置的完整字段(只读展示,design/17 §2)。裁决 16.4.6 取「操作更便利」的一侧: * 配置字段在列表页内联展开编辑(少跳一层),所以专属页不再复制一份表单,只把后端 * 给出的真实字段摊开,并给一个回到列表页编辑的入口。token 类键由后端掩码后下发, * 前端只转述、不还原。 */ export function AdapterConfigSummary({ platform, account, unknown, onEdit, }: { platform: string; account?: AdminChannelAccount; /** 渠道列表读取失败:账号字段无从确认,不能显示成「没配置」。 */ unknown: boolean; /** 列表页的内联表单是唯一编辑入口,这里只提供跳转。 */ onEdit: () => void; }): React.ReactNode { const label = PLATFORM_LABEL[platform] ?? platform; return (

配置字段(只读)

{label} 编辑在列表页内联展开
{unknown ? ( 读取失败(与后端失联),无法确认该配置的字段;请先确认后端在线后重试。 ) : !account ? ( channels.json 中没有该账号(可能已被删除或改名);请回到「活动适配器列表」确认后再进入本页。 ) : ( <> 字段取自后端的实时渠道配置(GET /api/v1/admin/channels);token 类键只下发掩码值,面板不持有原文。 )}
); }