根因:`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」。
369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|