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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user