diff --git a/frontend/web/src/admin/modules/OwnerAccountsEditor.tsx b/frontend/web/src/admin/modules/OwnerAccountsEditor.tsx index 2ed6ba9..82c78c5 100644 --- a/frontend/web/src/admin/modules/OwnerAccountsEditor.tsx +++ b/frontend/web/src/admin/modules/OwnerAccountsEditor.tsx @@ -3,7 +3,7 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types import { Button } from "../../components/primitives"; import { accountCandidates, - flattenAccounts, + draftAccounts, groupAccounts, platformIsConfigured, platformOptions, @@ -13,11 +13,16 @@ import { /** * 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用 * 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置 - * 账号里选」的候选。改动经 `onChange` 回到草稿时**立即展平**为 + * 账号里选」的候选。改动经 `onChange` 回到草稿时**写回扁平数组** * `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。 * * 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的 - * `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与展平。 + * `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与写回草稿。 + * + * 为什么拆成两个函数:`OwnerAccountsEditor` 只留「分组/选项」这层 memo(同一表单里改 + * 别的字段时不必重算),界面本体 `OwnerAccountsView` 是**无 hook** 的 —— 本项目没有 + * jsdom,测试正是**直接调用**它、点它自己的按钮来钉住「手势 → 草稿 → 重渲染」这条往返 + * (见 `ownerAccounts.test.ts` ⑤)。往 View 里加 hook 会让那条测试报 "Invalid hook call"。 */ export function OwnerAccountsEditor({ accounts, @@ -39,10 +44,39 @@ export function OwnerAccountsEditor({ () => platformOptions(platforms, groups.map((g) => g.platform)), [platforms, groups] ); + return ( + + ); +} - // 每次改动都「分组 → 展平 → 回草稿」:草稿里始终是后端要的扁平形态, - // 界面上的分组只是它的投影,两者不会各说各话。 - const emit = (next: OwnerAccountGroup[]): void => onChange(flattenAccounts(next)); +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))); @@ -156,6 +190,7 @@ function PlatformGroupRow(props: PlatformGroupRowProps): React.ReactNode { {group.accountIds.map((accountId, j) => ( + 账号 ID diff --git a/frontend/web/src/admin/modules/Users.tsx b/frontend/web/src/admin/modules/Users.tsx index 2de176b..1bbbdad 100644 --- a/frontend/web/src/admin/modules/Users.tsx +++ b/frontend/web/src/admin/modules/Users.tsx @@ -20,6 +20,7 @@ import type { import { useAuditStore } from "../audit"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; import { OwnerAccountsEditor } from "./OwnerAccountsEditor"; +import { accountsForSave } from "./ownerAccounts"; import { Button, Surface, Tag } from "../../components/primitives"; const roleTone: Record = { @@ -166,7 +167,9 @@ function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode setBusy(true); setFail(null); setNotice(null); - adminSaveOwnerProfile(profile) + // 草稿里允许有还没填完的空行(编辑期不能丢行),写盘前才收成写盘形态:只留平台与账号 ID + // 都非空的条目,并去掉两侧空白。既不把空行写进 owner.yaml,也不会因为半成品条目被后端 400。 + adminSaveOwnerProfile({ ...profile, accounts: accountsForSave(profile.accounts) }) .then((saved) => { log("owner.profile.edit", "ok"); setProfile(saved); diff --git a/frontend/web/src/admin/modules/ownerAccounts.test.ts b/frontend/web/src/admin/modules/ownerAccounts.test.ts index 6d016db..f0678db 100644 --- a/frontend/web/src/admin/modules/ownerAccounts.test.ts +++ b/frontend/web/src/admin/modules/ownerAccounts.test.ts @@ -5,10 +5,15 @@ // 界面无任何提示)。改法:界面**一个平台一条** + 该平台下「+ 添加账号 ID」;平台是权威 // 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。 // -// 锁三件事: -// ① 分组/展平是纯函数且**展平后就是后端契约的扁平数组**(`owner.yaml` 格式不变); +// 锁这些事: +// ① 分组/草稿/写盘形态(纯函数):**草稿无损**(`draftAccounts` 逐行保留,空行/未选平台的组 +// 都在 —— 编辑期的中间态不许蒸发),**写盘有损**(`accountsForSave` 只留平台与账号 ID 都 +// 非空的条目:半成品条目会让后端 `Validate()` 直接 400,完全空的条目被跳过); // ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉); -// ③ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点。 +// ③ 候选账号 = 该平台已配置的渠道账号 id; +// ④ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点; +// ⑤ **手势往返**(主人 2026-10-07 报的缺陷:卡片里「+ 添加账号 ID」似乎没用):点一下必须 +// 真的多出一行可输入的账号行;「+ 添加平台」走同一条往返,同样钉住。 // // 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs): // @@ -21,12 +26,14 @@ // `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、 // 不 import node:*。 -import { createElement } from "react"; +import { createElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types"; -import { OwnerAccountsEditor } from "./OwnerAccountsEditor"; +import { OwnerAccountsEditor, OwnerAccountsView } from "./OwnerAccountsEditor"; import { accountCandidates, + accountsForSave, + draftAccounts, flattenAccounts, groupAccounts, platformIsConfigured, @@ -56,7 +63,7 @@ const PUBLISHED: EnumItem[] = [ { id: "webhook", label: "Webhook" }, ]; -// ── ① 分组 / 展平(纯函数) ───────────────────────────────────────────────────── +// ── ① 分组 / 草稿 / 写盘形态(纯函数) ────────────────────────────────────────── { const flat: OwnerAccount[] = [ { platform: "qq-obv11", accountId: "9616b27a" }, @@ -68,15 +75,71 @@ const PUBLISHED: EnumItem[] = [ same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块"); same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序"); - // 展平回扁平数组。注意:分组天然把**同一平台的账号排到相邻位置** ⇒ 保存后文件的 - // 行序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),所以 - // 这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。 + // 草稿(编辑期形态,`draftAccounts`)**无损**:逐行原样回来 —— 空行、还没选平台的组都在。 + // 这一条是主人 2026-10-07 那个缺陷的正面表述:中间态不许在往返里蒸发。 + same( + groupAccounts(draftAccounts(groups)), + groups, + "组 → 草稿 → 组:原样回来(无损往返)" + ); + same( + draftAccounts([{ platform: "kook", accountIds: ["9616b27a", "", " "] }]), + [ + { platform: "kook", accountId: "9616b27a" }, + { platform: "kook", accountId: "" }, + { platform: "kook", accountId: " " }, + ], + "空行留在草稿里(不 trim、不丢行:编辑期不改写主人正在输入的字)" + ); + same( + groupAccounts(draftAccounts([{ platform: "", accountIds: [""] }])), + [{ platform: "", accountIds: [""] }], + "「还没选平台」的空组也在草稿里(「+ 添加平台」靠它)" + ); + + // 写盘形态(`flattenAccounts` / `accountsForSave`):分组天然把**同一平台的账号排到相邻位置** + // ⇒ 保存后文件的条目序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关), + // 所以这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。 const sorted = (list: OwnerAccount[]): OwnerAccount[] => [...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`)); same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目"); const shape = Object.keys(flattenAccounts(groups)[0]).sort(); same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)"); + // 保存路径(草稿 → 写盘形态):只写填完的条目。 + // 草稿里允许有空行,写盘时收掉;半成品条目(缺平台或缺 id)**必须**在这里收掉,否则 + // 后端 `Validate()`(internal/user/load.go)对这类条目直接 400,主人会看到「保存失败」。 + same( + accountsForSave([ + { platform: "qq-obv11", accountId: "9616b27a" }, + { platform: "qq-obv11", accountId: "" }, + { platform: "kook", accountId: "" }, + ]), + [{ platform: "qq-obv11", accountId: "9616b27a" }], + "保存:只写平台与账号 ID 都非空的条目(空行不进 owner.yaml)" + ); + same( + accountsForSave([ + { platform: "", accountId: "9616b27a" }, + { platform: "kook", accountId: "" }, + { platform: "", accountId: "" }, + ]), + [], + "保存:半成品条目一个都不写(写了会被后端 400)" + ); + same(accountsForSave([]), [], "保存:空白名单写出空数组(不是 null)"); + same(sorted(accountsForSave(flat)), sorted(flat), "保存:已生效的条目原样往返(不会被写没)"); + same( + sorted(accountsForSave(flattenAccounts(groups))), + sorted(flat), + "保存:写盘形态再收一次仍是同一组(幂等)" + ); + same( + Object.keys(accountsForSave([{ platform: "kook", accountId: "42" }])[0]).sort(), + ["accountId", "platform"], + "保存:落盘条目仍只有 {platform, accountId}(owner.yaml 格式一个字不改)" + ); + // 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。 same( flattenAccounts([ @@ -171,4 +234,247 @@ const PUBLISHED: EnumItem[] = [ ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)"); } +// ── ⑤ 手势 → 草稿 → 渲染:任何界面手势产生的中间态都不能在往返中蒸发 ───────────── +// +// 这一节钉的是主人报的那个缺陷:「配置指定适配器主人账号的卡片内的『添加账号ID』按钮似乎没用」。 +// 它只发生在「手势 → 草稿 → 再投影回界面」这条往返上:草稿若是**有损**的扁平形态(展平时把空行 +// 丢掉),刚点出来的空行当场蒸发、界面照旧 —— 点了等于没点。注意纯函数往返测试 +// (`展平(分组(flat)) == flat`)**测不到**它:那个等式把「空行被丢掉」当成恒等,等于把有损性 +// 当成了正确性。 +// +// 本项目没有 jsdom(见 flowTimelineWiring.render.test.ts 的说明)⇒ 这里直接调用**无 hook** 的 +// `OwnerAccountsView` 拿到真的元素树,**点真的那个按钮**(调它自己的 `onClick`),把 `onChange` +// 收到的**真草稿**再喂回去重渲染 —— 走的正是 `Users.tsx` 的接线(草稿是唯一状态,界面是它的投影)。 + +/** 元素树里的一个宿主节点(`button`/`input`/`select`/`div`…)。 */ +interface HostEl { + type: string; + props: Record; +} + +/** 节点里的可见文字(按钮标签、提示语靠它找)。 */ +function textOf(node: ReactNode): string { + if (node === null || node === undefined || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textOf).join(""); + return textOf((node as { props?: { children?: ReactNode } }).props?.children); +} + +/** 走一遍元素树,收集宿主节点。函数组件会被**直接调用**(`Button` / `PlatformGroupRow` / + * `AccountIdRow` 都是无 hook 的纯呈现)—— 若往视图里塞 hook,这里会以 `Invalid hook call` + * 报错,那是提醒你把手势逻辑留在无 hook 的那一层。 */ +function hostEls(node: ReactNode): HostEl[] { + const out: HostEl[] = []; + const walk = (n: ReactNode): void => { + if (n === null || n === undefined || typeof n === "boolean") return; + if (typeof n === "string" || typeof n === "number") return; + if (Array.isArray(n)) { + n.forEach(walk); + return; + } + const el = n as { type?: unknown; props?: Record }; + const props = el.props ?? {}; + if (typeof el.type === "function") { + walk((el.type as (p: Record) => ReactNode)(props)); + return; + } + if (typeof el.type === "string") out.push({ type: el.type, props }); + walk(props.children as ReactNode); + }; + walk(node); + return out; +} + +/** 必须存在的东西(不存在就报错,而不是把 undefined 一路带下去)。 */ +function must(found: T | undefined, what: string): T { + checks += 1; + if (found === undefined) throw new Error(`FAILED: ${what}`); + return found; +} + +/** 界面的替身:草稿存在这里,每次操作都从草稿重新投影(= `Users.tsx` 那条接线)。 */ +class Harness { + draft: OwnerAccount[]; + + constructor(accounts: OwnerAccount[]) { + this.draft = accounts; + } + + /** 当前这一步的元素树(宿主节点)。 */ + tree(): HostEl[] { + const groups = groupAccounts(this.draft); + return hostEls( + createElement(OwnerAccountsView, { + groups, + options: platformOptions(PUBLISHED, groups.map((g) => g.platform)), + channels: null, + disabled: false, + onChange: (next: OwnerAccount[]) => { + this.draft = next; + }, + }) + ); + } + + /** 界面上能看见的字(「提示语说了什么」这类断言靠它)。 */ + text(): string { + return this.tree() + .map((e) => textOf(e.props.children as ReactNode)) + .join(" "); + } + + /** 点标签为 `label` 的第 `n` 个按钮(找不到就报错,不静默放过)。 */ + click(label: string, n = 0): void { + const hits = this.tree().filter( + (e) => e.type === "button" && textOf(e.props.children as ReactNode) === label + ); + const hit = must(hits[n], `界面上第 ${n + 1} 个「${label}」按钮(共找到 ${hits.length} 个)`); + (hit.props.onClick as () => void)(); + } + + /** 账号 ID 行数(每行带定位标记)。 */ + rows(): number { + return this.tree().filter((e) => e.type === "div" && e.props["data-account-row"] !== undefined) + .length; + } + + selects(): number { + return this.tree().filter((e) => e.type === "select").length; + } + + inputs(): HostEl[] { + return this.tree().filter((e) => e.type === "input" && e.props.value !== undefined); + } + + /** 在第 `n` 行输入框里打字(走它自己的 onChange)。 */ + type(n: number, value: string): void { + const el = must(this.inputs()[n], `第 ${n + 1} 个账号 ID 输入框`); + (el.props.onChange as (e: unknown) => void)({ target: { value } }); + } + + /** 在第 `n` 块里选平台(走它自己的 onChange)。 */ + pickPlatform(n: number, platform: string): void { + const el = must(this.tree().filter((e) => e.type === "select")[n], `第 ${n + 1} 块平台下拉`); + (el.props.onChange as (e: unknown) => void)({ target: { value: platform } }); + } +} + +{ + // 起点 = 主人现在的真实样子:qq-obv11 下已有一个账号,他想再加一个。 + const ui = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]); + same(ui.rows(), 1, "起点:该平台一行账号 ID"); + same(ui.inputs().length, 1, "起点:一个账号 ID 输入框"); + + ui.click("+ 添加账号 ID"); + same(ui.rows(), 2, "点「+ 添加账号 ID」⇒ 界面上真的多出一行(空行没在往返里蒸发)"); + same( + ui.draft, + [ + { platform: "qq-obv11", accountId: "9616b27a" }, + { platform: "qq-obv11", accountId: "" }, + ], + "新行真的进了草稿(空的、挂在同一平台下)—— 不是「点了没点」" + ); + same( + must(ui.inputs()[1], "新行输入框").props.value, + "", + "新行是一个可输入的空输入框(接着就能打字)" + ); + + // 在新行里打字 ⇒ 值进草稿、界面仍是两行:这条往返得是**可用**的,不是只能加不能填。 + ui.type(1, "aaaa1111"); + same( + ui.draft, + [ + { platform: "qq-obv11", accountId: "9616b27a" }, + { platform: "qq-obv11", accountId: "aaaa1111" }, + ], + "在新行里打的字落进草稿" + ); + same(ui.rows(), 2, "打字后仍是两行(已填的值没在往返里丢)"); + ok(!ui.text().includes("有 1 条空白账号 ID"), "两行都填了就不再报空白(提示不自相矛盾)"); + + // 空行的既有提示语义保留:加了行还没填时,界面明说「保存时会被忽略」,不是静默丢弃。 + const hinted = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]); + hinted.click("+ 添加账号 ID"); + ok( + hinted.text().includes("有 1 条空白账号 ID:保存时会被忽略"), + "新加的空行有显式提示(保存时会被忽略)" + ); +} + +{ + // 「+ 添加平台」走的是同一条往返(任务 3):点一下必须真的多出一块、块里带一行可填。 + const blank = new Harness([]); + same(blank.rows(), 0, "起点:一个平台都没有"); + blank.click("+ 添加平台"); + same( + blank.draft, + [{ platform: "", accountId: "" }], + "点「+ 添加平台」⇒ 草稿里真的多出一个未选平台的空组(不是「点了没点」)" + ); + same(blank.selects(), 1, "界面上真的多出一块平台(下拉)"); + same(blank.rows(), 1, "这块里带一行可填的账号 ID"); + + blank.pickPlatform(0, "kook"); + same(blank.draft, [{ platform: "kook", accountId: "" }], "在块里选平台会落到草稿(块没在往返里蒸发)"); + blank.click("+ 添加账号 ID"); + same( + blank.draft, + [ + { platform: "kook", accountId: "" }, + { platform: "kook", accountId: "" }, + ], + "选完平台再加一行:两行都挂在 kook 下" + ); + same(blank.rows(), 2, "界面上两行"); + + // 已知边界(**扁平草稿的固有性质**,不是本次缺陷):第二块「未选平台」的组会和第一块并成一块 + // —— `groupAccounts` 按平台聚合,两个空平台同键。行不会丢,只是同块(有下拉可接着选平台)。 + const two = new Harness([]); + two.click("+ 添加平台"); + two.click("+ 添加平台"); + same(two.rows(), 2, "连点两次「+ 添加平台」:两行都在(空平台两块并成一块)"); + same(two.selects(), 1, "空平台两块并成一块下拉"); +} + +{ + // 「删除某平台唯一一行」= 撤掉该平台(平台选择随之消失)。这是**修前就有**的语义,本次不动, + // 但在这里显式钉住:日后若被当成新缺陷、或被顺手改掉,这条会先红。 + const one = new Harness([{ platform: "kook", accountId: "42" }]); + one.click("删除"); + same(one.draft, [], "删掉唯一一行 ⇒ 该平台块整体消失(既有语义)"); + same(one.rows(), 0, "界面上也不再有这一块"); + same(one.selects(), 0, "平台下拉也一并撤掉"); +} + +// ⑤ 收尾:把「手势刚产生的草稿」接到「写盘形态」上。草稿留着空行(上面钉住了),那保存时 +// 就必须有人把它收干净 —— 后端 `Validate()`(internal/user/load.go)对「平台空 / 账号 id 空」 +// 这种半成品条目**直接 400**(主人看到「保存失败」),只有完全空的条目才是跳过。 +{ + const half = new Harness([]); + half.click("+ 添加平台"); + half.type(0, "9616b27a"); // 只填了 id,平台还没来得及选 + same(half.draft, [{ platform: "", accountId: "9616b27a" }], "草稿里这一行留着(编辑期不丢行)"); + same(accountsForSave(half.draft), [], "写盘时收掉半成品条目(不发给后端 ⇒ 不会 400)"); + + const filled = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]); + filled.click("+ 添加账号 ID"); + same( + accountsForSave(filled.draft), + [{ platform: "qq-obv11", accountId: "9616b27a" }], + "写盘时收掉空行:owner.yaml 只拿到填完的条目" + ); + filled.type(1, " aaaa1111 "); // 手滑多打了空格 + same(filled.draft[1].accountId, " aaaa1111 ", "编辑期不改写正在输入的字(逐键 trim 会吃掉空格)"); + same( + accountsForSave(filled.draft), + [ + { platform: "qq-obv11", accountId: "9616b27a" }, + { platform: "qq-obv11", accountId: "aaaa1111" }, + ], + "写盘时去掉两侧空白(带空格的键永远匹配不上 actor 元数据)" + ); +} + console.log(`owner accounts editor test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/ownerAccounts.ts b/frontend/web/src/admin/modules/ownerAccounts.ts index da1b90f..fd9e2a5 100644 --- a/frontend/web/src/admin/modules/ownerAccounts.ts +++ b/frontend/web/src/admin/modules/ownerAccounts.ts @@ -12,7 +12,13 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types * (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。 * 平台改为权威枚举下拉 + 账号 id 可从该平台已配置账号里选,消灭这类静默失效。 * - * 本模块是纯函数(无 DOM、无请求),因此分组/展平/下拉选项/候选都能在 node 下逐条测。 + * 本模块是纯函数(无 DOM、无请求),因此分组/草稿/写盘形态/下拉选项/候选都能在 node 下逐条测。 + * + * **两种扁平形态,别混用**(这是主人 2026-10-07 报的「添加账号 ID 按钮似乎没用」的根因所在): + * ① **草稿**(`draftAccounts`,编辑期):逐行原样保留,空行/未选平台的组都在 —— + * 界面手势产生的中间态**绝不能在往返中蒸发**; + * ② **写盘形态**(`accountsForSave`,保存期):只留平台与账号 id 都非空的条目。 + * 把①错当②用(编辑期就走写盘形态),空行当场消失 ⇒ 点了等于没点,界面上还看不出原因。 */ /** 一个平台分组:界面上的一条。 */ @@ -22,7 +28,7 @@ export interface OwnerAccountGroup { } /** 把扁平数组按平台分组(保持首次出现顺序,同一平台的多条账号收进一组)。 */ -export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] { +export function groupAccounts(accounts: readonly OwnerAccount[]): OwnerAccountGroup[] { const groups: OwnerAccountGroup[] = []; const byPlatform = new Map(); for (const a of accounts) { @@ -38,10 +44,27 @@ export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] { return groups; } -/** 展平成后端契约要的扁平数组。**平台与账号 id 都非空才写**:任一为空都匹配不上任何 - * actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组被忽略, - * 界面上有对应提示,不是静默丢弃。 */ -export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] { +/** 组 → **草稿**(编辑期形态):逐行原样保留 —— 空行、以及「还没选平台」的组都在。 + * + * 为什么必须保行:草稿是**界面手势的载体** —— 每次改动都走「草稿 → 分组 → 改 → 回草稿」, + * 写回若有损(把空行丢掉),刚点出来的「+ 添加账号 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(); @@ -54,6 +77,16 @@ export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] { 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:绝不下拉吃掉它, * 否则一次保存就把主人已经生效的配置删了)。 */ diff --git a/frontend/web/src/lib/types/admin.ts b/frontend/web/src/lib/types/admin.ts index be4908f..95417e3 100644 --- a/frontend/web/src/lib/types/admin.ts +++ b/frontend/web/src/lib/types/admin.ts @@ -357,6 +357,13 @@ export interface OwnerProfile { defaultPersona: string; identityFile: string; notes: string; + /** + * Channel account whitelist. While the panel is being edited this array is a **draft**: a row may + * legitimately be blank (`accountId: ""`, or no platform picked yet) because every UI gesture + * round-trips through it — dropping blank rows here makes a freshly added row disappear, i.e. the + * button looks dead. Only the save path narrows it (`accountsForSave`), so owner.yaml and the PUT + * contract keep carrying entries with both `platform` and `accountId` set. + */ accounts: OwnerAccount[]; } export interface OwnerAccount {