feat(w05): 数据工单去表格化 —— 翻面芯片卡(背面参数就地改)+ 基准瓷贴卡;平台多宿主收值

2026-08-31 用户第三轮:「界面上东西太多,做成几个区域;类型与核心参数字段做成和芯片卡
差不多的卡片;芯片卡与它的核心参数值合并 —— 点击翻转,背面写核心参数,悬浮要看得出能翻;
这个界面都不再需要表格来展示数据了」。

视图(主画面零表格,表格只活在新建类型抽屉里 —— 表单要加行,加行机制认 tr):
· 比对基准卡:类型丸 + 描述 + 字段瓷贴(名/硬软徽/单位/说明),与芯片卡同语言;
· 芯片卡翻面:正面主数据、背面本颗芯片的核心参数瓷贴(值必填红 / 说明建议橘)。
  翻转纯 CSS(checkbox + :has,零脚本;checkbox 无 data-col,collect 不认它),
  悬浮出「点击翻面 · 核心参数」提示丸 + 侧倾预告;主数据编辑锁正面、参数编辑自动
  翻背面(reduced-motion 下瞬时切换);说明四条收进折叠。

平台(ui-kit ViewContainer,多宿主收值):「参数值印在每张卡背、就地可改」= 同一张表
的行分居两张卡 —— 一个地址一个宿主装不下。hostsOf(querySelectorAll)+ 收值/开合/
镜像/快照全部按宿主清单走;**格子只归最近的 data-edit 祖先**(不然卡背参数会被主数据
的闸收走并进主数据行 —— collect-nested 钉过的静默丢数据换脸回来)。嵌套 data-rows
不带 data-edit 时行为原样(两条旧钉测不动);addRow 仍单宿主(会加行的表都是单宿主)。

途中踩了三脚都逮回:CSS 注释里写了「data-col=」原文把 ②③ 的「全只读」字面断言踩翻
(选择器与注释都不许出现);.flip-cue i 特异度压过 .cue-back{display:none} 两枚提示
丸同时冒出;host 计数断言错把渲染后的数当模版源码的数。

