feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 帧壳 `turnId` 在 socket 层不再被丢(`lib/ws.ts` 的 dispatchFrame 补 turnId/seq)—— 按路分桶的前提
- 并行帧组接入:`turn_begin` / `steer_ack` / `pending_ack` / `pending_drain` / `pending_full` /
  `chat_duplicate` ⇒ 六态 + 灰态 + `(sessionId, turnId)` 两级分桶;`appendChunk` 不再永远写尾气泡
- 多路忙态:`session_interrupted` 不再无条件当空闲(看 mode/activeTurns);`stream_end` 不再只按会话过滤
- `documents` 渲染(含 status/chars/truncated 与**逐字中文 reason**);`turnId` 历史行分组小标
- `System.tsx` 键名改正(真键 HOST/PORT/ADMIN_USERNAME/ADMIN_PASSWORD,避免照抄静默失效)
- 主人档案**按平台分组** + 平台下拉 + 账号候选;**写盘仍展平数组**(后端契约不变)
- 验证:tsc --noEmit ✅、npm run build ✅、22 个前端测试文件全通过(本次新增 3 文件 108 条断言)
- 未做(如实):握手 history handler 与断线补拉、上行 interrupt 按钮、proactive_message/subagent_result
  等帧落点、文档上传入口与图片预检、voice 管线、System 的 env 读端点
This commit is contained in:
AskaEth committed 2026-10-05 11:09:24 +08:00
1 parent 6a545badbd
commit eaada3af5d
34 files changed
+2457 -223

No files matched your search

