feat: 渠道管理分两块(.env必要回退+删除同步+可用类型/已保存分开)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

agent-120 .env只留必要回退:
- .env.example KOOK移4可选业务字段(RECEIVE_MODE/WEBHOOK_PATH/VERIFY_TOKEN/ENCRYPT_KEY),只留token/base_url; channelAccountsFromEnv不读这4个; 说明业务字段走channelConfigForm.channels.json
- 黑名单/限流/receive_mode等走管理面板热更新, env仅必要连接回退

agent-121 删除渠道列表同步(根因: adminListChannels枚举caps,删除不同步caps):
- adminListChannels 以channels.json为准: 遍历caps时已删账号adapter跳过(s.channels注入时), 删除/保存后列表=channels.json, 删除即消失
- (capReg Remove后续可加,范围外标注)

agent-122 渠道管理分两块显示(主人:不割裂):
- ①可用适配器类型(顶部PLATFORM_TYPES全集grid卡片,已配置/未配置Tag,点新建配置)
- ②已保存配置(下方AdminTable过滤accounts>0,账号+bot身份+掩码token提示+连接/断开/删除)
- 拆 channelConfigForm.tsx(248行) 表单抽出; Channels.tsx 463→306行; token只回填hint不写值(保存留空不改secret)
全仓 build/vet/test绿+前端build绿(356KB)
This commit is contained in:
Aska committed 2026-09-07 16:16:50 +08:00
1 parent b614633432
commit 0ed7984de3
8 files changed
+578 -290

No files matched your search

