根因:`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」。
131 lines
6.3 KiB
TypeScript
131 lines
6.3 KiB
TypeScript
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<string, OwnerAccountGroup>();
|
||
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<string>();
|
||
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);
|
||
}
|