From 66be5bab6e6ced68d72dfcd3151c96a3ee058baa Mon Sep 17 00:00:00 2001 From: xcs Date: Mon, 31 Aug 2026 15:36:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(w05):=20=E6=95=B0=E6=8D=AE=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E5=8E=BB=E8=A1=A8=E6=A0=BC=E5=8C=96=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=BF=BB=E9=9D=A2=E8=8A=AF=E7=89=87=E5=8D=A1(?= =?UTF-8?q?=E8=83=8C=E9=9D=A2=E5=8F=82=E6=95=B0=E5=B0=B1=E5=9C=B0=E6=94=B9?= =?UTF-8?q?)+=20=E5=9F=BA=E5=87=86=E7=93=B7=E8=B4=B4=E5=8D=A1;=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=A4=9A=E5=AE=BF=E4=B8=BB=E6=94=B6=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/activity.test.ts | 48 ++++-- .../src/manifest.ts | 2 +- .../src/views/report.ts | 139 ++++++++---------- .../src/views/style.ts | 82 +++++++++-- .../view-container-multihost.test.ts | 75 ++++++++++ packages/ui-kit/src/ViewContainer.tsx | 86 +++++++---- 6 files changed, 308 insertions(+), 124 deletions(-) create mode 100644 packages/ui-kit/__tests__/view-container-multihost.test.ts diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index 952b0ba..9de3950 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -570,7 +570,7 @@ describe('件① 评估输入补全', () => { expect(html).toContain('data-rows="mine:parts.profile" data-edit="parts.profile"'); expect(html).not.toContain('data-rows="mine:sub_eval.part" data-edit="parts.profile"'); const nested = [...html.matchAll(/((?:(?!<\/span>)[\s\S])*?)<\/span>/g)]; - expect(nested.length).toBeGreaterThanOrEqual(2); + expect(nested.length).toBeGreaterThanOrEqual(1); for (const m of nested) expect(m[1] ?? '').not.toContain('data-col'); }); @@ -597,20 +597,50 @@ describe('件① 评估输入补全', () => { /* 全只读:选类型在档案卡,建类型在抽屉 —— 基准卡上一个编辑面都不许有 */ expect(basis).not.toContain('data-col'); expect(basis).not.toContain('data-edit'); - /* 抽屉存在,且两张编辑表都住在里面 */ + /* 基准卡不再有表格:类型 + 字段全是瓷贴 */ + expect(basis).not.toContain(''); - const labEnd = html.indexOf(''); + const labEnd = html.indexOf('', labAt); expect(labAt).toBeGreaterThanOrEqual(0); expect(html.indexOf('')).toBeGreaterThan(labAt); expect(html.indexOf('
')).toBeGreaterThan(labAt); expect(labEnd).toBeGreaterThan(html.indexOf('
')); }); - it('三级占位:必填红 / 建议橘 —— 空格子直说要不要填(档案卡与参数值表两处)', () => { + /* + * 2026-08-31 用户第三轮:「芯片卡片需要和该芯片的核心参数值合并…点击后卡片翻转,背面写 + * 核心参数,悬浮要看得出可以翻;这个界面都不再需要表格来展示数据」。 + * 背面就地可改 = 两张卡背各住一个 parts.parameter 宿主 —— 多宿主收值是同日的平台扩面 + * (ViewContainer hostsOf + 格子只归最近的 data-edit 祖先,ui-kit multihost 测试钉着)。 + */ + it('翻面卡:背面住着参数宿主、按料过滤(渲染时随行复制成每卡一个 —— 多宿主收值由 ui-kit multihost 测试钉);翻转是无 data-col 的 checkbox;编辑态锁面', () => { + expect((html.match(/data-edit="parts\.parameter"/g) ?? []).length).toBe(1); + expect(html).toContain('data-rows="mine:parts.parameter" data-by="internal_pn=internal_pn" data-edit="parts.parameter"'); + const flip = /]*>/.exec(html)?.[0] ?? ''; + expect(flip).not.toBe(''); + expect(flip).toContain('type="checkbox"'); + expect(flip).not.toContain('data-col'); + expect(html).toContain('class="face front"'); + expect(html).toContain('class="face back"'); + /* 编辑锁面:主数据编辑停正面,参数编辑翻背面 —— 判断全在样式表 */ + expect(html).toContain('.dossiers.on .dossier .flip-in{transform:rotateY(0deg)}'); + expect(html).toContain('.dossier:has(.pback.on) .flip-in{transform:rotateY(180deg)}'); + /* 悬浮提示丸:看得出点击可翻 */ + expect(html).toContain('点击翻面'); + }); + + it('主画面无表格:说明折叠之前的正文一张
{ + const beforeDrawer = html.slice(0, html.indexOf('
')); + expect(beforeDrawer).not.toContain(' { expect(html).toContain('.fcell.req .jc-vcell:empty::after{content:"必填"'); expect(html).toContain('.fcell.sug .jc-vcell:empty::after{content:"建议"'); - expect(html).toContain('td.pv .jc-vcell:empty::after{content:"必填"'); - expect(html).toContain('td.sg .jc-vcell:empty::after{content:"建议"'); + expect(html).toContain('.ptile .pv .jc-vcell:empty::after{content:"必填"'); + expect(html).toContain('.ptile .sg .jc-vcell:empty::after{content:"建议"'); }); it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」(块①的 chip_type_id 与块②b 的 type_id 都是)', () => { @@ -769,14 +799,14 @@ describe('件① 评估输入补全', () => { * 只读态与编辑态下「#」列逐行都是空白。钉死 `.jc-tbl-wrap` 现在也接了这五条规则, * 且这张件确实只用 `.jc-tbl-wrap`(没有表在用没打这条补丁就会数不出来的 `.tbl-wrap`)。 */ - it('四张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => { + it('抽屉里两张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => { expect(html).toMatch(/\.jc-tbl-wrap\s+table\s*,\s*\.tbl-wrap\s+table\s*\{\s*counter-reset:\s*rn;?\s*\}/); expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s*\{\s*counter-increment:\s*rn;?\s*\}/); expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*\{\s*content:\s*counter\(rn\);?\s*\}/); - // 四张:比对基准卡的字段表 + 抽屉里两张编辑表 + 参数值表(2026-08-31 类型/字段并成基准卡后的账) + // 只剩抽屉里两张(2026-08-31 第三轮:基准卡瓷贴化、参数值上卡背 —— 主画面零表格) expect(html).not.toContain('class="tbl-wrap"'); const jcTblWrapCount = html.match(/class="jc-tbl-wrap"/g)?.length ?? 0; - expect(jcTblWrapCount).toBe(4); + expect(jcTblWrapCount).toBe(2); }); }); diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index cf34b86..b98fa94 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -54,7 +54,7 @@ export const manifest: ActivityManifest = { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', /* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */ - packageVersion: '2.8.1', + packageVersion: '2.9.0', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', diff --git a/activities/part-substitution-assessment/src/views/report.ts b/activities/part-substitution-assessment/src/views/report.ts index dacf5e9..c7fb846 100644 --- a/activities/part-substitution-assessment/src/views/report.ts +++ b/activities/part-substitution-assessment/src/views/report.ts @@ -178,29 +178,51 @@ ${badgeCss('.rl', [ * `option[selected]`(行作用域求值,不受外层回落污染):这一行真选上了才摆名, * 没选摆「待选择」胶囊 —— 判断全在样式表(与被退回的换版面那版同一招,机制经过实测)。 * - * 人不加行(要评估哪两颗是启动参数定死的),没有「+加一行」;整卡一个宿主一道闸 - * (`bodyOf` 是单 querySelector),`.on` 落在 `data-edit` 元素自身(非表格容器)。 + * 人不加行(要评估哪两颗是启动参数定死的),没有「+加一行」。 + * + * **翻面(2026-08-31 用户定)**:每张卡正面主数据、背面这颗芯片的核心参数值 —— + * 点击卡面翻转(纯 CSS:`.flip-tg` checkbox + :has,零脚本;checkbox 不带 `data-col`, + * collect 不认它),悬浮出「点击翻面 · 核心参数」提示丸 + 轻微侧倾预告可翻。 + * **背面的参数就地可改**:两张卡背各住一个 `data-edit="parts.parameter"` 宿主 —— + * 多宿主收值是 2026-08-31 平台扩面(ViewContainer `hostsOf` + 「格子只归最近的 + * data-edit 祖先」,packages/ui-kit/__tests__/view-container-multihost.test.ts 钉着); + * 开哪面的编辑,卡就停在哪面(主数据编辑锁正面、参数编辑自动翻背面,CSS 兜底)。 */ const DOSSIERS = `
-
- ${CHIP} - - - ${cell('internal_pn', 'mono')} - -
-
- 芯片类型 * - - ${optionSelect('chip_type_id', 'parts.chip_type', 'type_id', 'type_name', true)} + + 点击翻面 · 核心参数点击翻回 · 主数据 +
+
+
+ ${CHIP} + + + ${cell('internal_pn', 'mono')} + +
+
+ 芯片类型 * + + ${optionSelect('chip_type_id', 'parts.chip_type', 'type_id', 'type_name', true)} +
+
+ + + + + + +
+
+
+
核心参数值
+
+ ${cell('param_name')} + ${cell('param_value', 'mono')} + ${cell('param_note')} + ${cell('internal_pn', 'mono')} +
-
- - - - - -
`; @@ -220,25 +242,16 @@ const DOSSIERS = `
- 比对基准 - + ${CHIP} + + 比对基准 · 芯片类型 + +
-
- - - - - - - - - - - - - - -
#参数名参数说明约束(硬/软)参考单位
+
+ +

+
`; /** @@ -338,35 +351,6 @@ const TYPE_PARAM_TABLE = `
- - - - - - - - - - - - - - - - -
#所属料号参数名值单位说明
${cell('internal_pn', 'mono')}${cell('param_name')}${cell('param_value', 'mono')}${ro('param_unit')}${cell('param_note')}
`; - export const INPUT_SUPPLEMENT = doc({ accent: ACCENT_DATA, alias: ALIAS_INPUT, @@ -378,19 +362,22 @@ export const INPUT_SUPPLEMENT = doc({ footTag: '工单类型:数据', body: gateBar() + - zone('做什么', '核对两颗料在不在库、归没归同一个芯片类型、参数值齐不齐;缺什么就在下面补齐', ZIC_TASK) + + `
【做什么】核对两颗料在不在库、归没归同一个芯片类型、参数值齐不齐 —— 展开看说明与纪律` + note([ '模板定位:本单是「数据工单」——目标台账 = 芯片主数据(P-08 主记录 + 标准化参数子表),核心参数字段清单挂在芯片类型字典上。', '触发原因:下面电路板上两张档案卡就是本次要比对的原 PN 与候选 PN —— 至少有一颗不在库、两颗归的类型不一致、或必填格还空着,顶部那条红带才会出现。', '已绑定:两颗料的内部料号已按启动参数铺上档案卡,不必你重填料号本身,只需核对 / 补齐其余各格;在库的料现值已回填,只是查漏补缺。', - '纪律:两颗料必须归到同一个芯片类型下才谈得上逐项比对;类型下拉里没有合适的就在【芯片类型】区新建一个,并把它的核心参数字段一并定下来;查不到的参数值留空(会被红带点名),不要编一个值顶替。', + '纪律:两颗料必须归到同一个芯片类型下才谈得上逐项比对;类型下拉里没有合适的就在下面的抽屉里新建一个,并把它的核心参数字段一并定下来;查不到的参数值留空(会被红带点名),不要编一个值顶替。', ]) + - zone('要评估什么', '两颗料各一张档案卡,直接在卡上核对 / 补齐(人不加行,*为必填)', ZIC_TABLE) + - editGate('parts.profile', '两张档案卡上的主数据核对 / 补齐,点「编辑」开卡') + - gapbar('parts_profile', '必填') + - DOSSIERS + - zone('比对基准 · 芯片类型与核心参数字段', '两颗料归到哪个类型,就按哪个类型的字段逐项比 —— 档案卡上改选,这张卡当场跟着换', ZIC_TABLE) + + `
` + + zone('比对基准', '芯片类型 + 它的核心参数字段 —— 两颗料归到哪个类型,就按这些字段逐项比;档案卡上改选,这张卡当场跟着换', ZIC_TABLE) + BASIS_CARD + + zone('芯片档案', '一颗芯片一张卡:正面主数据、背面核心参数值 —— 点卡翻面看另一面', ZIC_TABLE) + + editGate('parts.profile', '正面主数据核对 / 补齐 —— 开编辑后卡停在正面') + + editGate('parts.parameter', '背面参数值核对 / 补齐 —— 开编辑后两张卡自动翻到背面') + + gapbar('parts_profile', '必填') + + gapbar('parts_parameter', '必填') + + DOSSIERS + gapbar('parts_chip_type', '必填') + gapbar('parts_type_param', '必填') + `
类型库里没有合适的?展开新建一个类型,并把它的核心参数字段定下来 —— 定完回到上面的档案卡挑它
` + @@ -402,10 +389,6 @@ export const INPUT_SUPPLEMENT = doc({ TYPE_PARAM_TABLE + addrow('parts.type_param', '+ 加一行(补一个核心参数字段)', '所属类型 / 参数名 / 参数说明 / 约束类型必须写;参考单位选填') + `
` + - zone('参数值', '逐条核心参数的实测 / 规格值(一字段一行,*为必填列;单位由字典定,这里不改)', ZIC_TABLE) + - editGate('parts.parameter', '逐条参数值核对 / 补齐,点「编辑」开表') + - gapbar('parts_parameter', '必填') + - PARAMETER_TABLE + flow('提交 / 确认:本工单只承载内容;确认请在对话区的【执行卡】中进行。确认后活动自动从「等待数据」唤醒。'), }); diff --git a/activities/part-substitution-assessment/src/views/style.ts b/activities/part-substitution-assessment/src/views/style.ts index 867de90..670c701 100644 --- a/activities/part-substitution-assessment/src/views/style.ts +++ b/activities/part-substitution-assessment/src/views/style.ts @@ -288,28 +288,84 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va wrapper 不为 :empty,上一条够不着 —— 认裸 class 补同一枚胶囊(单层 :has) */ .fcell.req .jc-vcell:has(> [class="stx"])::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px} .fcell.sug .jc-vcell:empty::after{content:"建议";font-size:10.5px;font-weight:800;color:var(--amber);background:var(--amber-soft);border-radius:999px;padding:0 9px} -/* 档案卡的编辑态(非表格容器,.on 落在 data-edit 元素自身) */ -.dossiers.on .jc-vcell{display:none} -.dossiers.on .jc-vcell.ro{display:block} -.dossiers.on .jc-cell{display:block} +/* 档案卡的编辑态 —— **只点亮正面**(.on 落在 data-edit 元素自身;卡背住着另一地址的宿主, + 不圈 .front 的话参数输入会被主数据的闸顺手点亮) */ +.dossiers.on .front .jc-vcell{display:none} +.dossiers.on .front .jc-vcell.ro{display:block} +.dossiers.on .front .jc-cell{display:block} +.dossiers.on .front .jc-vcell:empty::after{content:none} .dossiers.on .dz-role{display:flex} -/* 参数值表:所属料号列的角色徽 + 值/说明列的三级占位 */ -.pkrole{margin-bottom:2px} -.jc-tbl-wrap td.pv .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px} -.jc-tbl-wrap td.sg .jc-vcell:empty::after{content:"建议";font-size:10.5px;font-weight:800;color:var(--amber);background:var(--amber-soft);border-radius:999px;padding:0 9px} + +/* ── 翻面(纯 CSS:checkbox + :has,零脚本;reduced-motion 下瞬时切换)── */ +.dossier{position:relative;perspective:1200px;padding:0;background:none;border:0;box-shadow:none} +.dossier:hover{transform:none;box-shadow:none} +.flip-in{display:grid;transform-style:preserve-3d;transition:transform .55s cubic-bezier(.3,.7,.3,1)} +.face{grid-area:1 / 1;display:flex;flex-direction:column;gap:11px;background:#fff;border:1px solid var(--line-soft);border-radius:16px;padding:15px 16px 14px;box-shadow:0 8px 22px rgba(18,38,63,.09);backface-visibility:hidden;min-width:0} +.face.back{transform:rotateY(180deg)} +.flip-tg{position:absolute;opacity:0;pointer-events:none} +.flip-hit{position:absolute;inset:0;z-index:3;cursor:pointer} +.flip-cue{position:absolute;z-index:4;bottom:10px;right:10px;pointer-events:none;opacity:.6;transition:opacity .16s,transform .16s} +.flip-cue i{font-style:normal;display:inline-block;font-size:10.5px;font-weight:800;color:var(--acc-deep);background:var(--acc-soft);border:1px solid var(--acc-line);border-radius:999px;padding:2px 10px} +.dossier:hover .flip-cue{opacity:1;transform:translateY(-2px)} +/* 两句提示按面切换 —— 选择器都写到 .flip-cue i 之下的同层级,别被基座那条特异度压回来(踩过) */ +.flip-cue .cue-back{display:none} +/* 翻转三态,后写的赢:人点的 < 主数据编辑锁正面 < 参数编辑翻背面 */ +.dossier:has(.flip-tg:checked) .flip-in{transform:rotateY(180deg)} +.dossier:has(.flip-tg:checked) .flip-cue .cue-front{display:none} +.dossier:has(.flip-tg:checked) .flip-cue .cue-back{display:inline-block} +.dossiers.on .dossier .flip-in{transform:rotateY(0deg)} +.dossier:has(.pback.on) .flip-in{transform:rotateY(180deg)} +/* 编辑中不许翻(哪面在编辑锁哪面),盖满卡面的点击层与提示丸一起收 */ +.dossiers.on .flip-hit,.dossier:has(.pback.on) .flip-hit{display:none} +.dossiers.on .flip-cue,.dossier:has(.pback.on) .flip-cue{display:none} +/* 悬浮侧倾:预告「这张卡点了会翻」—— 只在读态、只在真有悬浮的设备、且不嫌动画的人那里 */ +@media (hover:hover) and (prefers-reduced-motion: no-preference){ + .dossiers:not(.on) .dossier:hover:not(:has(.flip-tg:checked)):not(:has(.pback.on)) .flip-in{transform:rotateY(-12deg)} + .dossiers:not(.on) .dossier:hover:has(.flip-tg:checked):not(:has(.pback.on)) .flip-in{transform:rotateY(168deg)} +} + +/* ── 卡背:参数瓷贴 ── */ +.pb-hd{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap} +.pb-hd b{font-size:14px;font-weight:800;color:var(--ink)} +.pb-lb{font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--ink-4)} +.pback{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.ptile{display:flex;flex-direction:column;gap:2px;border:1px solid var(--line-soft);border-radius:10px;background:var(--soft);padding:8px 10px;min-width:0} +.pt-hd{display:flex;align-items:baseline;gap:6px;min-width:0} +.pt-hd .jc-vcell{font-size:11px;font-weight:800;color:var(--ink-3)} +.pt-unit{font-style:normal;font-size:10px;color:var(--ink-4);font-family:var(--mono)} +.pt-val .jc-vcell{font-size:14px;font-weight:800;color:var(--ink)} +.pt-note .jc-vcell{font-size:10.5px;color:var(--ink-3)} +/* 三级占位:值必填红 / 说明建议橘 */ +.ptile .pv .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px} +.ptile .sg .jc-vcell:empty::after{content:"建议";font-size:10px;font-weight:800;color:var(--amber);background:var(--amber-soft);border-radius:999px;padding:0 8px} +/* 参数编辑态:值与说明换输入,参数名照旧当标签摆着;两枚主键孪生件藏死(钥匙不是编辑面)。 + 藏钥匙用包装类(.pt-name / .rowkey),不用「data-col 属性选择器」—— 那段选择器文本会原样 + 出现在共享样式表里,把「落账确认全只读」那类字面断言踩翻(2026-08-31 踩过,连注释也不许写)。 */ +.pback.on .pv > .jc-vcell,.pback.on .sg > .jc-vcell{display:none} +.pback.on .pv > .jc-cell,.pback.on .sg > .jc-cell{display:block} +.pback .pt-name .jc-cell{display:none} +.pt-name{display:inline-flex;min-width:0} /* ================= 比对基准卡(类型 + 字段合一,只读、跟手)================= */ .basis{display:block;margin:0 0 10px} .basis:empty{border:1.5px dashed var(--line);border-radius:12px;padding:16px;display:block} .basis:empty::after{content:"还没定比对基准 —— 先在上面任一张档案卡上挑一个芯片类型,或展开下面的抽屉新建一个。";font-size:12.5px;color:var(--ink-3)} .basis-in{display:flex;flex-direction:column;gap:10px;border:1px solid var(--acc-line);border-radius:12px;background:var(--acc-wash);padding:12px 14px 14px} -.basis-hd{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap} +.basis-hd{display:flex;align-items:center;gap:12px;flex-wrap:wrap} .basis-lb{flex:none;font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--acc-deep)} .basis-chip b{display:inline-block;font-size:13px;font-weight:800;color:#fff;background:var(--acc);border-radius:999px;padding:2px 14px;margin-right:8px} .basis-desc{font-style:normal;font-size:11.5px;color:var(--ink-3)} .basis-hd:has(.basis-chip:empty)::after{content:"待选择";font-size:11px;font-weight:700;color:var(--amber);background:var(--amber-soft);border:1px dashed #eeda9e;border-radius:999px;padding:1px 10px} -.basis-in:has(tbody:empty) .jc-tbl-wrap{display:none} -.basis-in .jc-tbl-wrap{background:#fff} +.basis-hd .csvg{width:56px;flex:none} +.basis-id{display:flex;flex-direction:column;gap:5px;min-width:0;flex:1 1 auto} +.basis-in:has(.ftiles:empty) .ftiles{display:none} +.ftiles{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.ftile{display:flex;flex-direction:column;gap:3px;border:1px solid var(--line-soft);border-radius:10px;background:#fff;padding:9px 11px;min-width:0} +.ft-hd{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap} +.ft-hd b{font-size:12.5px;font-weight:800;color:var(--ink)} +.ft-unit{font-style:normal;font-size:10px;color:var(--ink-4);font-family:var(--mono)} +.ft-desc{margin:0;font-size:11px;line-height:1.6;color:var(--ink-3)} +.ft-desc:empty{display:none} /* ================= 新建类型工作抽屉(details,零脚本)================= */ .lab{margin:0 0 6px;border:1.5px dashed var(--line);border-radius:12px;background:var(--soft)} @@ -322,11 +378,15 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va .lab[open] .lab-caret{transform:rotate(-135deg)} .lab-bd{padding:2px 14px 14px} .lab-sub{margin:16px 0 8px;font-size:11.5px;color:var(--ink-3)} +.lab-brief{margin:0 0 16px} +.lab-brief .brief{margin:2px 14px 14px;border-left-width:3px} /* ================= 窄屏 ================= */ @media(max-width:620px){ .card-head{padding:16px 14px 18px} .dz-grid{grid-template-columns:1fr} + .pback{grid-template-columns:1fr} + .ftiles{grid-template-columns:1fr} .card-bd{padding:16px 14px 6px} .hero-title{font-size:19.5px} .stage{gap:14px} diff --git a/packages/ui-kit/__tests__/view-container-multihost.test.ts b/packages/ui-kit/__tests__/view-container-multihost.test.ts new file mode 100644 index 0000000..1191a11 --- /dev/null +++ b/packages/ui-kit/__tests__/view-container-multihost.test.ts @@ -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 = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + +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); + }); +}); diff --git a/packages/ui-kit/src/ViewContainer.tsx b/packages/ui-kit/src/ViewContainer.tsx index 6ba7b88..0d3b39e 100644 --- a/packages/ui-kit/src/ViewContainer.tsx +++ b/packages/ui-kit/src/ViewContainer.tsx @@ -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(`[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(`[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('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('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 { * 认行元素则怎么增删都对得上:同一个 `` 里的格子就是同一行。 */ export function collect(doc: Document, address: string): Array> { - const host = bodyOf(doc, address); - if (host === null) return []; const byRow = new Map>(); - for (const cell of cellsOf(doc, address)) { + for (const host of hostsOf(doc, address)) { + for (const cell of Array.from( + host.querySelectorAll('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> { - const host = bodyOf(doc, address); - if (host === null) return []; - return Array.from(host.querySelectorAll('.jc-row-gone')).map((row) => { + return hostsOf(doc, address).flatMap((host) => + Array.from(host.querySelectorAll('.jc-row-gone')) + .filter((row) => row.closest('[data-edit]') === host) + .map((row) => { const one: Record = {}; for (const cell of Array.from(row.querySelectorAll('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.on` 写); + * 不是表格的落在 `data-edit` 元素自身(2026-08-02 A2 对决卡立的规矩)。 + */ +export function setOpenIn(doc: Document, address: string, on: boolean): void { + doc.querySelector(`[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()); + const snapshot = useRef(new Map()); /* * 挂事件 + 开关闸 —— 都在**父文档这一侧**做,iframe 里没有脚本。 @@ -462,11 +501,7 @@ export function ViewContainer({ html, title = '活动视图', editable = false, * 容器是 `
`,从前这里写死 `closest('table')`,非表格容器永远拿不到 * `.on`,编辑态整个打不开)。 */ - const setOpen = (address: string, on: boolean): void => { - doc.querySelector(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on); - const host = doc.querySelector(`[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;