- 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 条变异反证
355 lines
16 KiB
TypeScript
355 lines
16 KiB
TypeScript
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)]">
|
||
角色解析顺序:会话绑定 > 渠道绑定 > 全局默认。
|
||
渠道绑定<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>
|
||
);
|
||
}
|