feat(web): 渠道配置表单排版+引导——核心必填(token)+高级折叠+业务策略独立区
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

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掩码/不回显
This commit is contained in:
Aska committed 2026-09-07 15:45:04 +08:00
1 parent 65091f6497
commit 5398486fdf
2 files changed
+186 -126

No files matched your search

+116 -126
View File
@@ -13,57 +13,14 @@ 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];
}
/** One curated config input for a platform. `key` is the config-map JSON key
* the backend decodes (see plugins/channels/* adapter Config json tags). */
interface PlatformField {
key: string;
label: string;
placeholder?: string;
/** Render as a masked [type=password] input (token / verify / encrypt / secret). */
secret?: boolean;
}
/** Platform-specific config fields shown for a platform. The COMMON fields
* (account id, token/bot_token, baseURL/base_url, allow/deny lists, flow limits)
* are rendered for every platform separately; this map holds only what a given
* adapter additionally needs. Keys are the adapter Config json tags verbatim. */
const PLATFORM_FIELDS: Record<string, PlatformField[]> = {
// KOOK is "token 即用、其余自动获取": only the token is required. base_url
// defaults to the official root, receive_mode defaults to "ws" and webhook_path
// to "/webhook/kook", so none of them need a form field. Advanced values
// (receive_mode="webhook", webhook_path, webhook_verify_token, webhook_encrypt_key)
// are set through the "额外 config" JSON instead. We keep the (empty) entry so
// the panel still renders the KOOK-specific hint below.
kook: [],
"qq-obv11": [
{ key: "mode", label: "mode(client/server)", placeholder: "server" },
{ key: "listen_addr", label: "listen_addr(server 监听)", placeholder: "0.0.0.0:6700" },
{ key: "remote_url", label: "remote_url(client 连接)", placeholder: "ws://…" },
{ key: "access_token", label: "access_token(OneBot 访问 token)", secret: true },
{ key: "self_id", label: "self_id(机器人 QQ 号)", placeholder: "123456789" },
],
telegram: [{ key: "webhook_url", label: "webhook_url(公网回调地址)", placeholder: "https://…" }],
"weixin-ilink": [
{ key: "cdn_base_url", label: "cdn_base_url(媒体 CDN)", placeholder: "https://…" },
{ key: "bot_id", label: "bot_id(ilink_bot_id)", placeholder: "wx…" },
{ key: "owner_id", label: "owner_id(绑定主人 uid)", placeholder: "wx…" },
{ key: "cursor", label: "cursor(get_updates_buf)", placeholder: "…" },
],
webhook: [
{ key: "endpointPath", label: "endpointPath(挂载路由)", placeholder: "/webhook" },
{ key: "channelId", label: "channelId(渠道 id)", placeholder: "…" },
{ key: "channelName", label: "channelName(渠道名)", placeholder: "…" },
{ key: "secret", label: "secret(HMAC 签名密钥)", secret: true },
{ key: "signatureHeader", label: "signatureHeader(签名头)", placeholder: "X-Webhook-Signature" },
],
};
export function ChannelsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
useAutoReload(reload, 10000);
@@ -94,6 +51,11 @@ export function ChannelsModule(): React.ReactNode {
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);
@@ -157,6 +119,8 @@ export function ChannelsModule(): React.ReactNode {
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 = () => {
@@ -165,10 +129,8 @@ export function ChannelsModule(): React.ReactNode {
setErr(null);
setNote(null);
const toList = (s: string) => s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
// Curate this platform's specific config keys (e.g. kook receive_mode /
// webhook_path, qq mode / listen_addr), then let an explicit extraConfig JSON
// override any of them. Keys are the adapter Config json tags the backend
// DecodeConfig reads, so the saved config lands in the right struct fields.
// 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();
@@ -333,90 +295,92 @@ export function ChannelsModule(): React.ReactNode {
{cfgPlatform ? (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">配置账号 → {cfgPlatform}</h3>
{cfgPlatform === "kook" ? (
<p className="text-xs text-[var(--mu-text-lo)]">
KOOK 为「token 即用、其余自动获取」:仅需填 token(必填);base_url 留空即用官方默认;receive_mode 默认 ws、webhook_path 默认 /webhook/kook,均无需填写。若改用 webhook 或配校验/加密,在下方「额外 config」JSON 里设置,如
{" "}
{"{"}"receive_mode":"webhook","webhook_path":"/webhook/kook","webhook_verify_token":"...","webhook_encrypt_key":"..."{"}"}
(verify_token / encrypt_key 仅在 webhook 模式需要)。连接后渠道列表会显示该 KOOK bot 身份。
</p>
) : null}
<p className="text-xs text-[var(--mu-text-lo)]">
保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID(留空自动生成)</span>
<input value={cfgId} onChange={(e) => setCfgId(e.target.value)} placeholder="conf0" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">Token(bot_token)</span>
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="机器人 token" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL(地址)</span>
<input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="https://…" className={inputCls} />
</label>
{(PLATFORM_FIELDS[cfgPlatform] ?? []).map((f) => (
<label key={f.key} className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{f.label}</span>
<input
type={f.secret ? "password" : "text"}
value={platVals[f.key] ?? ""}
onChange={(e) => setPlatVals((prev) => ({ ...prev, [f.key]: e.target.value }))}
placeholder={f.placeholder}
className={inputCls}
/>
{/* 核心必填:最小必备字段 + 醒目 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>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">allowGroups(逗号分隔白名单)</span>
<input value={allowGroups} onChange={(e) => setAllowGroups(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">denyGroups(黑名单)</span>
<input value={denyGroups} onChange={(e) => setDenyGroups(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">allowUsers(白名单)</span>
<input value={allowUsers} onChange={(e) => setAllowUsers(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">denyUsers(黑名单)</span>
<input value={denyUsers} onChange={(e) => setDenyUsers(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">groupMsgPerMin(群聊限流,≤0 关闭)</span>
<input value={groupMsgPerMin} onChange={(e) => setGroupMsgPerMin(e.target.value)} inputMode="numeric" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">msgPerMin</span>
<input value={msgPerMin} onChange={(e) => setMsgPerMin(e.target.value)} inputMode="numeric" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">maxMessages</span>
<input value={maxMessages} onChange={(e) => setMaxMessages(e.target.value)} inputMode="numeric" className={inputCls} />
</label>
{/* 高级选项(连接配置):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>
<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 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>
<Button variant="primary" disabled={busy !== null} onClick={save}>保存配置</Button>
<Button variant="ghost" disabled={busy !== null} onClick={() => setCfgPlatform("")}>取消</Button>
</div>
</Surface>
) : null}
@@ -462,3 +426,29 @@ export function ChannelsModule(): React.ReactNode {
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,70 @@
/** Channel config field metadata + per-platform guidance (design/16 §4.4-4.6).
* Separated from Channels.tsx so the module stays under the 500-line bound and a
* platform only needs a new entry here. */
/** One curated config input; `key` is the adapter Config json tag the backend decodes. */
export interface PlatformField {
key: string;
label: string;
placeholder?: string;
/** Masked [type=password] input (token / verify / encrypt / secret). */
secret?: boolean;
}
/** Platform-specific config fields only (the COMMON fields are rendered separately).
* Keys are the adapter Config json tags verbatim. */
export const PLATFORM_FIELDS: Record<string, PlatformField[]> = {
kook: [], // KOOK is "token 即用、其余自动获取"—advanced values go in the extra JSON.
"qq-obv11": [
{ key: "mode", label: "mode(client/server)", placeholder: "server" },
{ key: "listen_addr", label: "listen_addr(server 监听)", placeholder: "0.0.0.0:6700" },
{ key: "remote_url", label: "remote_url(client 连接)", placeholder: "ws://…" },
{ key: "access_token", label: "access_token(OneBot 访问 token)", secret: true },
{ key: "self_id", label: "self_id(机器人 QQ 号)", placeholder: "123456789" },
],
telegram: [{ key: "webhook_url", label: "webhook_url(公网回调地址)", placeholder: "https://…" }],
"weixin-ilink": [
{ key: "cdn_base_url", label: "cdn_base_url(媒体 CDN)", placeholder: "https://…" },
{ key: "bot_id", label: "bot_id(ilink_bot_id)", placeholder: "wx…" },
{ key: "owner_id", label: "owner_id(绑定主人 uid)", placeholder: "wx…" },
{ key: "cursor", label: "cursor(get_updates_buf)", placeholder: "…" },
],
webhook: [
{ key: "endpointPath", label: "endpointPath(挂载路由)", placeholder: "/webhook" },
{ key: "channelId", label: "channelId(渠道 id)", placeholder: "…" },
{ key: "channelName", label: "channelName(渠道名)", placeholder: "…" },
{ key: "secret", label: "secret(HMAC 签名密钥)", secret: true },
{ key: "signatureHeader", label: "signatureHeader(签名头)", placeholder: "X-Webhook-Signature" },
],
};
/** Core-required guidance per platform: headline `guide` + auto-fetch `hint`. */
export interface PlatformGuide {
guide: string;
hint?: string;
tokenRequired?: boolean;
}
export const PLATFORM_GUIDE: Record<string, PlatformGuide> = {
kook: {
guide: "只需填 bot_token(必填),其余自动获取",
hint: "base_url 留空即用官方地址;receive_mode 默认 ws、webhook_path 默认 /webhook/kook,均无需填写;连接后渠道列表自动显示该 bot 身份。如需 webhook(receive_mode=webhook)或 webhook_verify_token / webhook_encrypt_key,在「高级选项 → 额外 config」里以 JSON 设置。",
},
"qq-obv11": {
guide: "填 token(必填)并选择接入方式(mode:client / server)",
hint: "mode=server 填 listen_addr(本地监听端口);mode=client 填 remote_url(连接上游)。mode / listen_addr / remote_url / access_token 在「高级选项」里配置。",
},
telegram: {
guide: "只需填 bot_token(必填),其余自动获取",
hint: "webhook_url(公网回调,可选)在「高级选项」里配置;留空则走长轮询。",
},
"weixin-ilink": {
guide: "填 bot_token(必填)与 bot_id / owner_id",
hint: "bot_id / owner_id / cdn_base_url / cursor 在「高级选项」里配置;媒体 CDN 可留空自动。",
},
webhook: {
guide: "填 endpointPath 与 secret(必填)",
hint: "endpointPath / secret / channelId / signatureHeader 在「高级选项」里配置,用于接收平台回调。",
tokenRequired: false,
},
};