Files
Cyrene_v2/frontend/web/src/admin/modules/channelConfigForm.tsx
T
Aska 0ed7984de3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 渠道管理分两块(.env必要回退+删除同步+可用类型/已保存分开)
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)
2026-09-07 16:16:50 +08:00

249 lines
11 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 { 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>
);
}