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 下逐条测。 * * **两种扁平形态,别混用**(这是主人 2026-10-07 报的「添加账号 ID 按钮似乎没用」的根因所在): * ① **草稿**(`draftAccounts`,编辑期):逐行原样保留,空行/未选平台的组都在 —— * 界面手势产生的中间态**绝不能在往返中蒸发**; * ② **写盘形态**(`accountsForSave`,保存期):只留平台与账号 id 都非空的条目。 * 把①错当②用(编辑期就走写盘形态),空行当场消失 ⇒ 点了等于没点,界面上还看不出原因。 */ /** 一个平台分组:界面上的一条。 */ export interface OwnerAccountGroup { platform: string; accountIds: string[]; } /** 把扁平数组按平台分组(保持首次出现顺序,同一平台的多条账号收进一组)。 */ export function groupAccounts(accounts: readonly OwnerAccount[]): OwnerAccountGroup[] { const groups: OwnerAccountGroup[] = []; const byPlatform = new Map(); 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」空行当场蒸发 ⇒ 点了等于没点 * (主人 2026-10-07 报的正是这个)。空行只在**保存时**才丢(见 `accountsForSave`), * 界面上另有「有 N 条空白账号 ID:保存时会被忽略」的显式提示,不是静默丢弃。 * * 另外:编辑期**不改写**主人正在输入的字(不 trim)。逐键 trim 会把还在输入的空白吃掉; * 规范化(去空白 + 丢空行)留到写盘那一步做。 */ export function draftAccounts(groups: readonly OwnerAccountGroup[]): OwnerAccount[] { const out: OwnerAccount[] = []; for (const g of groups) { for (const accountId of g.accountIds) out.push({ platform: g.platform, accountId }); } return out; } /** 展平成后端契约要的扁平数组(**写盘形态**)。**平台与账号 id 都非空才写**:任一为空都 * 匹配不上任何 actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组 * 被忽略,界面上有对应提示,不是静默丢弃。 */ export function flattenAccounts(groups: readonly 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; } /** 草稿 → 写盘形态(`PUT /api/v1/admin/owner-profile` 的 `accounts`,即 * `configs/users/owner.yaml` 的条目)。**保存路径只走它**,别自己拼这两步。 * * 编辑期草稿里允许有空行,但后端 `Validate()` 对「平台空、账号 id 非空」这种半成品条目 * 直接 400(`internal/user/load.go`),完全空的条目则被跳过 —— 所以这一步是必须的,不是洁癖。 * 去掉两侧空白同理:带空格的键永远匹配不上 actor 元数据。 */ export function accountsForSave(accounts: readonly OwnerAccount[]): OwnerAccount[] { return flattenAccounts(groupAccounts(accounts)); } /** 下拉选项:后端权威枚举(`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(); 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); }