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 ( ); } 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): 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 (
{groups.length === 0 ? (

尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。

) : null} {groups.map((g, i) => ( patchGroup(i, { platform: v })} onAccount={(j, v) => patchAccount(i, j, v)} onAddAccount={() => addAccount(i)} onRemoveAccount={(j) => removeAccount(i, j)} onRemoveGroup={() => removeGroup(i)} /> ))}
); } /** 头部:标题 + 说明(写盘格式不变)+「+ 添加平台」,以及「一组都没有」的诚实提示。 */ function WhitelistHeader({ disabled, onAddGroup, }: { disabled: boolean; onAddGroup: () => void; }): React.ReactNode { return ( <>
渠道账号白名单(命中即走 owner 车道)· 一个平台一条,可加多个账号 ID

保存时仍是扁平数组{' '} {'accounts: [{platform, accountId}, …]'} (owner.yaml 格式不变);平台与账号 ID 都填了的条目才会写盘。

); } 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 (
{group.accountIds.length === 0 ? (

该平台还没有账号 ID:保存时这一组会被忽略(不会写入 owner.yaml)。

) : null} {group.accountIds.map((accountId, j) => ( props.onAccount(j, v)} onRemove={() => props.onRemoveAccount(j)} /> ))}
{emptyCount > 0 ? ( 有 {emptyCount} 条空白账号 ID:保存时会被忽略。 ) : null}
); } /** 分组头:平台下拉(权威枚举)+ 该平台的账号条数 + 「删除该平台」。 */ function PlatformPicker({ group, options, disabled, onPlatform, onRemoveGroup, }: { group: OwnerAccountGroup; options: EnumItem[]; disabled: boolean; onPlatform: (v: string) => void; onRemoveGroup: () => void; }): React.ReactNode { return (
{group.platform === "" ? "未选平台:这组不会被写盘" : `该平台下的账号 ID(${group.accountIds.length} 条)`}
); } 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 (
{candidates.length ? ( ) : ( )}
); } /** 候选账号:``(键盘可筛)+ 可点的标签(看得见、点一下就填)。 */ function AccountCandidates({ listId, candidates, disabled, onPick, }: { listId: string; candidates: string[]; disabled: boolean; onPick: (v: string) => void; }): React.ReactNode { return ( <> {candidates.map((c) => (
从该平台已配置账号里选: {candidates.map((c) => ( ))}
); } /** 没有候选时给一句诚实的解释(而不是假装有候选或静静空着)。 */ function CandidateHint({ platform, known }: { platform: string; known: boolean }): React.ReactNode { if (!platform) return null; return (

{known ? "该平台暂无已配置账号:先在「渠道管理」里配好账号,这里就能直接选。" : "该平台未在渠道管理里注册:账号 ID 只能手填。"}

); }