测:ui-kit multihost 3 条(排除规则变异验证)+ 包内 110(翻面/无表格/瓷贴新锚)+
2528 全仓四门绿;2.9.0 装库 SQL 核过(flip/back_host/ftiles/cue/主画面表数=2);
浏览器实测:点卡真翻(背面 5 瓷贴)、参数编辑自动翻背双卡成表单、LM 全值回显。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-31 15:36:39 +08:00
co-authored by Claude Fable 5
parent 794af58902
commit 66be5bab6e
6 changed files with 308 additions and 124 deletions
+61 -25
View File
@@ -161,9 +161,33 @@ function replaceInPlace(doc: Document, html: string): void {
}
}
/** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找 */
/** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找(多宿主时取文档序第一个,老调用方语义不变) */
export function bodyOf(doc: Document, address: string): HTMLElement | null {
return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
return hostsOf(doc, address)[0] ?? null;
}
/**
* **同一个地址的全部宿主**(2026-08-31,W05 芯片卡翻面逼出的多宿主收值)。
*
* 从前一个地址恰一个宿主(`bodyOf` 单 querySelector)——「参数值印在每张芯片卡的背面、
* 就地可改」这种版面下,同一张表(`parts.parameter`)的行天然分居两张卡,一个宿主装不下。
* 现在收值 / 开合 / 镜像都按**宿主清单**走:每个宿主各自归行(行仍是宿主的直接子元素),
* 清单合起来就是这张表。加行(`addRow`)仍只认第一个宿主 —— 会加行的表(类型字典 /
* 字段清单)从来都是单宿主,多宿主的表行由配方铺、人不加行。
*/
export function hostsOf(doc: Document, address: string): HTMLElement[] {
return Array.from(doc.querySelectorAll<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`));
}
/**
* 这个格子归这个宿主管吗 —— **认最近的 `data-edit` 祖先**。
* 宿主可以嵌套(芯片卡是 `parts.profile` 的行,卡背里又住着 `parts.parameter` 的宿主):
* 外层收值时不认里层宿主的格子,不然参数值会被当成主数据的列并进主数据的行
* (`view-container-collect-nested.test.ts` 钉过的那种静默丢数据,换一副面孔回来)。
* 嵌套的 `data-rows` **不带** `data-edit` 时行为原样(照旧并行 —— 那两条钉测不动)。
*/
function ownsCell(host: HTMLElement, cell: Element): boolean {
return cell.closest('[data-edit]') === host;
}
/**
@@ -186,11 +210,11 @@ type EditCell = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内。下拉与多行文本(textarea)与文本输入同源同待 */
function cellsOf(doc: Document, address: string): EditCell[] {
const host = bodyOf(doc, address);
if (host === null) return [];
return Array.from(
host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]'),
).filter((i) => i.closest('.jc-row-gone') === null);
return hostsOf(doc, address).flatMap((host) =>
Array.from(host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]')).filter(
(i) => ownsCell(host, i) && i.closest('.jc-row-gone') === null,
),
);
}
/**
@@ -212,10 +236,12 @@ export function unanswered(cell: EditCell): boolean {
* 认行元素则怎么增删都对得上:同一个 `<tr>` 里的格子就是同一行。
*/
export function collect(doc: Document, address: string): Array<Record<string, string>> {
const host = bodyOf(doc, address);
if (host === null) return [];
const byRow = new Map<Element, Record<string, string>>();
for (const cell of cellsOf(doc, address)) {
for (const host of hostsOf(doc, address)) {
for (const cell of Array.from(
host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]'),
)) {
if (!ownsCell(host, cell) || cell.closest('.jc-row-gone') !== null) continue;
const col = cell.dataset['col'];
const row = rowOf(host, cell);
if (col === undefined || row == null) continue;
@@ -230,6 +256,7 @@ export function collect(doc: Document, address: string): Array<Record<string, st
one[col] = cell.value;
byRow.set(row, one);
}
}
return [...byRow.values()];
}
@@ -265,16 +292,18 @@ function mirrorTwins(doc: Document, address: string): void {
/** 人删掉的那几行 —— 只交主键那几格够认行的值(整行都交也无妨,服务端只看主键) */
function collectGone(doc: Document, address: string): Array<Record<string, string>> {
const host = bodyOf(doc, address);
if (host === null) return [];
return Array.from(host.querySelectorAll<HTMLElement>('.jc-row-gone')).map((row) => {
return hostsOf(doc, address).flatMap((host) =>
Array.from(host.querySelectorAll<HTMLElement>('.jc-row-gone'))
.filter((row) => row.closest('[data-edit]') === host)
.map((row) => {
const one: Record<string, string> = {};
for (const cell of Array.from(row.querySelectorAll<HTMLInputElement>('input[data-col]'))) {
const col = cell.dataset['col'];
if (col !== undefined) one[col] = cell.value;
}
return one;
});
}),
);
}
/**
@@ -322,6 +351,16 @@ export function addRow(doc: Document, address: string): boolean {
return true;
}
/**
* 那块内容与它的闸一起开合 —— **同一地址的全部宿主一起**(多宿主时一把闸管一整张表)。
* `.on` 落在哪:行容器是表格的落在 `<table>`(老件,样式按 `table.on` 写);
* 不是表格的落在 `data-edit` 元素自身(2026-08-02 A2 对决卡立的规矩)。
*/
export function setOpenIn(doc: Document, address: string, on: boolean): void {
doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on);
for (const host of hostsOf(doc, address)) (host.closest('table') ?? host).classList.toggle('on', on);
}
/**
* `data-file="域.对象/主键值"` → 取文件要的那两样。
*
@@ -409,7 +448,7 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
const fileSink = useRef(onOpenFile);
fileSink.current = onOpenFile;
/** 进编辑态那一刻的样子 —— 「取消」要还原成它(值 + 那时有几行) */
const snapshot = useRef(new Map<string, { values: string[]; rows: number }>());
const snapshot = useRef(new Map<string, { values: string[]; rows: number[] }>());
/*
* 挂事件 + 开关闸 —— 都在**父文档这一侧**做,iframe 里没有脚本。
@@ -462,11 +501,7 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
* 容器是 `<section>`,从前这里写死 `closest('table')`,非表格容器永远拿不到
* `.on`,编辑态整个打不开)。
*/
const setOpen = (address: string, on: boolean): void => {
doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on);
const host = doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
(host?.closest('table') ?? host)?.classList.toggle('on', on);
};
const setOpen = (address: string, on: boolean): void => setOpenIn(doc, address, on);
const open = hit.dataset['editOpen'];
const cancel = hit.dataset['editCancel'];
@@ -479,7 +514,8 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
// (radio 的 value 是恒定的选项值,快照与还原认的是 checked)
snapshot.current.set(open, {
values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? ((c as HTMLInputElement).checked ? '1' : '') : c.value)),
rows: Array.from(bodyOf(doc, open)?.children ?? []).length,
/* 行数逐宿主记 —— 多宿主时「取消」要把每个宿主各自加出来的行裁回去 */
rows: hostsOf(doc, open).map((h) => h.children.length),
});
setOpen(open, true);
return;
@@ -488,11 +524,11 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
const was = snapshot.current.get(cancel);
if (was !== undefined) {
// 加出来的那几行整个撤掉,删掉的标记摘掉,再把值放回去 —— 三样一起才叫还原
const host = bodyOf(doc, cancel);
if (host !== null) {
while (host.children.length > was.rows) host.lastElementChild?.remove();
hostsOf(doc, cancel).forEach((host, i) => {
const keep = was.rows[i];
if (keep !== undefined) while (host.children.length > keep) host.lastElementChild?.remove();
for (const row of Array.from(host.querySelectorAll('.jc-row-gone'))) row.classList.remove('jc-row-gone');
}
});
cellsOf(doc, cancel).forEach((c, i) => {
const saved = was.values[i];
if (saved === undefined) return;