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:
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)));
|
||||
|
||||
@@ -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:绝不下拉吃掉它,
|
||||
* 否则一次保存就把主人已经生效的配置删了)。 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user