Files
Cyrene_v2/frontend/web/src/admin/modules/Channels.tsx
T
Aska 5398486fdf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): 渠道配置表单排版+引导——核心必填(token)+高级折叠+业务策略独立区
agent-118 排版引导:
- 核心必填区: 高亮 Callout(各平台'只需填X'头条)+Token必填badge(红底)+自动获取旁注, 不再藏段落
- 高级选项折叠(Miuix arrow折叠): base_url/平台特定字段/额外 config(连接配置), 收起默认
- 各平台引导头条: kook'只需填bot_token其余自动获取'/telegram/qq(mode接入方式)/weixin/webhook

我补(主人原则: 连接配置 vs 业务策略分开):
- 黑白名单(allow/deny groups/users)+限流(groupMsgPerMin/msgPerMin/maxMessages) 从'高级选项'抽离成独立'业务策略'折叠区(不参与端到端连接)
- openConfig 重置 polOpen; 业务策略默认收起
- 拆 channelFields.ts(PlatformField/PLATFORM_FIELDS/PLATFORM_GUIDE) → Channels.tsx 454行<500红线合规

前端 build 绿; 敏感字段password掩码/不回显
2026-09-07 15:45:04 +08:00

455 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import {
adminListChannels,
adminChannelOpen,
adminChannelClose,
adminChannelTest,
adminSaveChannelConfig,
adminDeleteChannelAccount,
} from "../../lib/api";
import type { AdminChannelRecord, AdminChannelAccount, 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";
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
function platformOf(name: string): string {
return name.split(":")[0];
}
export function ChannelsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
useAutoReload(reload, 10000);
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [note, setNote] = useState<string | 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);
const toggle = (name: string, connect: boolean) => {
setBusy(name);
setErr(null);
const call = connect ? adminChannelOpen(name) : adminChannelClose(name);
call
.then(() => {
log(connect ? "channel.open" : "channel.close", "ok");
reload();
})
.catch((e: unknown) => {
log(connect ? "channel.open" : "channel.close", "error");
setErr(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`);
})
.finally(() => setBusy(null));
};
const test = () => {
if (!ownerId.trim()) {
setErr("邮箱/账号 id 不能为空");
return;
}
setBusy("__test__");
setErr(null);
adminChannelTest({ platform: platform.trim(), accountId: ownerId.trim(), isGroup: group })
.then((v) => {
log("channel.identity-test", "ok");
setVerdict(v);
})
.catch((e: unknown) => {
log("channel.identity-test", "error");
setErr(e instanceof Error ? e.message : "身份测试失败");
})
.finally(() => setBusy(null));
};
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 ?? {};
setToken(typeof cfg.bot_token === "string" && cfg.bot_token ? "(已配置,仅掩码显示)" : "");
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);
}
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.
};
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) => {
setBusy("__del__" + id);
setErr(null);
setNote(null);
adminDeleteChannelAccount(platform, id)
.then(() => {
log("channel.delete", "ok");
setNote(`已删除账号 ${id} 并热更新 ${platform}`);
reload();
})
.catch((e: unknown) => {
log("channel.delete", "error");
setErr(e instanceof Error ? e.message : "删除渠道账号失败");
})
.finally(() => setBusy(null));
};
const columns: AdminColumn<AdminChannelRecord>[] = [
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
{
key: "connected",
title: "状态",
render: (c) => (
<Tag tone={c.connected ? "ok" : "err"}>
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${c.connected ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
{c.connected ? "已连接" : "未连接"}
</Tag>
),
},
{
key: "accounts",
title: "已配账号",
render: (c) =>
c.accounts && c.accounts.length ? (
<span className="flex flex-wrap items-center gap-1">
{c.accounts.map((a) => {
// 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");
return (
<span key={a.id || "default"} className="flex items-center gap-1">
<Tag tone="neutral">{label}</Tag>
{a.id ? (
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
删除
</Button>
) : null}
</span>
);
})}
</span>
) : (
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
),
},
{
key: "actions",
title: "操作",
render: (c) => (
<span className="flex items-center gap-2">
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => openConfig(c.name)}>
配置
</Button>
<Button size="sm" variant="ghost" disabled={busy !== null || c.connected} onClick={() => toggle(c.name, true)}>
连接
</Button>
<Button size="sm" variant="ghost" disabled={busy !== null || !c.connected} onClick={() => toggle(c.name, false)}>
断开
</Button>
</span>
),
},
];
return (
<div className="space-y-5">
<PageHeader
title="渠道管理"
subtitle="GET/PUT/DELETE /api/v1/admin/channels · 配置(落盘 channels.json + 热更新) + 连接/断开(真实生命周期) + 身份识别"
actions={
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
刷新
</Button>
}
/>
{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`} />
</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} />
))}
</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={() => setCfgPlatform("")}>取消</Button>
</div>
</Surface>
) : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
输入渠道确认的发送者账号 id,后端用 channel 层 actor 元数据 × 主人白名单(configs/users/owner.yaml)判定;判定绝不依赖提示词 / LLM 自我判断。
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={platform}
onChange={(e) => setPlatform(e.target.value)}
placeholder="platform(如 weixin-ilink,可留空)"
className="min-w-0 flex-1 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)]"
/>
<input
value={ownerId}
onChange={(e) => setOwnerId(e.target.value)}
placeholder="渠道账号 id(如 weixin-ilink 的 id)"
className="min-w-0 flex-1 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 className="flex items-center gap-1.5 text-xs text-[var(--mu-text-mid)]">
<input type="checkbox" checked={group} onChange={(e) => setGroup(e.target.checked)} className="accent-[var(--mu-accent)]" />
群聊
</label>
<Button variant="primary" disabled={busy !== null} onClick={test}>
测试
</Button>
</div>
{verdict ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">
<div>角色:{verdict.role} | 主人:{verdict.owner ? "是" : "否"} | 群聊:{verdict.isGroup ? "是" : "否"} | 环境:{verdict.env}</div>
<div>判定:{verdict.verdict}</div>
</div>
) : null}
</Surface>
<Notice>账号配置落盘 channels.json(本地、已 gitignore)并热更新运行中 adapter;token/密钥不回显、不打印,.env 为兜底。消息流预览、推送测试由后端 M10+ 提供。</Notice>
</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>
);
}