feat(admin): 人格页「绑定会话数」+ 渠道账号绑定角色(B 批次 2 前端)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 design/22 §4.4/§4.6 + §7-4/7-7(契约先行,后端由并行代理实现)。
- 人格页:展示每个角色绑定的会话数——面板侧用 GET /admin/sessions 的 personaId 聚合(personaId 为空 = 继承默认角色,已计入默认角色并在 UI 写明口径),随既有 useAutoReload 刷新;**未新增后端字段**
- 渠道级绑定 UI:每个渠道账号加「角色」下拉(选项取 GET /admin/personas),保存走 PUT /admin/channels/{platform}/config 的 personaId;已绑角色回填、未绑定显示「跟随默认角色」(不冒充某个角色);提示语写明**群聊/陌生人会话始终用默认角色**,避免暗示可覆盖隔离会话
- 验收:npm run build 绿 + 逻辑测试(personaSession/messageSync)通过
This commit is contained in:
AskaEth committed 2026-10-03 20:50:07 +08:00
1 parent 2aa3233f84
commit 3732c538cc
9 files changed
+553 -26

No files matched your search

@@ -1,8 +1,11 @@
import { useState } from "react";
import { adminSaveChannelConfig } from "../../lib/api";
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 { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_LABEL } from "./channelFields";
/** Prefill bundle computed by the module's openConfig from the backend record
@@ -19,6 +22,8 @@ export interface ChannelConfigInitial {
groupMsgPerMin: string;
msgPerMin: string;
maxMessages: string;
/** The account's bound role, `''` = 未绑定(跟随默认角色)(design/22 §4.4). */
personaId: string;
}
interface ChannelConfigFormProps {
@@ -58,6 +63,12 @@ export function ChannelConfigForm({
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;
@@ -87,19 +98,25 @@ export function ChannelConfigForm({
} 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,
})
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),
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
maxMessages: maxMessages ? Number(maxMessages) : undefined,
},
{ id: personaId, dirty: personaDirty },
),
)
.then((r) => {
log("channel.save", "ok");
onSaved(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
@@ -115,6 +132,8 @@ export function ChannelConfigForm({
setMsgPerMin("");
setMaxMessages("");
setExtraConfig("");
setPersonaId("");
setPersonaDirty(false);
})
.catch((e: unknown) => {
log("channel.save", "error");
@@ -157,6 +176,16 @@ export function ChannelConfigForm({
) : 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
@@ -221,6 +250,67 @@ export function ChannelConfigForm({
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;