feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
- 帧壳 `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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user