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」。
This commit is contained in:
AskaEth committed 2026-10-13 20:00:43 +08:00
1 parent b01e2ddb1a
commit 625ae33c1e
5 files changed
+369 -19

No files matched your search

@@ -3,7 +3,7 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types
import { Button } from "../../components/primitives";
import {
accountCandidates,
flattenAccounts,
draftAccounts,
groupAccounts,
platformIsConfigured,
platformOptions,
@@ -74,8 +74,9 @@ export function OwnerAccountsView({
onChange,
}: OwnerAccountsViewProps): React.ReactNode {
// 每次改动都「分组 → 改 → 回草稿」:草稿里始终是扁平形态,界面上的分组只是它的投影,
// 两者不会各说各话。
const emit = (next: OwnerAccountGroup[]): void => onChange(flattenAccounts(next));
// 两者不会各说各话。写回必须**无损**(`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)));
+4 -1
View File
@@ -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<string, "accent" | "ok" | "neutral" | "warn"> = {
@@ -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);
@@ -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<string, unknown>;
}
/** 节点里的可见文字(按钮标签、提示语靠它找)。 */
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<string, unknown> };
const props = el.props ?? {};
if (typeof el.type === "function") {
walk((el.type as (p: Record<string, unknown>) => 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<T>(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`);
@@ -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<string, OwnerAccountGroup>();
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:绝不下拉吃掉它,
* 否则一次保存就把主人已经生效的配置删了)。 */
+7
View File
@@ -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 {