Files
Cyrene_v2/frontend/web/src/admin/modules/OwnerAccountsEditor.tsx
T
AskaEth 625ae33c1e fix(admin): 主人账号编辑器草稿改存无损形态 ——「+ 添加账号 ID」不再点了没点
根因:`emit` 把每次改动都写成 `flattenAccounts(groups)`(写盘形态,空行被丢掉),而
草稿本身就是界面手势的往返载体 ⇒ 刚点出来的占位空行当场蒸发,界面照旧(主人报的
「配置指定适配器主人账号的卡片内的添加账号ID按钮似乎没用」)。`+ 添加平台` 走同一条
通路(空平台组同样被丢),实测同样失效。

改法:分清两种扁平形态 ——
  · 草稿 `draftAccounts`(编辑期,无损:空行/未选平台的组都留着;不 trim 正在输入的字)
  · 写盘形态 `accountsForSave`(保存期,只留平台与账号 ID 都非空的条目)
后端契约与 `configs/users/owner.yaml` 格式一个字不改。保存路径写盘前必须收形:半成品
条目(有 id 没平台)会被后端 `Validate()` 直接 400(internal/user/load.go)。

测试:`OwnerAccountsView` 从编辑器里拆出来(无 hook),测试可直接调用它、点它自己的
按钮、把 onChange 收到的真草稿再喂回去重渲染 —— 走的是「手势 → 草稿 → 界面」真往返
(本项目没有 jsdom)。修前该测试红在「点『+ 添加账号 ID』⇒ 界面上真的多出一行 —
got 1, want 2」。
2026-10-13 20:00:43 +08:00

369 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from "react";
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
import { Button } from "../../components/primitives";
import {
accountCandidates,
draftAccounts,
groupAccounts,
platformIsConfigured,
platformOptions,
type OwnerAccountGroup,
} from "./ownerAccounts";
/**
* 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用
* 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置
* 账号里选」的候选。改动经 `onChange` 回到草稿时**写回扁平数组**
* `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。
*
* 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与写回草稿。
*
* 为什么拆成两个函数:`OwnerAccountsEditor` 只留「分组/选项」这层 memo(同一表单里改
* 别的字段时不必重算),界面本体 `OwnerAccountsView` 是**无 hook** 的 —— 本项目没有
* jsdom,测试正是**直接调用**它、点它自己的按钮来钉住「手势 → 草稿 → 重渲染」这条往返
* (见 `ownerAccounts.test.ts` ⑤)。往 View 里加 hook 会让那条测试报 "Invalid hook call"。
*/
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]
);
return (
<OwnerAccountsView
groups={groups}
options={options}
channels={channels}
disabled={disabled}
onChange={onChange}
/>
);
}
export interface OwnerAccountsViewProps {
/** 界面上的分组(草稿的投影);手势产生的下一份**草稿**也由它算出来。 */
groups: OwnerAccountGroup[];
options: EnumItem[];
channels: AdminChannelRecord[] | null;
disabled: boolean;
/** 新草稿(扁平数组)。 */
onChange: (next: OwnerAccount[]) => void;
}
/** 界面本体(**无 hook**,见文件头):所有手势都走这里 → `onChange(新草稿)`。 */
export function OwnerAccountsView({
groups,
options,
channels,
disabled,
onChange,
}: OwnerAccountsViewProps): React.ReactNode {
// 每次改动都「分组 → 改 → 回草稿」:草稿里始终是扁平形态,界面上的分组只是它的投影,
// 两者不会各说各话。写回必须**无损**(`draftAccounts`):一旦写成有损的写盘形态,刚点出来的
// 「+ 添加账号 ID」空行当场蒸发 —— 点了等于没点。空行只在保存时丢(见 `accountsForSave`)。
const emit = (next: OwnerAccountGroup[]): void => onChange(draftAccounts(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}`}
row={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;
/** 在本分组里的行号(`0` 起):给行一个稳定的定位标记,浏览器里/测试里都指得到具体那一行。 */
row: number;
accountId: string;
candidates: string[];
platform: string;
known: boolean;
disabled: boolean;
onValue: (v: string) => void;
onRemove: () => void;
}
/** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */
function AccountIdRow({
listId,
row,
accountId,
candidates,
platform,
known,
disabled,
onValue,
onRemove,
}: AccountIdRowProps): React.ReactNode {
return (
<div className="space-y-1" data-account-row={row}>
<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>
);
}