@@ -0,0 +1,330 @@
import { useMemo } from "react";
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
import { Button } from "../../components/primitives";
import {
accountCandidates,
flattenAccounts,
groupAccounts,
platformIsConfigured,
platformOptions,
type OwnerAccountGroup,
} from "./ownerAccounts";
/**
* 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用
* 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置
* 账号里选」的候选。改动经 `onChange` 回到草稿时**立即展平**为
* `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。
*
* 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与展平。
*/
export function OwnerAccountsEditor({
accounts,
disabled,
platforms,
channels,
onChange,
}: {
accounts: OwnerAccount[];
disabled: boolean;
/** 权威平台枚举(`GET /api/v1/meta/enums` 的 `platforms`);读取失败可为空。 */
platforms: EnumItem[] | undefined;
/** 已配置渠道账号(`GET /api/v1/admin/channels`),用于给出账号 id 候选。 */
channels: AdminChannelRecord[] | null;
onChange: (next: OwnerAccount[]) => void;
}): React.ReactNode {
const groups = useMemo(() => groupAccounts(accounts), [accounts]);
const options = useMemo(
() => platformOptions(platforms, groups.map((g) => g.platform)),
[platforms, groups]
);
// 每次改动都「分组 → 展平 → 回草稿」:草稿里始终是后端要的扁平形态,
// 界面上的分组只是它的投影,两者不会各说各话。
const emit = (next: OwnerAccountGroup[]): void => onChange(flattenAccounts(next));
const patchGroup = (i: number, patch: Partial<OwnerAccountGroup>): void =>
emit(groups.map((g, idx) => (idx === i ? { ...g, ...patch } : g)));
const addAccount = (i: number): void =>
patchGroup(i, { accountIds: [...groups[i].accountIds, ""] });
const removeAccount = (i: number, j: number): void =>
patchGroup(i, { accountIds: groups[i].accountIds.filter((_, k) => k !== j) });
const patchAccount = (i: number, j: number, value: string): void =>
patchGroup(i, {
accountIds: groups[i].accountIds.map((id, k) => (k === j ? value : id)),
});
const removeGroup = (i: number): void => emit(groups.filter((_, idx) => idx !== i));
const addGroup = (): void => emit([...groups, { platform: "", accountIds: [""] }]);
return (
<div className="space-y-3">
<WhitelistHeader disabled={disabled} onAddGroup={addGroup} />
{groups.length === 0 ? (
<p className="text-xs text-[var(--mu-text-lo)]">
尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。
</p>
) : null}
{groups.map((g, i) => (
<PlatformGroupRow
key={`platform-${i}`}
group={g}
index={i}
options={options}
candidates={accountCandidates(channels, g.platform)}
known={platformIsConfigured(channels, g.platform)}
disabled={disabled}
onPlatform={(v) => patchGroup(i, { platform: v })}
onAccount={(j, v) => patchAccount(i, j, v)}
onAddAccount={() => addAccount(i)}
onRemoveAccount={(j) => removeAccount(i, j)}
onRemoveGroup={() => removeGroup(i)}
/>
))}
</div>
);
}
/** 头部:标题 + 说明(写盘格式不变)+「+ 添加平台」,以及「一组都没有」的诚实提示。 */
function WhitelistHeader({
disabled,
onAddGroup,
}: {
disabled: boolean;
onAddGroup: () => void;
}): React.ReactNode {
return (
<>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-[var(--mu-text-lo)]">
渠道账号白名单(命中即走 owner 车道)· 一个平台一条,可加多个账号 ID
</span>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onAddGroup}>
+ 添加平台
</Button>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">
保存时仍是扁平数组{' '}
<span className="font-mono">{'accounts: [{platform, accountId}, …]'}</span>
(<span className="font-mono">owner.yaml</span> 格式不变);平台与账号 ID 都填了的条目才会写盘。
</p>
</>
);
}
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)] disabled:opacity-60";
interface PlatformGroupRowProps {
group: OwnerAccountGroup;
index: number;
options: EnumItem[];
candidates: string[];
known: boolean;
disabled: boolean;
onPlatform: (v: string) => void;
onAccount: (j: number, v: string) => void;
onAddAccount: () => void;
onRemoveAccount: (j: number) => void;
onRemoveGroup: () => void;
}
/** 一个平台分组:平台下拉 + 该平台下的账号 ID 多行(每行带候选,可删)。 */
function PlatformGroupRow(props: PlatformGroupRowProps): React.ReactNode {
const { group, index, options, candidates, known, disabled } = props;
const emptyCount = group.accountIds.filter((id) => id.trim() === "").length;
return (
<div
className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/50 p-3"
data-platform={group.platform}
data-group-index={index}
>
<PlatformPicker
group={group}
options={options}
disabled={disabled}
onPlatform={props.onPlatform}
onRemoveGroup={props.onRemoveGroup}
/>
{group.accountIds.length === 0 ? (
<p className="text-xs text-[var(--mu-warn)]">
该平台还没有账号 ID:保存时这一组会被忽略(不会写入 owner.yaml)。
</p>
) : null}
{group.accountIds.map((accountId, j) => (
<AccountIdRow
key={`acct-${index}-${j}`}
listId={`acct-${index}-${j}-candidates`}
accountId={accountId}
candidates={candidates}
platform={group.platform}
known={known}
disabled={disabled}
onValue={(v) => props.onAccount(j, v)}
onRemove={() => props.onRemoveAccount(j)}
/>
))}
<div className="flex items-center gap-2">
<Button size="sm" variant="soft" disabled={disabled} onClick={props.onAddAccount}>
+ 添加账号 ID
</Button>
{emptyCount > 0 ? (
<span className="text-xs text-[var(--mu-text-lo)]">
有 {emptyCount} 条空白账号 ID:保存时会被忽略。
</span>
) : null}
</div>
</div>
);
}
/** 分组头:平台下拉(权威枚举)+ 该平台的账号条数 + 「删除该平台」。 */
function PlatformPicker({
group,
options,
disabled,
onPlatform,
onRemoveGroup,
}: {
group: OwnerAccountGroup;
options: EnumItem[];
disabled: boolean;
onPlatform: (v: string) => void;
onRemoveGroup: () => void;
}): React.ReactNode {
return (
<div className="flex flex-wrap items-end gap-2">
<label className="block w-48 space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">平台</span>
<select
value={group.platform}
onChange={(e) => onPlatform(e.target.value)}
disabled={disabled}
className={inputCls}
>
<option value="">请选择平台…</option>
{options.map((p) => (
<option key={p.id} value={p.id}>
{p.label}({p.id})
</option>
))}
</select>
</label>
<span className="text-xs text-[var(--mu-text-lo)]">
{group.platform === ""
? "未选平台:这组不会被写盘"
: `该平台下的账号 ID(${group.accountIds.length} 条)`}
</span>
<Button size="sm" variant="ghost" className="ml-auto" disabled={disabled} onClick={onRemoveGroup}>
删除该平台
</Button>
</div>
);
}
interface AccountIdRowProps {
listId: string;
accountId: string;
candidates: string[];
platform: string;
known: boolean;
disabled: boolean;
onValue: (v: string) => void;
onRemove: () => void;
}
/** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */
function AccountIdRow({
listId,
accountId,
candidates,
platform,
known,
disabled,
onValue,
onRemove,
}: AccountIdRowProps): React.ReactNode {
return (
<div className="space-y-1">
<div className="flex flex-wrap items-end gap-2">
<label className="block min-w-56 flex-1 space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID</span>
<input
value={accountId}
onChange={(e) => onValue(e.target.value)}
disabled={disabled}
list={candidates.length ? listId : undefined}
className={`${inputCls} font-mono`}
placeholder={candidates.length ? "可手填,或从下方候选里选" : "如 9616b27a…"}
/>
</label>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onRemove}>
删除
</Button>
</div>
{candidates.length ? (
<AccountCandidates
listId={listId}
candidates={candidates}
disabled={disabled}
onPick={onValue}
/>
) : (
<CandidateHint platform={platform} known={known} />
)}
</div>
);
}
/** 候选账号:`<datalist>`(键盘可筛)+ 可点的标签(看得见、点一下就填)。 */
function AccountCandidates({
listId,
candidates,
disabled,
onPick,
}: {
listId: string;
candidates: string[];
disabled: boolean;
onPick: (v: string) => void;
}): React.ReactNode {
return (
<>
<datalist id={listId}>
{candidates.map((c) => (
<option key={c} value={c} />
))}
</datalist>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--mu-text-lo)]">从该平台已配置账号里选:</span>
{candidates.map((c) => (
<button
key={c}
type="button"
disabled={disabled}
onClick={() => onPick(c)}
className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-4)] px-2 py-0.5 font-mono text-xs text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-5)] disabled:opacity-60"
title={`填入已配置账号 ${c}`}
>
{c}
</button>
))}
</div>
</>
);
}
/** 没有候选时给一句诚实的解释(而不是假装有候选或静静空着)。 */
function CandidateHint({ platform, known }: { platform: string; known: boolean }): React.ReactNode {
if (!platform) return null;
return (
<p className="text-xs text-[var(--mu-text-lo)]">
{known
? "该平台暂无已配置账号:先在「渠道管理」里配好账号,这里就能直接选。"
: "该平台未在渠道管理里注册:账号 ID 只能手填。"}
</p>
);
}
+11 -3
View File
@@ -157,9 +157,17 @@ export function SystemModule(): React.ReactNode {
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="package" size={18} />} title="环境变量" />
<KeyValueRow label="SERVER_HOST" value="0.0.0.0" mono />
<KeyValueRow label="SERVER_PORT" value="8080" mono />
<KeyValueRow label="AUTH_ADMIN_USER/PASS" value="••••••••" mono />
{/* 键名必须与 `.env` / `.env.example` **逐字一致**:面板是只读事实卡,
用户会照着这里抄键名去改 .env —— 写错一个字母就是静默不生效(AGENTS §10 E6)。
真键:HOST / PORT / ADMIN_USERNAME / ADMIN_PASSWORD(见 .env.example)。 */}
<KeyValueRow label="HOST" value="0.0.0.0" mono />
<KeyValueRow label="PORT" value="8080" mono />
<KeyValueRow label="ADMIN_USERNAME" value="admin" mono />
<KeyValueRow label="ADMIN_PASSWORD" value="••••••••" mono />
<p className="pt-1 text-xs text-[var(--mu-text-lo)]">
以上为 <span className="font-mono">.env.example</span> 的默认值(面板无 env 读取端点,不是运行实例的实时值);
改值请编辑 <span className="font-mono">.env</span> 后重启服务。
</p>
</Surface>
<Surface className="space-y-1">
+37 -51
View File
@@ -3,15 +3,23 @@ import { useAsyncData } from "../../lib/useAsyncData";
import {
adminCreateUser,
adminGetOwnerProfile,
adminListChannels,
adminListUsers,
adminIssueApiKey,
adminRevokeApiKey,
adminSaveOwnerProfile,
fetchEnums,
getStoredRole,
} from "../../lib/api";
import type { AdminUserRecord, OwnerAccount, OwnerProfile } from "../../lib/types";
import type {
AdminChannelRecord,
AdminUserRecord,
EnumItem,
OwnerProfile,
} from "../../lib/types";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
import { Button, Surface, Tag } from "../../components/primitives";
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
@@ -138,6 +146,10 @@ function UsersTableSection({
* 是一次真实 PUT:成功回读服务端档案,失败如实报错 —— 绝不假装保存成功。 */
function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode {
const { data, loading, error } = useAsyncData(adminGetOwnerProfile, []);
// 平台下拉取权威枚举(未接线/失败时回落本仓镜像的标签表,取值仍原样呈现);
// 账号 id 候选取该平台已配置的渠道账号,避免手打错一位就静默失效。
const enums = useAsyncData(fetchEnums, []);
const channels = useAsyncData(adminListChannels, []);
const log = useAuditStore((s) => s.log);
const [profile, setProfile] = useState<OwnerProfile | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@@ -181,7 +193,15 @@ function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode
{fail ? <Notice tone="err">{fail}</Notice> : null}
{notice ? <Notice tone="ok">{notice}</Notice> : null}
{profile ? (
<OwnerProfileForm profile={profile} disabled={!isOwner} busy={busy} onChange={setProfile} onSave={save} />
<OwnerProfileForm
profile={profile}
disabled={!isOwner}
busy={busy}
platforms={enums.data?.platforms}
channels={channels.data}
onChange={setProfile}
onSave={save}
/>
) : (
<p className="text-xs text-[var(--mu-text-lo)]">{loading ? "读取中…" : "未加载到主人档案"}</p>
)}
@@ -193,17 +213,23 @@ interface OwnerProfileFormProps {
profile: OwnerProfile;
disabled: boolean;
busy: boolean;
platforms: EnumItem[] | undefined;
channels: AdminChannelRecord[] | null;
onChange: (next: OwnerProfile) => void;
onSave: () => void;
}
/** 档案表单本体(纯受控:所有改动都经 onChange 回到草稿)。 */
function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerProfileFormProps): React.ReactNode {
function OwnerProfileForm({
profile,
disabled,
busy,
platforms,
channels,
onChange,
onSave,
}: OwnerProfileFormProps): React.ReactNode {
const patch = (p: Partial<OwnerProfile>): void => onChange({ ...profile, ...p });
const patchAccount = (i: number, p: Partial<OwnerAccount>): void =>
patch({ accounts: profile.accounts.map((a, idx) => (idx === i ? { ...a, ...p } : a)) });
const removeAccount = (i: number): void => patch({ accounts: profile.accounts.filter((_, idx) => idx !== i) });
const appendAccount = (): void => patch({ accounts: [...profile.accounts, { platform: "", accountId: "" }] });
return (
<>
@@ -213,12 +239,12 @@ function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerPr
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => patch({ defaultPersona: v })} disabled={disabled} />
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => patch({ identityFile: v })} disabled={disabled} />
</div>
<AccountWhitelistEditor
<OwnerAccountsEditor
accounts={profile.accounts}
disabled={disabled}
onPatch={patchAccount}
onRemove={removeAccount}
onAppend={appendAccount}
platforms={platforms}
channels={channels}
onChange={(accounts) => patch({ accounts })}
/>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
@@ -242,46 +268,6 @@ function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerPr
);
}
interface AccountWhitelistEditorProps {
accounts: OwnerAccount[];
disabled: boolean;
onPatch: (index: number, patch: Partial<OwnerAccount>) => void;
onRemove: (index: number) => void;
onAppend: () => void;
}
/** 渠道账号白名单行编辑器:命中此列表的账号走 owner 车道(元数据判定,非提示词)。 */
function AccountWhitelistEditor({
accounts,
disabled,
onPatch,
onRemove,
onAppend,
}: AccountWhitelistEditorProps): React.ReactNode {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--mu-text-lo)]">渠道账号白名单(命中即走 owner 车道)</span>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onAppend}>
添加账号
</Button>
</div>
{accounts.length === 0 ? (
<p className="text-xs text-[var(--mu-text-lo)]">尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。</p>
) : null}
{accounts.map((a, i) => (
<div key={`acct-${i}`} className="flex flex-wrap items-end gap-2">
<Field label="平台" value={a.platform} onChange={(v) => onPatch(i, { platform: v })} disabled={disabled} />
<Field label="账号 id" value={a.accountId} onChange={(v) => onPatch(i, { accountId: v })} disabled={disabled} />
<Button size="sm" variant="ghost" disabled={disabled} onClick={() => onRemove(i)}>
删除
</Button>
</div>
))}
</div>
);
}
/** API key 签发块(裁决 15.4.3):不选角色 —— 所有 key 一律超管。 */
function ApiKeySection({ isOwner, onIssued }: { isOwner: boolean; onIssued: () => void }): React.ReactNode {
const log = useAuditStore((s) => s.log);
@@ -0,0 +1,174 @@
// 主人档案「渠道账号白名单」编辑器的测试(主人 2026-10-05 要求)。
//
// 为什么需要它:此前一行 = `{platform, accountId}`,同一平台多个账号要**重复填平台名**,
// 且平台是纯文本输入框 —— 填错(`qq_obv11` / `QQ-OBV11`)会**静默失效**(主人被当陌生人,
// 界面无任何提示)。改法:界面**一个平台一条** + 该平台下「+ 添加账号 ID」;平台是权威
// 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。
//
// 锁三件事:
// ① 分组/展平是纯函数且**展平后就是后端契约的扁平数组**(`owner.yaml` 格式不变);
// ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉);
// ③ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点。
//
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/ownerAccounts.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-owner-accounts.test.cjs \
// && node /tmp/cyrene-owner-accounts.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
import {
accountCandidates,
flattenAccounts,
groupAccounts,
platformIsConfigured,
platformOptions,
} from "./ownerAccounts";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
ok(
JSON.stringify(got) === JSON.stringify(want),
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
);
}
const PUBLISHED: EnumItem[] = [
{ id: "web", label: "网页" },
{ id: "kook", label: "KOOK" },
{ id: "qq-obv11", label: "QQ" },
{ id: "weixin-ilink", label: "微信" },
{ id: "telegram", label: "Telegram" },
{ id: "webhook", label: "Webhook" },
];
// ── ① 分组 / 展平(纯函数) ─────────────────────────────────────────────────────
{
const flat: OwnerAccount[] = [
{ platform: "qq-obv11", accountId: "9616b27a" },
{ platform: "web", accountId: "web-user" },
{ platform: "qq-obv11", accountId: "aaaa1111" },
];
const groups = groupAccounts(flat);
same(groups.length, 2, "两个平台 ⇒ 两块(同一平台的多个账号不重复出现平台)");
same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块");
same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序");
// 展平回扁平数组。注意:分组天然把**同一平台的账号排到相邻位置** ⇒ 保存后文件的
// 行序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),所以
// 这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。
const sorted = (list: OwnerAccount[]): OwnerAccount[] =>
[...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`));
same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目");
const shape = Object.keys(flattenAccounts(groups)[0]).sort();
same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)");
// 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。
same(
flattenAccounts([
{ platform: "qq-obv11", accountIds: ["9616b27a", " ", ""] },
{ platform: "", accountIds: ["orphan"] },
{ platform: "kook", accountIds: [] },
]),
[{ platform: "qq-obv11", accountId: "9616b27a" }],
"空白账号 / 空白平台 / 空平台组都不写盘"
);
same(
flattenAccounts([{ platform: " kook ", accountIds: [" 42 "] }]),
[{ platform: "kook", accountId: "42" }],
"两侧空白被去掉(避免写出匹配不上的键)"
);
}
// ── ② 下拉选项:权威枚举 + 存量旧值(display-raw) ───────────────────────────────
{
const options = platformOptions(PUBLISHED, ["qq-obv11", "discord"]);
ok(options.length === 7, "权威 6 项 + 1 个存量里的未知平台(不重复)");
same(options[0], { id: "web", label: "网页" }, "权威枚举在最前");
same(options[6], { id: "discord", label: "discord" }, "未列出的旧值原样保留(display-raw,不被下拉吃掉)");
ok(
platformOptions(undefined, ["qq-obv11"]).length === 1,
"取不到枚举(未接线/失败)时仍能列出存量平台,不至于把下拉掏空"
);
ok(
platformOptions(PUBLISHED, []).some((o) => o.id === "qq-obv11" && o.label === "QQ"),
"枚举项用后端给的标签"
);
}
// ── ③ 候选账号:来自该平台已配置的渠道账号 ──────────────────────────────────────
{
const channels: AdminChannelRecord[] = [
{ name: "qq-obv11", connected: true, accounts: [{ id: "9616b27a", config: {} }, { id: "aaaa1111", config: {} }] },
{ name: "kook", connected: false, accounts: [{ id: "", config: {} }] },
];
same(accountCandidates(channels, "qq-obv11"), ["9616b27a", "aaaa1111"], "候选取该平台已配置账号的 id");
same(accountCandidates(channels, "kook"), [], "空 id 的历史条目不作候选(它没有可用 id)");
same(accountCandidates(channels, "telegram"), [], "未配置的平台没有候选");
same(accountCandidates(null, "qq-obv11"), [], "渠道列表没读到时不编候选");
ok(platformIsConfigured(channels, "qq-obv11"), "已注册平台");
ok(!platformIsConfigured(channels, "telegram"), "未注册平台");
}
// ── ④ 渲染:一个平台一条 + 子按钮 + 候选 ────────────────────────────────────────
{
const accounts: OwnerAccount[] = [
{ platform: "qq-obv11", accountId: "9616b27a" },
{ platform: "qq-obv11", accountId: "aaaa1111" },
{ platform: "kook", accountId: "" },
];
const channels: AdminChannelRecord[] = [
{ name: "qq-obv11", connected: true, accounts: [{ id: "9616b27a", config: {} }] },
];
const html = renderToStaticMarkup(
createElement(OwnerAccountsEditor, {
accounts,
disabled: false,
platforms: PUBLISHED,
channels,
onChange: () => {},
})
);
const groupCount = html.split("data-platform=").length - 1;
same(groupCount, 2, "界面「一个平台一条」:两个平台 ⇒ 两块(同一平台的账号合在一块)");
ok(html.includes('data-platform="qq-obv11"'), "分组带平台标记,便于核对与测试定位");
ok(html.includes("<select"), "平台是下拉选择,不再是纯文本输入框(消灭拼错平台导致的静默失效)");
ok(html.includes('value="qq-obv11"') && html.includes("QQ(qq-obv11)"), "下拉里有权威枚举项(含 id,抄不错)");
ok(html.includes('value="discord"') === false, "存量里没有的平台不凭空塞进下拉");
ok(html.includes("+ 添加账号 ID"), "每个平台下都有「+ 添加账号 ID」子按钮");
ok(html.includes("从该平台已配置账号里选:"), "账号 ID 旁给出「从已配置账号里选」的候选");
ok(html.includes("9616b27a"), "候选里出现该平台已配置的账号 id");
ok(html.includes("owner.yaml"), "明写保存后仍是 owner.yaml 的既有格式");
ok(html.includes("有 1 条空白账号 ID"), "空账号 ID 有显式提示(保存时会被忽略,不是静默丢弃)");
ok(html.includes("该平台下的账号 ID(1 条)"), "分组标题报出该平台的账号条数");
// 未注册的平台(没有候选)给出诚实的解释,而不是假装有候选。
const other = renderToStaticMarkup(
createElement(OwnerAccountsEditor, {
accounts: [{ platform: "telegram", accountId: "" }],
disabled: true,
platforms: PUBLISHED,
channels,
onChange: () => {},
})
);
ok(other.includes("该平台未在渠道管理里注册"), "未注册平台说明「账号 ID 只能手填」");
ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)");
}
console.log(`owner accounts editor test: ${checks} checks passed`);
@@ -0,0 +1,97 @@
import { labelFor } from "../../lib/enums";
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
/**
* 主人档案「渠道账号白名单」的呈现层(主人 2026-10-05 要求):界面**按平台分组**
* (一个平台一条 + 该平台下「+ 添加账号 ID」),**写盘时展平成扁平数组**
* `accounts:[{platform,accountId}…]` —— 后端契约与 `configs/users/owner.yaml` 的
* 文件格式**一个字都不改**,这里纯粹是呈现层。
*
* 为什么值得改:此前一行 = `{platform, accountId}`,同一平台多个账号要**重复填平台名**,
* 且平台是纯文本输入框 —— 填错(`qq_obv11` / `QQ-OBV11`)会**静默失效**
* (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。
* 平台改为权威枚举下拉 + 账号 id 可从该平台已配置账号里选,消灭这类静默失效。
*
* 本模块是纯函数(无 DOM、无请求),因此分组/展平/下拉选项/候选都能在 node 下逐条测。
*/
/** 一个平台分组:界面上的一条。 */
export interface OwnerAccountGroup {
platform: string;
accountIds: string[];
}
/** 把扁平数组按平台分组(保持首次出现顺序,同一平台的多条账号收进一组)。 */
export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] {
const groups: OwnerAccountGroup[] = [];
const byPlatform = new Map<string, OwnerAccountGroup>();
for (const a of accounts) {
const platform = a?.platform ?? "";
let g = byPlatform.get(platform);
if (!g) {
g = { platform, accountIds: [] };
byPlatform.set(platform, g);
groups.push(g);
}
g.accountIds.push(a?.accountId ?? "");
}
return groups;
}
/** 展平成后端契约要的扁平数组。**平台与账号 id 都非空才写**:任一为空都匹配不上任何
* actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组被忽略,
* 界面上有对应提示,不是静默丢弃。 */
export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] {
const out: OwnerAccount[] = [];
for (const g of groups) {
const platform = g.platform.trim();
for (const raw of g.accountIds) {
const accountId = raw.trim();
if (!platform || !accountId) continue;
out.push({ platform, accountId });
}
}
return out;
}
/** 下拉选项:后端权威枚举(`GET /api/v1/meta/enums` 的 `platforms`)在前,随后补上
* 存量里出现过的平台 —— 未列出的旧值**原样保留**(display-raw:绝不下拉吃掉它,
* 否则一次保存就把主人已经生效的配置删了)。 */
export function platformOptions(
published: readonly EnumItem[] | undefined,
used: readonly string[]
): EnumItem[] {
const out: EnumItem[] = [];
const seen = new Set<string>();
const push = (id: string, label?: string) => {
if (!id || seen.has(id)) return;
seen.add(id);
out.push({ id, label: label || labelFor("platform", id) });
};
for (const p of published ?? []) push(p.id, p.label);
for (const id of used) push(id);
return out;
}
/** 该平台**已配置账号**的候选(数据来自 `GET /api/v1/admin/channels` 的 accounts):
* 让主人从真凭据里选,不再手打 id(手打错一个字符同样是静默失效)。 */
export function accountCandidates(
channels: AdminChannelRecord[] | null | undefined,
platform: string
): string[] {
if (!platform) return [];
const record = (channels ?? []).find((c) => c.name === platform);
const ids = (record?.accounts ?? [])
.map((a) => a?.id ?? "")
.filter((id) => id !== "");
return [...new Set(ids)];
}
/** 该平台是否在「已配置账号」里出现过(用于给「候选为空」一句诚实的解释)。 */
export function platformIsConfigured(
channels: AdminChannelRecord[] | null | undefined,
platform: string
): boolean {
if (!platform) return false;
return (channels ?? []).some((c) => c.name === platform);
}