feat(admin): 人格页「绑定会话数」+ 渠道账号绑定角色(B 批次 2 前端)
按 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:
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)]">
|
||||
角色解析顺序:会话绑定 > 渠道绑定 > 全局默认。
|
||||
渠道绑定<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;
|
||||
|
||||
Reference in new issue
Block a user