Files
Cyrene_v2/frontend/web/src/admin/modules/channelConfigForm.tsx
T
AskaEth c1a98d7c01 perf(admin-web): 长列表虚拟滚动 + 专属页只读配置卡(裁决 16.4.3/16.4.6)+ 修限流读写成对的数据丢失
- 16.4.3(效率最高):AdminTable 超阈值走窗口化(DOM 行数与数据量解耦,上下占位行撑滚动条);
  新增可复用 VirtualList;适配器消息日志换成窗口化版
- 16.4.6(操作更便利):保留列表页内联编辑,同时把「专属页只读展示该平台完整字段」真正实现
  (AdapterConfigSummary,此前专属页一个配置字段都不显示)
- 🔴 顺带修一条静默数据丢失:列表页限流三项原从 acct.config.* 读,而后端把它们存**账号顶层**
  ⇒ 表单永远回显空、保存时 undefined→0 会把已设限流清空。现改从顶层读、留空则不发该键、非法输入报错中止;
  并顺手改正用户可见的错误文案(0 = 保护性默认 6条/分,负值才是关闭)
- 复核:tsc 0 错 + vite build 成功;channel limits 27 / admin virtual scroll 8023 项断言;3 条变异反证
2026-10-04 13:41:08 +08:00

355 lines
16 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 { adminListPersonas, adminSaveChannelConfig } from "../../lib/api";
import type { PersonaRecord } from "../../lib/types";
import { useAsyncData } from "../../lib/useAsyncData";
import { useAuditStore } from "../audit";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { withChannelPersona } from "./channels/channelPersona";
import { limitRequestBody } from "./channels/channelUtils";
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;
/** The account's bound role, `''` = 未绑定(跟随默认角色)(design/22 §4.4). */
personaId: 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);
// Channel-level role binding (design/22 §4.4). `dirty` tells an explicit clear
// (owner picked「跟随默认角色」) from a value the form simply never had, so an
// untouched unbound account does not write an empty personaId over a stored one.
const [personaId, setPersonaId] = useState(initial.personaId);
const [personaDirty, setPersonaDirty] = useState(false);
const personas = useAsyncData(adminListPersonas, []);
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;
}
// 限流三键:留空的**不进请求体**(后端 int 契约下省略 = 0 = 未设置),非法输入整单
// 拒绝 —— 已设限额只可能被「没回显出来」或「NaN → null」清掉,两条路都在这里封死。
const limits = limitRequestBody({ groupMsgPerMin, msgPerMin, maxMessages });
if (!limits.ok) {
onError(limits.error);
setBusy(null);
return;
}
adminSaveChannelConfig(
platform,
withChannelPersona(
{
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),
...limits.body,
},
{ id: personaId, dirty: personaDirty },
),
)
.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("");
setPersonaId("");
setPersonaDirty(false);
})
.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>
<ChannelPersonaField
value={personaId}
onChange={(id) => {
setPersonaId(id);
setPersonaDirty(true);
}}
personas={personas.data ?? []}
loadError={personas.error}
/>
{/* 高级选项(连接配置):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="space-y-2 px-3 pb-3 pt-1">
{/* 数值语义按 internal/services/channelbridge/flowgate.go 的 flowOrDefault:
0 = 保护性默认(群 6 条/分、待处理上限 50),负值 = 关闭限制;msgPerMin 只认正值。 */}
<p className="text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
留空 = 未设置(不动盘上的值);<code>groupMsgPerMin</code>/<code>maxMessages</code> 填
<strong className="font-medium"> 0 = 该渠道的保护性默认</strong>(群 6 条/分、待处理 50 条),
<strong className="font-medium">负值 = 关闭该限制</strong>;<code>msgPerMin</code> 只认正值(留空/0 = 不限)。
</p>
<div className="grid grid-cols-1 gap-2 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(每群每分钟上限)" 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>
</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)]";
/** Channel-level role binding (design/22 §4.4, 批次 2). The copy is deliberately
* explicit that this cannot reach an isolated conversation: 群聊/陌生人 always
* run as the default role, and a session's own binding outranks the account's. */
function ChannelPersonaField({
value,
onChange,
personas,
loadError,
}: {
value: string;
onChange: (personaId: string) => void;
personas: PersonaRecord[];
loadError: string | null;
}): React.ReactNode {
const bound = value.trim();
const known = personas.find((p) => p.id === bound);
const unknown = bound !== "" && known === undefined;
return (
<div className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">角色(渠道级绑定)</span>
{bound === "" ? (
<Tag tone="neutral">未绑定 · 跟随默认角色</Tag>
) : (
<Tag tone="accent">已绑定 · {known?.name ?? bound}</Tag>
)}
</div>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">
该渠道账号在非隔离会话(与主人的私聊等)中使用的角色
</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label="渠道账号绑定的角色"
className={inputCls}
>
<option value="">跟随默认角色(未绑定)</option>
{unknown ? <option value={bound}>{bound}(不在角色列表)</option> : null}
{personas.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.isDefault ? " · 默认" : ""}
</option>
))}
</select>
</label>
<p className="text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
角色解析顺序:会话绑定 &gt; 渠道绑定 &gt; 全局默认。
渠道绑定<strong className="font-medium">只影响该账号与主人的私聊等非隔离会话</strong>;
群聊 / 陌生人会话始终使用默认角色,此处的绑定覆盖不了它们。
</p>
{loadError ? (
<p className="text-[11px] text-[var(--mu-err)]">
角色列表读取失败({loadError}):不改动该下拉时保存会原样保留当前绑定;请确认后端在线后重试再改。
</p>
) : null}
</div>
);
}
/** 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>
);
}