Archived
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:
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* **多宿主收值 + 嵌套异地址排除**(2026-08-31,W05 芯片卡翻面逼出的平台扩面)。
|
||||
*
|
||||
* 版面要求「参数值印在每张芯片卡的**背面**、就地可改」:参数表的行天然分居两张卡,
|
||||
* 一个宿主装不下 —— `hostsOf` 把同一地址的全部宿主都数进来,每个宿主各自归行。
|
||||
*
|
||||
* 更要紧的是**嵌套异地址排除**:卡背的参数宿主(`data-edit="parts.parameter"`)住在
|
||||
* 芯片卡宿主(`data-edit="parts.profile"`)的行里 —— 外层收值若把里层宿主的格子也
|
||||
* 收走,参数值就会被当成主数据的列并进主数据的行(`view-container-collect-nested.test.ts`
|
||||
* 钉过的静默丢数据,换一副面孔回来)。规则:**格子只归它最近的 `data-edit` 祖先**。
|
||||
* 嵌套的 `data-rows` 不带 `data-edit` 时行为原样(照旧并行 —— 那两条钉测不动)。
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collect, setOpenIn } from '../src/ViewContainer.tsx';
|
||||
|
||||
/** 两张芯片卡:正面是主数据格(profile),背面各住一个参数宿主(parameter) */
|
||||
const FLIP_CARDS =
|
||||
'<article data-edit="parts.profile">' +
|
||||
'<section class="dossier">' +
|
||||
'<div class="front"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="brand_name" value="TI"/></div>' +
|
||||
'<div class="back"><section class="pback" data-edit="parts.parameter">' +
|
||||
'<div class="ptile"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_name" value="输出电压"/><input data-col="param_value" value="5.0"/></div>' +
|
||||
'<div class="ptile"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_name" value="输出电流"/><input data-col="param_value" value="3"/></div>' +
|
||||
'</section></div>' +
|
||||
'</section>' +
|
||||
'<section class="dossier">' +
|
||||
'<div class="front"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="brand_name" value="XLSEMI"/></div>' +
|
||||
'<div class="back"><section class="pback" data-edit="parts.parameter">' +
|
||||
'<div class="ptile"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="param_name" value="输出电压"/><input data-col="param_value" value="5.0"/></div>' +
|
||||
'<div class="ptile"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="param_name" value="输出电流"/><input data-col="param_value" value="2.5"/></div>' +
|
||||
'</section></div>' +
|
||||
'</section>' +
|
||||
'</article>' +
|
||||
'<div class="jc-editgate" data-edit-for="parts.parameter"></div>';
|
||||
|
||||
function docOf(html: string): Document {
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = html;
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe('多宿主 collect():同一地址的宿主都数进来,格子只归最近的 data-edit 祖先', () => {
|
||||
it('parts.parameter 分居两张卡背的两个宿主 —— 4 个瓷贴收到 4 行,主键各自带对', () => {
|
||||
const got = collect(docOf(FLIP_CARDS), 'parts.parameter');
|
||||
expect(got).toHaveLength(4);
|
||||
const xl = got.filter((r) => r['internal_pn'] === 'XL2596S-5.0');
|
||||
expect(xl).toHaveLength(2);
|
||||
expect(new Map(xl.map((r) => [r['param_name'], r['param_value']])).get('输出电流')).toBe('2.5');
|
||||
});
|
||||
|
||||
it('parts.profile 收值**不含**卡背参数宿主的格子 —— 两行主数据,一列参数都不许混进来', () => {
|
||||
const got = collect(docOf(FLIP_CARDS), 'parts.profile');
|
||||
expect(got).toHaveLength(2);
|
||||
for (const row of got) {
|
||||
expect(row['brand_name']).toBeDefined();
|
||||
/* 静默丢数据的那副面孔:param_value 若出现在主数据行里,就是里层宿主被外层吞了 */
|
||||
expect(row['param_value']).toBeUndefined();
|
||||
expect(row['param_name']).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('setOpenIn:一把闸开合同一地址的**全部**宿主(非表格容器,.on 落在宿主自身)', () => {
|
||||
const doc = docOf(FLIP_CARDS);
|
||||
setOpenIn(doc, 'parts.parameter', true);
|
||||
const hosts = Array.from(doc.querySelectorAll('.pback'));
|
||||
expect(hosts).toHaveLength(2);
|
||||
expect(hosts.every((h) => h.classList.contains('on'))).toBe(true);
|
||||
expect(doc.querySelector('[data-edit-for="parts.parameter"]')?.classList.contains('on')).toBe(true);
|
||||
/* 外层 profile 宿主不许被顺手点亮 —— 两把闸各管各的 */
|
||||
expect(doc.querySelector('[data-edit="parts.profile"]')?.classList.contains('on')).toBe(false);
|
||||
setOpenIn(doc, 'parts.parameter', false);
|
||||
expect(hosts.every((h) => !h.classList.contains('on'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user