+101 -258
View File
@@ -5,22 +5,27 @@ import {
adminChannelOpen,
adminChannelClose,
adminChannelTest,
adminSaveChannelConfig,
adminDeleteChannelAccount,
} from "../../lib/api";
import type { AdminChannelRecord, AdminChannelAccount, ChannelTestResult } from "../../lib/types";
import type { AdminChannelRecord, ChannelTestResult } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { PLATFORM_FIELDS, PLATFORM_GUIDE } from "./channelFields";
import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_TYPES, PLATFORM_LABEL } from "./channelFields";
import { ChannelConfigForm, type ChannelConfigInitial } from "./channelConfigForm";
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
function platformOf(name: string): string {
return name.split(":")[0];
}
/** Open-config target: the platform + the prefill bundle for its form. */
interface CfgTarget {
platform: string;
initial: ChannelConfigInitial;
}
export function ChannelsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
useAutoReload(reload, 10000);
@@ -28,37 +33,15 @@ export function ChannelsModule(): React.ReactNode {
const [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [cfg, setCfg] = useState<CfgTarget | null>(null);
const [ownerId, setOwnerId] = useState("");
const [platform, setPlatform] = useState("");
const [group, setGroup] = useState(false);
const [verdict, setVerdict] = useState<ChannelTestResult | null>(null);
// Config form state (per selected platform).
const [cfgPlatform, setCfgPlatform] = useState("");
const [cfgId, setCfgId] = useState("");
const [token, setToken] = useState("");
const [baseURL, setBaseURL] = useState("");
// Platform-specific config values keyed by the adapter Config json tag
// (receive_mode/webhook_path/mode/listen_addr/webhook_url/... ). Rendered only
// for the currently selected platform's fields.
const [platVals, setPlatVals] = useState<Record<string, string>>({});
const [allowGroups, setAllowGroups] = useState("");
const [denyGroups, setDenyGroups] = useState("");
const [allowUsers, setAllowUsers] = useState("");
const [denyUsers, setDenyUsers] = useState("");
const [groupMsgPerMin, setGroupMsgPerMin] = useState("");
const [msgPerMin, setMsgPerMin] = useState("");
const [maxMessages, setMaxMessages] = useState("");
const [extraConfig, setExtraConfig] = useState("");
// Advanced-options fold; default closed (values persist in state regardless).
const [advOpen, setAdvOpen] = useState(false);
// Business-policy fold (blacklist/allow + rate limits) is separate from the
// connection config — it governs admission/de-noise, not the endpoint.
const [polOpen, setPolOpen] = useState(false);
// Hint shown next to the Token field when an account already has a secret
// (masked): the token input stays empty so a save does NOT overwrite it.
const [tokenHint, setTokenHint] = useState("");
const configured = (data ?? []).filter((c) => (c.accounts?.length ?? 0) > 0);
const configuredSet = new Set(configured.map((c) => platformOf(c.name)));
const toggle = (name: string, connect: boolean) => {
setBusy(name);
@@ -98,116 +81,44 @@ export function ChannelsModule(): React.ReactNode {
const openConfig = (name: string) => {
setErr(null);
setNote(null);
const platform = platformOf(name);
setCfgPlatform(platform);
// Prefill from the backend-provided account (if already configured) so a
// saved config is visible again instead of the form opening blank. token is
// masked by the backend (never raw), so we only echo it as a hint.
const acct = (data ?? []).find((c) => platformOf(c.name) === platform)?.accounts?.[0];
setCfgId(acct?.id ?? "");
const cfg = acct?.config ?? {};
const plat = platformOf(name);
const acct = (data ?? []).find((c) => platformOf(c.name) === plat)?.accounts?.[0];
const acctCfg = acct?.config ?? {};
// 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.
const hasToken = typeof cfg.bot_token === "string" && cfg.bot_token && !cfg.bot_token.includes("(已配置");
setToken("");
setTokenHint(hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : "");
setBaseURL(typeof cfg.base_url === "string" ? cfg.base_url : "");
const plat: Record<string, string> = {};
for (const f of PLATFORM_FIELDS[platform] ?? []) {
const v = cfg[f.key];
plat[f.key] = v == null ? "" : String(v);
const hasToken = typeof acctCfg.bot_token === "string" && acctCfg.bot_token && !acctCfg.bot_token.includes("(已配置");
const platVals: Record<string, string> = {};
for (const f of PLATFORM_FIELDS[plat] ?? []) {
const v = acctCfg[f.key];
platVals[f.key] = v == null ? "" : String(v);
}
setPlatVals(plat);
setAllowGroups((acct?.allowGroups ?? []).join(","));
setDenyGroups((acct?.denyGroups ?? []).join(","));
setAllowUsers((acct?.allowUsers ?? []).join(","));
setDenyUsers(acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "");
setGroupMsgPerMin(cfg.groupMsgPerMin == null ? "" : String(cfg.groupMsgPerMin));
setMsgPerMin(cfg.msgPerMin == null ? "" : String(cfg.msgPerMin));
setMaxMessages(cfg.maxMessages == null ? "" : String(cfg.maxMessages));
setExtraConfig("");
setAdvOpen(false); // fold stays closed on open; advanced values are prefilled.
setPolOpen(false); // business-policy fold also starts closed.
setCfg({
platform: plat,
initial: {
accountId: acct?.id ?? "",
tokenHint: hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : "",
baseURL: typeof acctCfg.base_url === "string" ? acctCfg.base_url : "",
platVals,
allowGroups: (acct?.allowGroups ?? []).join(","),
denyGroups: (acct?.denyGroups ?? []).join(","),
allowUsers: (acct?.allowUsers ?? []).join(","),
denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "",
groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin),
msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin),
maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages),
},
});
};
const save = () => {
if (!cfgPlatform) return;
setBusy("__save__");
setErr(null);
setNote(null);
const toList = (s: string) => s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
// Curate platform-specific config keys, then let an explicit extraConfig JSON
// override any of them; these json tags are what the backend DecodeConfig reads.
const specificMap: Record<string, unknown> = {};
for (const f of PLATFORM_FIELDS[cfgPlatform] ?? []) {
const v = (platVals[f.key] ?? "").trim();
if (v) specificMap[f.key] = v;
}
let configMap: Record<string, unknown> | undefined;
if (extraConfig.trim()) {
let extra: unknown;
try {
extra = JSON.parse(extraConfig);
} catch {
setErr("额外配置 JSON 格式有误");
setBusy(null);
return;
}
if (typeof extra !== "object" || extra === null || Array.isArray(extra)) {
setErr("额外配置 JSON 需为对象(如 {\"receive_mode\":\"ws\"})");
setBusy(null);
return;
}
configMap = { ...specificMap, ...(extra as Record<string, unknown>) };
} else if (Object.keys(specificMap).length) {
configMap = specificMap;
}
adminSaveChannelConfig(cfgPlatform, {
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),
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
maxMessages: maxMessages ? Number(maxMessages) : undefined,
})
.then((r) => {
log("channel.save", "ok");
setNote(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
setCfgId("");
setToken("");
setBaseURL("");
setPlatVals({});
setAllowGroups("");
setDenyGroups("");
setAllowUsers("");
setDenyUsers("");
setGroupMsgPerMin("");
setMsgPerMin("");
setMaxMessages("");
setExtraConfig("");
reload();
})
.catch((e: unknown) => {
log("channel.save", "error");
setErr(e instanceof Error ? e.message : "保存渠道配置失败");
})
.finally(() => setBusy(null));
};
const del = (platform: string, id: string) => {
const del = (plat: string, id: string) => {
setBusy("__del__" + id);
setErr(null);
setNote(null);
adminDeleteChannelAccount(platform, id)
adminDeleteChannelAccount(plat, id)
.then(() => {
log("channel.delete", "ok");
setNote(`已删除账号 ${id} 并热更新 ${platform}`);
setNote(`已删除账号 ${id} 并热更新 ${plat}`);
reload();
})
.catch((e: unknown) => {
@@ -218,7 +129,7 @@ export function ChannelsModule(): React.ReactNode {
};
const columns: AdminColumn<AdminChannelRecord>[] = [
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{PLATFORM_LABEL[platformOf(c.name)] ?? c.name}</span> },
{
key: "connected",
title: "状态",
@@ -239,9 +150,13 @@ export function ChannelsModule(): React.ReactNode {
// Show the connected bot's resolved identity (kook user/me) when the
// adapter fetched it, falling back to the account id.
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
const tokenMasked = typeof a.config?.bot_token === "string" && a.config.bot_token;
return (
<span key={a.id || "default"} className="flex items-center gap-1">
<Tag tone="neutral">{label}</Tag>
<Tag tone={tokenMasked ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
{tokenMasked ? "token 已配置" : "token 未填"}
</Tag>
{a.id ? (
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
删除
@@ -289,109 +204,66 @@ export function ChannelsModule(): React.ReactNode {
{err ? <Notice tone="err">{err}</Notice> : null}
{note ? <Notice tone="ok">{note}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无渠道数据"
rowKey={(c) => c.name}
/>
</Surface>
{cfgPlatform ? (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">配置账号 → {cfgPlatform}</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
</p>
{/* 核心必填:最小必备字段 + 醒目 Callout / 高亮 Tag,不平铺全量 */}
<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)]" />
核心必填
</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[cfgPlatform].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[cfgPlatform].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>
</div>
{PLATFORM_GUIDE[cfgPlatform].hint ? (
<p className="text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[cfgPlatform].hint}</p>
) : null}
</div>
{/* 高级选项(连接配置):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"
onClick={() => setAdvOpen((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)] ${advOpen ? "rotate-90" : ""}`}>
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
</span>
高级选项(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[cfgPlatform] ?? []).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} />
))}
{/* ① 可用适配器类型(新建):全部已知平台,未配置也可创建 */}
<section className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold">可用适配器类型(新建)</h3>
<span className="text-xs text-[var(--mu-text-lo)]">选择平台类型创建配置,或管理下方已保存配置</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{PLATFORM_TYPES.map((p) => {
const has = configuredSet.has(p);
return (
<Surface key={p} gap="sm" className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Tag tone="accent">{PLATFORM_LABEL[p] ?? p}</Tag>
<Tag tone={has ? "ok" : "neutral"}>{has ? "已配置" : "未配置"}</Tag>
</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)]" />
</label>
</div>
) : null}
</div>
<p className="min-h-10 text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[p].guide}</p>
<div className="mt-auto">
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => openConfig(p)}>
{has ? "编辑配置" : "新建配置"}
</Button>
</div>
</Surface>
);
})}
</div>
</section>
{/* 业务策略(独立于连接配置):黑白名单 / 限流——管准入与降噪,不参与端到端连接。 */}
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
<button
type="button"
onClick={() => setPolOpen((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)] ${polOpen ? "rotate-90" : ""}`}>
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
</span>
业务策略(黑白名单 / 群聊限流——不参与连接配置)
</button>
{polOpen ? (
<div className="grid grid-cols-1 gap-2 px-3 pb-3 pt-1 sm:grid-cols-2">
<Field label="allowGroups(逗号分隔白名单)" value={allowGroups} onChange={setAllowGroups} />
<Field label="denyGroups(黑名单)" value={denyGroups} onChange={setDenyGroups} />
<Field label="allowUsers(白名单)" value={allowUsers} onChange={setAllowUsers} />
<Field label="denyUsers(黑名单)" value={denyUsers} onChange={setDenyUsers} />
<Field label="groupMsgPerMin(群聊限流,≤0 关闭)" value={groupMsgPerMin} onChange={setGroupMsgPerMin} inputMode="numeric" />
<Field label="msgPerMin" value={msgPerMin} onChange={setMsgPerMin} inputMode="numeric" />
<Field label="maxMessages" value={maxMessages} onChange={setMaxMessages} inputMode="numeric" />
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy !== null} onClick={save}>保存配置</Button>
<Button variant="ghost" disabled={busy !== null} onClick={() => setCfgPlatform("")}>取消</Button>
</div>
{/* ② 已保存配置:仅显示已配置账号的平台 */}
<section className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold">已保存配置</h3>
<span className="text-xs text-[var(--mu-text-lo)]">仅显示已配置账号的平台(含连接/断开/删除)</span>
</div>
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : configured}
loading={loading}
error={error}
emptyTitle="尚未保存任何渠道配置——请在上方选择平台类型创建"
rowKey={(c) => c.name}
/>
</Surface>
</section>
{cfg ? (
<ChannelConfigForm
key={cfg.platform}
platform={cfg.platform}
initial={cfg.initial}
busy={busy}
setBusy={setBusy}
onSaved={(msg) => {
setNote(msg);
setErr(null);
reload();
}}
onError={(msg) => setErr(msg)}
onClose={() => setCfg(null)}
/>
) : null}
<Surface className="space-y-3">
@@ -432,32 +304,3 @@ export function ChannelsModule(): React.ReactNode {
</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)]";
/** Compact labeled input for the config form; `secret` masks the field. */
interface FieldProps {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
secret?: boolean;
inputMode?: "text" | "numeric";
}
function Field({ label, value, onChange, placeholder, secret, inputMode = "text" }: FieldProps): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
type={secret ? "password" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
inputMode={inputMode}
className={inputCls}
/>
</label>
);
}
@@ -0,0 +1,248 @@
import { useState } from "react";
import { adminSaveChannelConfig } from "../../lib/api";
import { useAuditStore } from "../audit";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
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<string, string>;
allowGroups: string;
denyGroups: string;
allowUsers: string;
denyUsers: string;
groupMsgPerMin: string;
msgPerMin: string;
maxMessages: 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<Record<string, string>>(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);
const save = () => {
if (!platform) return;
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;
}
let configMap: Record<string, unknown> | 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<string, unknown>) };
} else if (Object.keys(specificMap).length) {
configMap = specificMap;
}
adminSaveChannelConfig(platform, {
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),
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
maxMessages: maxMessages ? Number(maxMessages) : undefined,
})
.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("");
})
.catch((e: unknown) => {
log("channel.save", "error");
onError(e instanceof Error ? e.message : "保存渠道配置失败");
})
.finally(() => setBusy(null));
};
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">配置账号 → {PLATFORM_LABEL[platform] ?? platform}</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
保存后写入 channels.json(本地、已 gitignore)并热更新 {platform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
</p>
{/* 核心必填:最小必备字段 + 醒目 Callout / 高亮 Tag,不平铺全量 */}
<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)]" />
核心必填
</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>
</div>
{PLATFORM_GUIDE[platform].hint ? (
<p className="text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[platform].hint}</p>
) : null}
</div>
{/* 高级选项(连接配置):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"
onClick={() => setAdvOpen((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)] ${advOpen ? "rotate-90" : ""}`}>
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
</span>
高级选项(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)]" />
</label>
</div>
) : null}
</div>
{/* 业务策略(独立于连接配置):黑白名单 / 限流——管准入与降噪,不参与端到端连接。 */}
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
<button
type="button"
onClick={() => setPolOpen((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)] ${polOpen ? "rotate-90" : ""}`}>
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
</span>
业务策略(黑白名单 / 群聊限流——不参与连接配置)
</button>
{polOpen ? (
<div className="grid grid-cols-1 gap-2 px-3 pb-3 pt-1 sm:grid-cols-2">
<Field label="allowGroups(逗号分隔白名单)" value={allowGroups} onChange={setAllowGroups} />
<Field label="denyGroups(黑名单)" value={denyGroups} onChange={setDenyGroups} />
<Field label="allowUsers(白名单)" value={allowUsers} onChange={setAllowUsers} />
<Field label="denyUsers(黑名单)" value={denyUsers} onChange={setDenyUsers} />
<Field label="groupMsgPerMin(群聊限流,≤0 关闭)" value={groupMsgPerMin} onChange={setGroupMsgPerMin} inputMode="numeric" />
<Field label="msgPerMin" value={msgPerMin} onChange={setMsgPerMin} inputMode="numeric" />
<Field label="maxMessages" value={maxMessages} onChange={setMaxMessages} inputMode="numeric" />
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy !== null} onClick={save}>保存配置</Button>
<Button variant="ghost" disabled={busy !== null} onClick={onClose}>取消</Button>
</div>
</Surface>
);
}
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)]";
/** Compact labeled input for the config form; `secret` masks the field. */
interface FieldProps {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
secret?: boolean;
inputMode?: "text" | "numeric";
}
function Field({ label, value, onChange, placeholder, secret, inputMode = "text" }: FieldProps): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
type={secret ? "password" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
inputMode={inputMode}
className={inputCls}
/>
</label>
);
}
@@ -68,3 +68,16 @@ export const PLATFORM_GUIDE: Record<string, PlatformGuide> = {
tokenRequired: false,
},
};
/** Human-friendly platform display name (the map key is the adapter prefix). */
export const PLATFORM_LABEL: Record<string, string> = {
kook: "KOOK",
"qq-obv11": "QQ(OneBot v11)",
telegram: "Telegram",
"weixin-ilink": "微信(iLink)",
webhook: "Webhook",
};
/** The full set of known adapter platform types (whether or not configured yet).
* Drives the "可用适配器类型(新建)" grid — every known type is creatable. */
export const PLATFORM_TYPES: string[] = Object.keys(PLATFORM_GUIDE);