feat(w05): 数据工单第四轮九连改 —— 笔↔软盘卡角编辑、只选不建、浅蓝题头、镜像修数字

2026-08-31 用户一次点齐九件,逐条落:
① 两行编辑闸退役 → 卡右上角笔(开当前这一面的编辑)↔ 软盘(保存)+ 叉(放弃);
  按钮就是平台的 data-edit-open/save/cancel 触发器,快照/取消机制原样,按面显隐全在样式表。
② 保存一瞬间下拉变数字 → ui-kit mirrorTwins:下拉镜像**选项文案**不镜 value,并把
  selected 属性拨到人选的那一项(守卫 CSS 与取消回显都认它)。2 测。
③ 参数输入框占位提示 → 渲染器 twinInputs 认 data-ph 落成 placeholder(平台小扩面,1 测);
  值格「规格书上的值 · 查不到留空」,说明格「补充说明 · 选填」。
④ 参数说明空着不再摆「建议」→ 什么都不摆(连兜底破折号一起收)。
⑤ 区序对调:芯片档案在前,比对基准在后。
⑥ **新建芯片类型整条路移除**(只能选已有类型):抽屉与两张编辑表(种子行/Critical-1
  修法/thead 回归网)退役,S1 配方 ②③ 自投影删除(八笔→六笔),gateLedgers 收窄到
  profile+parameter,writes 同步,gate_reason 与指导书改口「只选不建」;样例改名
  「候选缺库·建档定型」;e2e 案例 0 改走选既有 type_1(type_id=90 那套绕坑法随路退役)。
  退役面的七条钉测按仓例 it.skip 留墓志铭(钉的是已移除的路径,不是放松断言)。
⑦ 题头回浅蓝渐变(原目标件那条 120°),深色夜景只留验收两件。
⑧ 对话区执行卡不再摆「参考信息」板块(basis 数据照旧随卡走,详情/归档仍有)。
⑨ 比对基准降噪:字段收成丸(名+硬软徽+单位),说明藏进悬浮气泡指上即看;
  类型说明一行截断、悬浮展开。

测:ui-kit 33(mirror 2 新)+ 渲染器 61(ph 1 新)+ 包内 104(38 steps 重排)+ 2525 全仓
四门绿;2.10.0 装库 SQL 六探针全中(hd-light/软盘/data-ph/零表格/无 chip_type 编辑面/
gateLedgers 两张);:8787 已重启载入 data-ph 渲染器。

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 16:41:14 +08:00
co-authored by Claude Fable 5
parent f6c8dccb4f
commit 51914292dc
12 changed files with 259 additions and 389 deletions
@@ -450,7 +450,7 @@ describe('报告模版:编辑面与只读面各就各位', () => {
it('每个可编辑格子的 data-col 都在对应台账对象的声明里(input-supplement 一屏四张表,四张台账对象的声明并起来看;difference-review 一张表)', () => { it('每个可编辑格子的 data-col 都在对应台账对象的声明里(input-supplement 一屏四张表,四张台账对象的声明并起来看;difference-review 一张表)', () => {
const cases: Array<[string, Array<[string, string]>]> = [ const cases: Array<[string, Array<[string, string]>]> = [
['input-supplement', [['parts', 'profile'], ['parts', 'chip_type'], ['parts', 'type_param'], ['parts', 'parameter']]], ['input-supplement', [['parts', 'profile'], ['parts', 'parameter']]],
/* 件② 2026-08-31 起多一张:收口四格可就地改,写向 substitution.relation */ /* 件② 2026-08-31 起多一张:收口四格可就地改,写向 substitution.relation */
['difference-review', [['substitution', 'difference_item'], ['substitution', 'relation']]], ['difference-review', [['substitution', 'difference_item'], ['substitution', 'relation']]],
]; ];
@@ -548,12 +548,12 @@ describe('件① 评估输入补全', () => {
expect(steps.find((s) => s.item === 'S1_inputs')?.view).toBe('input-supplement'); expect(steps.find((s) => s.item === 'S1_inputs')?.view).toBe('input-supplement');
}); });
it('四块都在,行都绑本单草稿(不是整表);档案卡行源是 parts.profile 本身', () => { it('三块都在,行都绑本单草稿(不是整表);档案卡行源是 parts.profile 本身(类型字典只读整表,不再有 mine: 镜像)', () => {
expect(html).toContain('data-rows="mine:sub_eval.part"'); expect(html).toContain('data-rows="mine:sub_eval.part"');
expect(html).toContain('data-rows="mine:parts.profile"'); expect(html).toContain('data-rows="mine:parts.profile"');
expect(html).toContain('data-rows="mine:parts.parameter"'); expect(html).toContain('data-rows="mine:parts.parameter"');
expect(html).toContain('data-rows="mine:parts.chip_type"'); expect(html).not.toContain('data-rows="mine:parts.chip_type"');
expect(html).toContain('data-rows="mine:parts.type_param"'); expect(html).not.toContain('data-rows="mine:parts.type_param"');
}); });
/* /*
@@ -597,16 +597,14 @@ describe('件① 评估输入补全', () => {
/* 全只读:选类型在档案卡,建类型在抽屉 —— 基准卡上一个编辑面都不许有 */ /* 全只读:选类型在档案卡,建类型在抽屉 —— 基准卡上一个编辑面都不许有 */
expect(basis).not.toContain('data-col'); expect(basis).not.toContain('data-col');
expect(basis).not.toContain('data-edit'); expect(basis).not.toContain('data-edit');
/* 基准卡不再有表格:类型 + 字段全是瓷贴 */ /* 基准卡不再有表格:类型 + 字段全是瓷贴;说明藏进悬浮气泡(降噪,2026-08-31) */
expect(basis).not.toContain('<table'); expect(basis).not.toContain('<table');
expect(basis).toContain('class="ftiles"'); expect(basis).toContain('class="ftiles"');
/* 抽屉存在,且两张编辑表都住在里面(说明折叠用的是 lab lab-brief,不会被认成这一个) */ expect(html).toContain('.ftile:hover .ft-desc');
const labAt = html.indexOf('<details class="lab">'); /* 新建类型抽屉已随「只选不建」退役(2026-08-31 用户定)—— 只剩说明折叠那一只 details */
const labEnd = html.indexOf('</details>', labAt); expect(html).not.toContain('<details class="lab">');
expect(labAt).toBeGreaterThanOrEqual(0); expect(html).not.toContain('data-edit="parts.chip_type"');
expect(html.indexOf('<table data-edit="parts.chip_type">')).toBeGreaterThan(labAt); expect(html).not.toContain('data-edit="parts.type_param"');
expect(html.indexOf('<table data-edit="parts.type_param">')).toBeGreaterThan(labAt);
expect(labEnd).toBeGreaterThan(html.indexOf('<table data-edit="parts.type_param">'));
}); });
/* /*
@@ -631,34 +629,28 @@ describe('件① 评估输入补全', () => {
expect(html).toContain('点击翻面'); expect(html).toContain('点击翻面');
}); });
it('主画面无表格:说明折叠之前的正文一张 <table 都没有 —— 表格只活在抽屉里(表单要加行,加行机制认 tr)', () => { it('整件零表格(2026-08-31 用户:「这个界面都不再需要表格来展示数据了」;抽屉连表带功能一并退役)', () => {
const beforeDrawer = html.slice(0, html.indexOf('<details class="lab">')); expect(html).not.toContain('<table');
expect(beforeDrawer).not.toContain('<table'); expect(html).not.toContain('class="jc-tbl-wrap"');
}); });
it('三级占位:必填红 / 建议橘 —— 空格子直说要不要填(档案卡正面与卡背参数瓷贴两处)', () => { it('占位口径:正面必填红/建议橘;卡背值必填红,说明空着**什么都不摆**(2026-08-31 用户:「没写就什么都不要展示」)', () => {
expect(html).toContain('.fcell.req .jc-vcell:empty::after{content:"必填"'); expect(html).toContain('.fcell.req .jc-vcell:empty::after{content:"必填"');
expect(html).toContain('.fcell.sug .jc-vcell:empty::after{content:"建议"'); expect(html).toContain('.fcell.sug .jc-vcell:empty::after{content:"建议"');
expect(html).toContain('.ptile .pv .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:"建议"'); expect(html).toContain('.ptile .sg .jc-vcell:empty{display:none}');
expect(html).not.toContain('.ptile .sg .jc-vcell:empty::after');
}); });
it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」(块①的 chip_type_id 与块②b 的 type_id 都是)', () => { it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」', () => {
expect(html).toContain('data-options="parts.chip_type"'); expect(html).toContain('data-options="parts.chip_type"');
expect(html).toContain('data-option-value="type_id"'); expect(html).toContain('data-option-value="type_id"');
expect(html).toContain('data-option-label="type_name"'); expect(html).toContain('data-option-label="type_name"');
/* data-options 与 data-slot 不能共存(渲染时 data-slot 会被剥掉)—— 当下值由 data-col 定 */ /* data-options 与 data-slot 不能共存(渲染时 data-slot 会被剥掉)—— 当下值由 data-col 定 */
expect(/data-options="[^"]*"[^>]*data-slot=/.test(html)).toBe(false); expect(/data-options="[^"]*"[^>]*data-slot=/.test(html)).toBe(false);
/* 块①(sub_eval.part 行模版,只有一份行模版,被 data-rows 迭代)绑 chip_type_id 下拉 */ /* 档案卡正面绑 chip_type_id 下拉 —— 只选不建(2026-08-31 用户定),type_id 的写入下拉已随抽屉退役 */
expect(html).toContain('data-col="chip_type_id" required data-options="parts.chip_type"'); expect(html).toContain('data-col="chip_type_id" required data-options="parts.chip_type"');
/* expect(html).not.toContain('data-col="type_id"');
* 块②b(type_param)绑 type_id 下拉的地方**该出现两次**——已有行的行模版(data-rows
* 迭代)一份、种子行(字面量静态行,不受行数所限)一份;只数子串存在会被「只剩其中一份」
* 这种退化蒙混过去(已用变异验证逮到:只把动态行模版那份换回裸文本输入,这条子串断言
* 仍然为真,因为种子行那份还在——按出现次数数才钉得住两处都在)。
*/
const typeIdSelectCount = (html.match(/data-col="type_id" required data-options="parts\.chip_type"/g) ?? []).length;
expect(typeIdSelectCount).toBe(2);
}); });
/* /*
@@ -667,18 +659,14 @@ describe('件① 评估输入补全', () => {
* (查证见 Task 7b 报告)。补一条**不受 `data-rows` 管辖的静态种子行**——两张表各一条, * (查证见 Task 7b 报告)。补一条**不受 `data-rows` 管辖的静态种子行**——两张表各一条,
* 字面量手写 `data-col` 输入格,不经 `cell()`/`data-slot`,行数再少也在。 * 字面量手写 `data-col` 输入格,不经 `cell()`/`data-slot`,行数再少也在。
*/ */
it('**零行也加得出第一行**:两张表各带一条静态种子行(class="jc-row-static"),不靠 data-rows 生成', () => { /*
const staticRows = html.match(/<tr class="jc-row-static">/g) ?? []; * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
expect(staticRows).toHaveLength(2); * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
/* 种子行里的输入格是手写的,不经 twinInputs —— 直接断言 data-col 存在即可 */ * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
expect(html).toContain('<input class="jc-cell" data-col="type_name" placeholder="新类型名称">'); * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
expect(html).toContain('<input class="jc-cell" data-col="description"'); */
expect(html).toContain('<input class="jc-cell" data-col="param_name" placeholder="参数名">'); it.skip('**零行也加得出第一行**:两张表各带一条静态种子行(class="jc-row-static"),不靠 data-rows 生成', () => {
expect(html).toContain('<input class="jc-cell" data-col="param_desc"'); expect(true).toBe(true);
expect(html).toContain('<select class="jc-cell" data-col="constraint_type">');
expect(html).toContain('<input class="jc-cell" data-col="param_unit"');
/* 种子行只在编辑态露面 —— 读的时候不该看见一行空壳子(界面不撒谎) */
expect(html).toContain('table:not(.on) tr.jc-row-static { display: none; }');
}); });
/* /*
@@ -696,17 +684,14 @@ describe('件① 评估输入补全', () => {
* 钉住修法:`type_id` 必须挂 `data-col`,且必须是禁用的 —— 人看得见「由系统生成」 * 钉住修法:`type_id` 必须挂 `data-col`,且必须是禁用的 —— 人看得见「由系统生成」
* 这句占位,但怎么点都改不了它的值。 * 这句占位,但怎么点都改不了它的值。
*/ */
it('**新建类型的种子行:type_id 挂 data-col、但禁用恒空**(2026-08-30 修 Critical 1 —— 漏挂会让 resolveRowKey 的"全表一致"把新建的类型误判成既有那一行,静默覆盖它)', () => { /*
expect(html).toContain('由系统生成'); * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
// 「由系统生成」只出现在 chip_type 那条种子行里 —— 精确抓出那一行 * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
const chipTypeStaticRow = /<tr class="jc-row-static">(?:(?!<\/tr>)[\s\S])*?由系统生成(?:(?!<\/tr>)[\s\S])*?<\/tr>/.exec(html)?.[0] ?? ''; * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
expect(chipTypeStaticRow).not.toBe(''); * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
/* 必须是一个真的 <input data-col>(collect() 只认 input/select/textarea[data-col]), */
不能是纯文本 <span> —— 否则这一格永远不会出现在回传的 cells 里,barred 的保护无从谈起 */ it.skip('**新建类型的种子行:type_id 挂 data-col、但禁用恒空**(2026-08-30 修 Critical 1 —— 漏挂会让 resolveRowKey 的"全表一致"把新建的类型误判成既有那一行,静默覆盖它)', () => {
expect(chipTypeStaticRow).toMatch(/<input[^>]*data-col="type_id"[^>]*disabled/); expect(true).toBe(true);
/* disabled 意味着人怎么点都改不了它的值 —— 保存时这一格恒是空串,才谈得上「barred」 */
expect(chipTypeStaticRow).toContain('data-col="type_name"');
expect(chipTypeStaticRow).toContain('data-col="description"');
}); });
it('**主键列必挂 `data-col`** —— 它不是编辑面,是行的钥匙(漏了会把人改的行错配到别行上)', () => { it('**主键列必挂 `data-col`** —— 它不是编辑面,是行的钥匙(漏了会把人改的行错配到别行上)', () => {
@@ -714,10 +699,28 @@ describe('件① 评估输入补全', () => {
expect(html).toContain('data-col="param_name"'); expect(html).toContain('data-col="param_name"');
}); });
it('可改的三张表 + 档案卡各有一道编辑闸', () => { /*
for (const address of ['parts.profile', 'parts.chip_type', 'parts.type_param', 'parts.parameter']) { * 2026-08-31 用户定:两行编辑闸退役,换成卡右上角的笔↔软盘 —— 笔开当前这一面的编辑
expect(html).toContain(`data-edit="${address}"`); * (正面=主数据 / 背面=参数值),软盘保存,叉放弃。按钮就是平台认的 data-edit-open /
* data-edit-save / data-edit-cancel 触发器,快照与取消机制原样。
*/
it('编辑收进卡角:笔(open)/软盘(save)/叉(cancel)正反两面各一套;地址只剩 profile 与 parameter', () => {
for (const address of ['parts.profile', 'parts.parameter']) {
expect(html).toContain(`data-edit-open="${address}"`);
expect(html).toContain(`data-edit-save="${address}"`);
expect(html).toContain(`data-edit-cancel="${address}"`);
} }
/* 老的闸行不再出现在正文里(样式表里的基座规则给 ② 用,不算) */
expect(html.slice(html.indexOf('</style>'))).not.toContain('jc-editgate');
/* 笔↔软盘的按面显隐由样式表判 */
expect(html).toContain('.dossiers.on .disk-f{display:grid}');
expect(html).toContain(".dossier:has(.pback.on) .disk-b{display:grid}");
/* 参数瓷贴的输入孪生件带占位提示(渲染器认 data-ph) */
expect(html).toContain('data-ph="规格书上的值 · 查不到留空"');
expect(html).toContain('data-ph="补充说明 · 选填"');
/* 题头回浅蓝渐变(2026-08-31 用户:「标题背景还是改成之前的那种蓝色渐变」) */
expect(html).toContain('class="card hd-light"');
expect(html).toContain('linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%)');
}); });
/* /*
@@ -727,11 +730,14 @@ describe('件① 评估输入补全', () => {
* 内层 `data-rows` 那个 `<tbody>` 一处(给 `renderRows` 的 `editable` 检测看, * 内层 `data-rows` 那个 `<tbody>` 一处(给 `renderRows` 的 `editable` 检测看,
* 它只认与 `data-rows` 同一个标签自带的属性,不看祖先)。 * 它只认与 `data-rows` 同一个标签自带的属性,不看祖先)。
*/ */
it('新建类型 / 新建字段两张表的 data-edit 各出现两次(外层 table + 内层 data-rows tbody)', () => { /*
for (const address of ['parts.chip_type', 'parts.type_param']) { * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
const count = (html.match(new RegExp(`data-edit="${address}"`, 'g')) ?? []).length; * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
expect(count, address).toBe(2); * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
} * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
*/
it.skip('新建类型 / 新建字段两张表的 data-edit 各出现两次(外层 table + 内层 data-rows tbody)', () => {
expect(true).toBe(true);
}); });
/* /*
@@ -758,20 +764,14 @@ describe('件① 评估输入补全', () => {
* 谁若把 `<thead>` 挪回两个 `<tbody>` 之前(哪怕只改一张表),这条测试立刻标红, * 谁若把 `<thead>` 挪回两个 `<tbody>` 之前(哪怕只改一张表),这条测试立刻标红,
* 提醒去核对 `view-render-addrow-thead.test.ts` 是否也需要同步。 * 提醒去核对 `view-render-addrow-thead.test.ts` 是否也需要同步。
*/ */
it('**回归安全网**:两张新表的 <thead> 都排在两个 <tbody> 之后(不是最前面)—— 与 view-render-addrow-thead.test.ts 的假设锁在一起', () => { /*
for (const address of ['parts.chip_type', 'parts.type_param']) { * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
const tableStart = html.indexOf(`<table data-edit="${address}">`); * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
expect(tableStart, address).toBeGreaterThanOrEqual(0); * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
const tableEnd = html.indexOf('</table>', tableStart); * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
const tableHtml = html.slice(tableStart, tableEnd); */
const theadAt = tableHtml.indexOf('<thead>'); it.skip('**回归安全网**:两张新表的 <thead> 都排在两个 <tbody> 之后(不是最前面)—— 与 view-render-addrow-thead.test.ts 的假设锁在一起', () => {
const tbodyPositions = [...tableHtml.matchAll(/<tbody/g)].map((m) => m.index ?? -1); expect(true).toBe(true);
expect(theadAt, `${address}: 找不到 <thead>`).toBeGreaterThanOrEqual(0);
expect(tbodyPositions.length, `${address}: 应有两个 <tbody>`).toBe(2);
// <thead> 必须排在两个 <tbody> 的开标签**之后**(源码序;视觉呈现由 CSS 的
// table-header-group 保证仍在最前,与源码序无关)
for (const pos of tbodyPositions) expect(theadAt, address).toBeGreaterThan(pos);
}
}); });
/* /*
@@ -799,14 +799,14 @@ describe('件① 评估输入补全', () => {
* 只读态与编辑态下「#」列逐行都是空白。钉死 `.jc-tbl-wrap` 现在也接了这五条规则, * 只读态与编辑态下「#」列逐行都是空白。钉死 `.jc-tbl-wrap` 现在也接了这五条规则,
* 且这张件确实只用 `.jc-tbl-wrap`(没有表在用没打这条补丁就会数不出来的 `.tbl-wrap`)。 * 且这张件确实只用 `.jc-tbl-wrap`(没有表在用没打这条补丁就会数不出来的 `.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*\}/); * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
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*\}/); * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
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*\}/); * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
// 只剩抽屉里两张(2026-08-31 第三轮:基准卡瓷贴化、参数值上卡背 —— 主画面零表格) * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
expect(html).not.toContain('class="tbl-wrap"'); */
const jcTblWrapCount = html.match(/class="jc-tbl-wrap"/g)?.length ?? 0; it.skip('抽屉里两张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => {
expect(jcTblWrapCount).toBe(2); expect(true).toBe(true);
}); });
}); });
@@ -1068,10 +1068,9 @@ describe.skip('徽标别名与值域相关的坑(2026-08-27 实测账,回归网)
*/ */
describe('可编辑件的主键与必填列(2026-08-27 复审:主键丢钥匙 / 必填填不了,两条 Critical 回归;Task 7b 补 parts.chip_type)', () => { describe('可编辑件的主键与必填列(2026-08-27 复审:主键丢钥匙 / 必填填不了,两条 Critical 回归;Task 7b 补 parts.chip_type)', () => {
/** input-supplement 一屏四张真能编辑的表与它们各自绑定的台账对象(Task 7b 加入 parts.chip_type) */ /** input-supplement 一屏四张真能编辑的表与它们各自绑定的台账对象(Task 7b 加入 parts.chip_type) */
/* 2026-08-31「只选不建」:chip_type / type_param 不再是可编辑面,从名单撤下 */
const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [ const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [
{ view: 'input-supplement', domain: 'parts', object: 'profile' }, { view: 'input-supplement', domain: 'parts', object: 'profile' },
{ view: 'input-supplement', domain: 'parts', object: 'chip_type' },
{ view: 'input-supplement', domain: 'parts', object: 'type_param' },
{ view: 'input-supplement', domain: 'parts', object: 'parameter' }, { view: 'input-supplement', domain: 'parts', object: 'parameter' },
]; ];
@@ -146,10 +146,10 @@ describe('S1 配方', () => {
* 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts` * 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts`
* ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。 * ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。
*/ */
it('八笔,次序是:两颗料 → 类型字典自投 → 字段清单自投 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => { it('六笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代(类型字典/字段清单两笔自投影已随「只选不建」退役)', () => {
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'project', 'project', 'card', 'say']); expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'say']);
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([ expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
'sub_eval.part', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter', 'sub_eval.case', 'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
]); ]);
}); });
@@ -165,21 +165,13 @@ describe('S1 配方', () => {
* 跳过已落地的行(loop-engine/port.ts),prune 回来:没落地的陈旧选型照撤, * 跳过已落地的行(loop-engine/port.ts),prune 回来:没落地的陈旧选型照撤,
* 已落地的原地留下 —— 案例 2 不再炸,用户那条路清得干净。 * 已落地的原地留下 —— 案例 2 不再炸,用户那条路清得干净。
*/ */
it('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(带 prune,已落地由基座跳过)', () => { /*
const chipType = acts[1]; * SKIP(2026-08-31 用户定「只选不建」):②③ 两笔自投影连同 gateLedgers 上那两张表
expect(chipType?.kind === 'project' && chipType.into).toBe('parts.chip_type'); * 一并退役 —— 类型只许从字典里选,选中的行本来就在正式表里,镜像与落地都失去了对象。
expect(chipType?.kind === 'project' && chipType.prune).toBe(true); * 这条钉的机制(prune + skipLanded)仍活在 auto-runner 测试里,不随本活动退役。
expect(chipType?.kind === 'project' && chipType.from).toMatchObject({ */
each: 'mine:sub_eval.part', it.skip('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(带 prune,已落地由基座跳过)', () => {
then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } }, expect(true).toBe(true);
});
const typeParam = acts[2];
expect(typeParam?.kind === 'project' && typeParam.into).toBe('parts.type_param');
expect(typeParam?.kind === 'project' && typeParam.prune).toBe(true);
expect(typeParam?.kind === 'project' && typeParam.from).toMatchObject({
each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
});
}); });
it('案号由 let 铸,只从本单开案时刻取材', () => { it('案号由 let 铸,只从本单开案时刻取材', () => {
@@ -203,7 +195,7 @@ describe('S1 配方', () => {
* 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。 * 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。
*/ */
it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => { it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => {
const kase = acts[5]; const kase = acts[3];
const fields = kase?.kind === 'project' ? kase.fields : undefined; const fields = kase?.kind === 'project' ? kase.fields : undefined;
const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined; const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined;
expect(gateReason?.when).toBe('sub_eval_part_rows < 2'); expect(gateReason?.when).toBe('sub_eval_part_rows < 2');
@@ -213,7 +205,7 @@ describe('S1 配方', () => {
}); });
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => { it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
const params = acts[4]; const params = acts[2];
/* 'live':人加不了行的表,quiet 拍也撤 —— 改类型后旧参数行跟手消失(2026-08-31 用户点名「只会变多不会变少」) */ /* 'live':人加不了行的表,quiet 拍也撤 —— 改类型后旧参数行跟手消失(2026-08-31 用户点名「只会变多不会变少」) */
expect(params?.kind === 'project' && params.prune).toBe('live'); expect(params?.kind === 'project' && params.prune).toBe('live');
const from = params?.kind === 'project' ? params.from : undefined; const from = params?.kind === 'project' ? params.from : undefined;
@@ -224,7 +216,7 @@ describe('S1 配方', () => {
}); });
it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => { it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
const card = acts[6]; const card = acts[4];
const when = card?.kind === 'card' ? (card.when ?? '') : ''; const when = card?.kind === 'card' ? (card.when ?? '') : '';
/* /*
* S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、 * S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
@@ -245,16 +237,16 @@ describe('S1 配方', () => {
* (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。 * (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。
*/ */
it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => { it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => {
const card = acts[6]; const card = acts[4];
expect(card?.kind === 'card' && card.as).toBe('execution'); expect(card?.kind === 'card' && card.as).toBe('execution');
expect(card?.kind === 'card' && card.variant).toBe('data'); expect(card?.kind === 'card' && card.variant).toBe('data');
expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']); expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.profile', 'parts.parameter']);
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0); expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3); expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
}); });
it('发卡条件与 doneWhen 是同一件事的正反面', () => { it('发卡条件与 doneWhen 是同一件事的正反面', () => {
const when = acts[6]?.kind === 'card' ? (acts[6].when ?? '') : ''; const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : '';
for (const reading of [ for (const reading of [
'sub_eval_part_exists_status_found', 'sub_eval_part_missing', 'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing', 'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing',
@@ -339,7 +339,7 @@ export const ASSESSMENT_STEPS = [
与 auto.do 第 ⑥ 笔 `card` 的 `gateLedgers` 同源(parts.chip_type / parts.type_param / 与 auto.do 第 ⑥ 笔 `card` 的 `gateLedgers` 同源(parts.chip_type / parts.type_param /
parts.profile / parts.parameter —— 四张表都在闸上,理由见那笔 `card` 定义处的注释)。 */ parts.profile / parts.parameter —— 四张表都在闸上,理由见那笔 `card` 定义处的注释)。 */
view: 'input-supplement', view: 'input-supplement',
writes: ['sub_eval.part', 'sub_eval.case', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'], writes: ['sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter'],
reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'], reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'],
tools: [], tools: [],
doneWhen: doneWhen:
@@ -378,82 +378,12 @@ export const ASSESSMENT_STEPS = [
}, },
}, },
/* /*
* ② **选中的那一行类型字典,原样投一遍**(2026-08-30,Ruling H35 —— 真跑 e2e 撞见的 * ~~② 类型字典自投影 / ③ 字段清单自投影~~(2026-08-31 用户定「本活动只选类型、
* 平台侧连带问题:「数据工单确认即入库」按 `gateLedgers` 整表并入正式表,而 * 不新增类型」后退役):这两笔当年(Ruling H35)是为「人新建的类型/字段要随数据
* `parts.chip_type` 从 Task 7 起就没列在 `gateLedgers` 里 —— 人一旦真的新建一个类型, * 工单确认一起落地、且 mine: 不为空好让闸不误拦」而生 —— 类型只许从字典里选之后,
* `parts.profile`/`parts.type_param` 引用着它一起落地,**字典行本身**却只留在这一单 * 选中的类型本来就在正式表里,镜像与落地都失去了对象;`gateLedgers` 同批收窄到
* 草稿里、从没落地过。下一个任务读到同一颗料,查这个 `chip_type_id` 时字典里空空如也, * `parts.profile` / `parts.parameter` 两张真被人编辑的表。历史见 git(2.9.0 及更早)。
* `refersTo` 当场拒——是一条悬空引用,不是「查无此值」那种输入错误)。
*
* **这一笔不看「新建还是已有」,统统投一遍**(不选型的选也一样、新建的选也一样):
* 读的是 `sub_eval.part` 每一行当下的 `chip_type_id`(空串时下面这层 `each` 天然查
* 不到、不产生任何行 —— 人还没选类型的那颗不会被提前铺出假行),按它去
* `parts.chip_type` 里找到那一行、原样把 `type_name`/`description` 抄回自己身上。
* 值没变,`draftWrite` 同 PK 覆盖静默跳过(幂等,不产生多余的写入痕迹)——
* 但**这一单的草稿层从此真的有这一行**,`mine:parts.chip_type` 不再天然为空。
*
* 这条把 `parts.chip_type` 重新放回下面 ⑥ 的 `gateLedgers` 铺平了路:闸不会再拿
* 「零行」的旧理由误拦(那个前提不成立了),而选中的类型(新建的也好、已有的也好)
* 会随「数据工单确认入库」一起落进正式表 —— 不再留悬空引用。
*/ */
{
kind: 'project' as const,
into: 'parts.chip_type',
/*
* **带 `prune`,已落地的行由基座跳过**(2026-08-31 改回)。这一笔曾特意不带
* `prune`(2026-08-30 e2e 案例 2:类型行随「数据工单确认即入库」落地后,人改了
* 类型选择,`prune` 想撤已落地的旧行,`draftDrop` 硬拒、配方整单炸)—— 当时的
* 代价是**没落地的**陈旧行也跟着不清:人在件上把候选从 LDO 改回 DC-DC,LDO 的
* 字典行与字段行积在屏上,「下面还是显示两个类型」(2026-08-31 用户实测点名)。
* 病根在撤行机制一刀切,不在 prune 本身 —— 现在 `draftDrop` 对配方这一路带
* `skipLanded`(见 loop-engine/port.ts):已落地的行原地留下(它已是世界的一部分,
* 字典行本就该积累),**没落地的陈旧选型照撤** —— 案例 2 那条路不再炸,
* 用户点名的那条路清得干净。撤行只在非 quiet 拍跑(人正编辑时不动他的行,F3)。
*/
prune: true,
from: {
each: 'mine:sub_eval.part',
then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } },
},
fields: {
type_id: { col: 'type_id' },
type_name: { col: 'type_name' },
description: { col: 'description' },
},
},
/*
* ③ **选中类型的核心参数字段清单,同一套道理原样投一遍**(2026-08-30,与 ② 同一次
* 发现、同一个技法):`parts.type_param` 只有人在件上「新建类型时加字段」才会写它 ——
* 挑一个已有类型时,这张表天然一行都没有,而它此刻仍在 ⑥ 的 `gateLedgers` 上,
* 「零行 = 没干完」的闸对这条路一样是误判(2026-08-30 真跑 e2e 五案例实测坐实:
* 「归到已有类型」的候选缺库 / 类型不一致 / 原PN缺库 / 两颗都缺库四例,全部卡死在
* 同一条「芯片类型核心参数字段 这一单还一行都没有」)。
*
* 修法与 ② 完全同构:按当下选中的 `chip_type_id` 把那个类型的核心参数字段清单
* 整套原样抄回自己身上(值没变,幂等,不产生多余写入)——**新建的类型也走这条路**:
* 人在件上新增的字段行本就已经在 `mine:parts.type_param` 里,这一笔重新投一遍
* 原样命中、照样幂等跳过,不会覆盖人刚填的内容。
*
* **同样带 `prune`(已落地的由基座 `skipLanded` 跳过)**—— 与 ② 同一次改回、
* 同一个理由,见 ② 头上的大注释:改选类型后,旧类型**没落地**的字段行照撤,
* 已随数据工单确认落地的原地留下,H35 案例 2 的死锁不再触发。
*/
{
kind: 'project' as const,
into: 'parts.type_param',
prune: true,
from: {
each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
},
fields: {
type_id: { col: 'type_id' },
param_name: { col: 'param_name' },
param_desc: { col: 'param_desc' },
constraint_type: { col: 'constraint_type' },
param_unit: { col: 'param_unit' },
},
},
/* /*
* ④ 主数据骨架:**两颗料都铺、值回填现值**。 * ④ 主数据骨架:**两颗料都铺、值回填现值**。
* 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。 * 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。
@@ -566,7 +496,7 @@ export const ASSESSMENT_STEPS = [
then: '有料号库里查不到 —— 在下面把它的主数据建起来。', then: '有料号库里查不到 —— 在下面把它的主数据建起来。',
else: { else: {
when: 'sub_eval_part_missing > 0', when: 'sub_eval_part_missing > 0',
then: '有料号还没归芯片类型 —— 在下面给它选一个;没有合适的就新建一个,并把它的核心参数字段一并定下来。', then: '有料号还没归芯片类型 —— 在档案卡上给它选一个;字典里没有合适的,先请数据管理员补进类型字典(本活动只选不建)。',
else: { else: {
when: 'sub_eval_part_distinct_chip_type_id > 1', when: 'sub_eval_part_distinct_chip_type_id > 1',
then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。', then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。',
@@ -601,29 +531,18 @@ export const ASSESSMENT_STEPS = [
], ],
guide: [ guide: [
'两颗料必须归到**同一个芯片类型**下 —— 要比的核心参数字段由类型定,类型不同就没有可比的字段。', '两颗料必须归到**同一个芯片类型**下 —— 要比的核心参数字段由类型定,类型不同就没有可比的字段。',
'类型下拉里没有合适的,就新建一个,并把它的**核心参数字段**一并定下来:每个字段要有名字、一句说明、以及硬约束还是软约束。', '类型**只从字典里选,本活动不新增类型**(2026-08-31 用户定)—— 字典里没有合适的,先请数据管理员补进字典,再回来选。',
'说明写「这个参数为什么要紧」——**它是后面判定差异时唯一的解释来源**,写成参数名的复述等于没写。', '硬约束 = 不满足就不能用(烧芯片、超温域、装不上);软约束 = 影响表现但可权衡(功耗、噪声)—— 字段的硬软在字典上定死,件上「比对基准」卡可查。',
'硬约束 = 不满足就不能用(烧芯片、超温域、装不上);软约束 = 影响表现但可权衡(功耗、噪声)。',
'参数值填**原厂规格书上的值**,查不到就留空 —— 留空会被红带点名,但**编一个值比留空坏得多**。', '参数值填**原厂规格书上的值**,查不到就留空 —— 留空会被红带点名,但**编一个值比留空坏得多**。',
], ],
standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。', standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。',
/* /*
* **`parts.chip_type` 与 `parts.type_param` 都在闸上**(2026-08-30 改,Ruling H35)—— * **闸只盯人真编辑的两张表**(2026-08-31 随「只选不建」收窄):从前 `parts.chip_type`/
* 从前 `parts.chip_type` 被永久排除在外,理由是「它没有编辑手段,`mine:` 天然为空, * `parts.type_param` 也在名单上 —— 那是给「人新建的类型要随确认一起落地」铺的路
* 列了就永久卡死」;那条理由**从 ②③ 两笔自我投影加上之后就不再成立**—— * (Ruling H35,史见 git)。类型只许从字典里选之后,字典行本来就在正式表里,
* `mine:parts.chip_type`/`mine:parts.type_param` 现在恒不为空(只要 `sub_eval.part` * 落地无对象、mine: 恒为空 —— 留在名单上反而会被「零行 = 没干完」的闸误拦死。
* 里已经选定了类型),闸不会再误判「一行都没有」。
*
* **既然要落地就必须在名单里,这不是碰运气**:「数据工单确认即入库」把 `gateLedgers`
* 列出的表整批并入正式表;`parts.chip_type` 若不在这份名单里,人新建的类型字典行
* 就只留在草稿层,而引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 ——
* 落地那一刻起,正式表里就有一行指着字典里查无此号的悬空引用(2026-08-30 真跑
* e2e 撞见的平台侧连带发现:范围放行没有对 `refersTo` 做传递闭包,并一张表却不
* 并它引用的表,必然留下悬空引用;那条更普遍的平台缺陷已记 `docs/debt.md`,
* 这里只在活动自己的声明层堵住这一处具体触发点)。四张表都在闸上、也都会一起
* 落地,才是「选类型」这件事完整、闭环的样子。
*/ */
gateLedgers: ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'], gateLedgers: ['parts.profile', 'parts.parameter'],
}, },
{ kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' }, { kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' },
], ],
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估', name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */ /* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.9.0', packageVersion: '2.10.0',
category: '需求分析与方案匹配', category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment', checksum: 'dev-checksum-part-substitution-assessment',
@@ -568,7 +568,7 @@ export const manifest: ActivityManifest = {
* LM2596S-3.3 刻意不在库。 * LM2596S-3.3 刻意不在库。
*/ */
samples: [ samples: [
{ name: '候选缺库·新建类型', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } }, { name: '候选缺库·建档定型', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } },
{ name: '都在库·零卡直分析', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } }, { name: '都在库·零卡直分析', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
{ name: '类型不一致·硬拦', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, expect: { ports: ['assessment-released'] } }, { name: '类型不一致·硬拦', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, expect: { ports: ['assessment-released'] } },
{ name: '原PN缺库', input: { originalInternalPn: 'LM2596S-3.3', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } }, { name: '原PN缺库', input: { originalInternalPn: 'LM2596S-3.3', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
@@ -70,16 +70,12 @@ const editGate = (address: string, label: string): string => `<div class="jc-edi
<button class="jc-egbtn eg-save" type="button" data-edit-save="${address}">保存</button> <button class="jc-egbtn eg-save" type="button" data-edit-save="${address}">保存</button>
</div>`; </div>`;
const gapbar = (object: string, what: string): string => `<div class="jc-gapbar" data-class-of="${object}_missing"> const gapbar = (object: string, what: string): string => `<div class="jc-gapbar" data-class-of="${object}_missing">
<span><b>还差 <span data-slot="${object}_missing"></span> 行${what}没填</b> —— <span data-slot="${object}_missing_keys"></span>。按「编辑」打开,带 * 的必填格填上,补齐了才确认得了。</span> <span><b>还差 <span data-slot="${object}_missing"></span> 行${what}没填</b> —— <span data-slot="${object}_missing_keys"></span>。打开编辑,带 * 的必填格填上,补齐了才确认得了。</span>
</div>`; </div>`;
const addrow = (address: string, label: string, hint: string): string => `<div class="jc-addrow"> /** 可改格:只读那一份留空,渲染器自动配孪生 `<input>`(手写 input 会不填值,人一点编辑就是白框)。
<button type="button" data-edit-add="${address}">${label}</button> * `ph` 落成孪生件的 placeholder(渲染器认 `data-ph`,2026-08-31)—— 短短一句说清这格填什么。 */
<em>${hint}</em> const cell = (col: string, cls = '', ph = ''): string =>
</div>`; `<span class="jc-vcell${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" data-col="${col}"${ph === '' ? '' : ` data-ph="${ph}"`} data-blank></span>`;
/** 可改格:只读那一份留空,渲染器自动配孪生 `<input>`(手写 input 会不填值,人一点编辑就是白框) */
const cell = (col: string, cls = ''): string => `<span class="jc-vcell${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" data-col="${col}" data-blank></span>`;
/** 只读格:即便挂在可改的表里也不许被 `table.on` 藏起来 */
const ro = (col: string, cls = ''): string => `<span class="jc-vcell ro${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" data-blank></span>`;
const select = (col: string, options: ReadonlyArray<readonly [string, string]>, required = false): string => const select = (col: string, options: ReadonlyArray<readonly [string, string]>, required = false): string =>
`<select class="jc-cell" data-col="${col}"${required ? ' required' : ''}><option value=""></option>${options.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>`; `<select class="jc-cell" data-col="${col}"${required ? ' required' : ''}><option value=""></option>${options.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>`;
/** /**
@@ -111,9 +107,9 @@ const gateBar = (): string => `<div class="jc-gate-bar">
* 收件人 / 关联任务两格由引擎注入;工单编号即本次评估案号 `eval_ref`(页脚)。 * 收件人 / 关联任务两格由引擎注入;工单编号即本次评估案号 `eval_ref`(页脚)。
* `stage` 是每张件自己的 hero 场景(SVG 舞台),`accent` 决定这张件的强调色。 * `stage` 是每张件自己的 hero 场景(SVG 舞台),`accent` 决定这张件的强调色。
*/ */
const doc = (input: { accent: string; alias: string; tag: string; kicker: string; title: string; step: string; stage: string; body: string; footTag: string }): string => const doc = (input: { accent: string; alias: string; tag: string; kicker: string; title: string; step: string; stage: string; body: string; footTag: string; headTone?: 'light' }): string =>
`<style>${SHELL_CSS}${input.accent}${input.alias}</style> `<style>${SHELL_CSS}${input.accent}${input.alias}</style>
<div class="page"><div class="card"> <div class="page"><div class="card${input.headTone === 'light' ? ' hd-light' : ''}">
<header class="card-head"> <header class="card-head">
<div class="hero-top"> <div class="hero-top">
<span class="hero-sys">${input.kicker}</span> <span class="hero-sys">${input.kicker}</span>
@@ -130,6 +126,14 @@ const doc = (input: { accent: string; alias: string; tag: string; kicker: string
</footer> </footer>
</div></div>`; </div></div>`;
/** 卡角编辑钮的三枚图标:笔(开编辑)/ 软盘(保存)/ 叉(放弃)—— 2026-08-31 用户定的笔↔软盘交互 */
const PEN_SVG =
'<svg class="fic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
const DISK_SVG =
'<svg class="fic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>';
const X_SVG =
'<svg class="fic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>';
/* ==================== 局面:评估输入补全(input-supplement,S1 的数据工单) ==================== */ /* ==================== 局面:评估输入补全(input-supplement,S1 的数据工单) ==================== */
/* /*
@@ -191,6 +195,14 @@ ${badgeCss('.rl', [
const DOSSIERS = `<section class="board"><article class="dossiers" data-rows="mine:parts.profile" data-edit="parts.profile"><section class="dossier"> const DOSSIERS = `<section class="board"><article class="dossiers" data-rows="mine:parts.profile" data-edit="parts.profile"><section class="dossier">
<label class="flip-hit"><input class="flip-tg" type="checkbox" aria-label="翻转这张卡"></label> <label class="flip-hit"><input class="flip-tg" type="checkbox" aria-label="翻转这张卡"></label>
<span class="flip-cue"><i class="cue-front">点击翻面 · 核心参数</i><i class="cue-back">点击翻回 · 主数据</i></span> <span class="flip-cue"><i class="cue-front">点击翻面 · 核心参数</i><i class="cue-back">点击翻回 · 主数据</i></span>
<span class="fabs">
<button class="fab xbtn x-f" type="button" data-edit-cancel="parts.profile" title="放弃这一面的修改">${X_SVG}</button>
<button class="fab xbtn x-b" type="button" data-edit-cancel="parts.parameter" title="放弃这一面的修改">${X_SVG}</button>
<button class="fab pen pen-f" type="button" data-edit-open="parts.profile" title="编辑这一面(主数据)">${PEN_SVG}</button>
<button class="fab pen pen-b" type="button" data-edit-open="parts.parameter" title="编辑这一面(核心参数值)">${PEN_SVG}</button>
<button class="fab disk disk-f" type="button" data-edit-save="parts.profile" title="保存这一面">${DISK_SVG}</button>
<button class="fab disk disk-b" type="button" data-edit-save="parts.parameter" title="保存这一面">${DISK_SVG}</button>
</span>
<div class="flip-in"> <div class="flip-in">
<div class="face front"> <div class="face front">
<header class="dz-top"> <header class="dz-top">
@@ -218,8 +230,8 @@ const DOSSIERS = `<section class="board"><article class="dossiers" data-rows="mi
<header class="pb-hd"><b class="mono" data-slot="internal_pn" data-blank></b><span class="pb-lb">核心参数值</span></header> <header class="pb-hd"><b class="mono" data-slot="internal_pn" data-blank></b><span class="pb-lb">核心参数值</span></header>
<section class="pback" data-rows="mine:parts.parameter" data-by="internal_pn=internal_pn" data-edit="parts.parameter"><div class="ptile"> <section class="pback" data-rows="mine:parts.parameter" data-by="internal_pn=internal_pn" data-edit="parts.parameter"><div class="ptile">
<span class="pt-hd"><span class="pt-name">${cell('param_name')}</span><i class="pt-unit" data-slot="param_unit" data-blank></i></span> <span class="pt-hd"><span class="pt-name">${cell('param_name')}</span><i class="pt-unit" data-slot="param_unit" data-blank></i></span>
<span class="pt-val pv">${cell('param_value', 'mono')}</span> <span class="pt-val pv">${cell('param_value', 'mono', '规格书上的值 · 查不到留空')}</span>
<span class="pt-note sg">${cell('param_note')}</span> <span class="pt-note sg">${cell('param_note', '', '补充说明 · 选填')}</span>
<span class="rowkey">${cell('internal_pn', 'mono')}</span> <span class="rowkey">${cell('internal_pn', 'mono')}</span>
</div></section> </div></section>
</div> </div>
@@ -254,102 +266,13 @@ const BASIS_CARD = `<article class="basis" data-rows="mine:${D}.case"><section c
</div></section> </div></section>
</section></article>`; </section></article>`;
/** /*
* 块②a 新建芯片类型 —— `parts.chip_type`。类型下拉(块①)里没有合适的,人在这里现造一个。 * ~~块②a 新建芯片类型 / 块②b 核心参数字段清单~~(2026-08-31 用户定:「把添加芯片类型的
* `type_id` 是自增主键 —— **人不填它**,自增列不问人;已有行只读态摆 `ro()`。 * 功能从这个活动中移除,不能在这个活动中新增芯片类型,只能选择已有的芯片类型」)。
* * 两张编辑表(静态种子行、加删行、thead 后置、type_id 禁用 data-col 那整套 Critical-1
* **种子行里 `type_id` 必须挂一个真实的(禁用、恒空)`data-col`,不能干脆不给 —— * 修法)与「新建类型」抽屉一并退役 —— 类型字典只在治理侧维护,本活动只读它。
* 这是 2026-08-30 终审 Critical 1 的病灶与修法**:第一版把这一格写成纯文本 `<span>`, * 机制史与钉测见 git(2.9.0 及更早)与 view-render-addrow-thead.test.ts(平台侧仍在)。
* 不带 `data-col` —— 保存时 `collect()` 只认 `input[data-col]/select[data-col]/
* textarea[data-col]`,`type_id` 压根不出现在回传的 `cells` 里;而 `desk.ts` 的
* `resolveRowKey` 用 `k in cells` 判「这一列人碰过没有」,漏挂在它眼里等同「这张表没有
* 这一列」,专为「auto 列件上摆着却空着」设计的 `barred` 保护从未触发,退回「全表一致」:
* `parts.chip_type` 恰好一行时,人新建的类型被当场判成「就是那唯一一行」,一次保存就把
* 种子字典行**静默改名** —— `autoKeys()` 续号器永远轮不到。修法:真的
* `<input data-col="type_id" disabled>`(值恒空,只显示「由系统生成」占位)。
* 回归钉在 `__tests__/activity.test.ts` 与 `apps/api/src/app/__tests__/view-table-edit.test.ts`。
*
* **零行照样加得出第一行**(这一块存在的全部理由):`mine:parts.chip_type` 每单开局是
* 零行,`ViewContainer.addRow` 靠克隆 `tr:last-of-type` 添新行,零行时无样可克隆 ——
* 多一个不受 `data-rows` 管辖的静态 `<tbody>`,字面量摆一行输入格(不经 `cell()`),
* 它不是数据行,永远在;填了按保存,`writeEditedRows` 认得出「主键全空 + 别的列有值」
* (`mintable`),转给 `autoKeys()` 续号。
*
* `data-edit="parts.chip_type"` **两处都写** —— 外层 `<table>` 一处(给
* `ViewContainer.collect()` 与 `.on` 切换看,要同时收到已有行与种子行两处的输入,宿主
* 必须是两者共同的祖先),内层 `data-rows` 那个 `<tbody>` 一处(`renderRows` 判「这组行
* 可编辑吗」只认与 `data-rows` 同一个标签自带的属性)。
*
* **`<thead>` 排在两个 `<tbody>` 之后 —— 评审逮到的 Critical 回归修法,不是随手换的顺序**
* (2026-08-30):`bodyOf()` 解析到外层 `<table>` 后,`addRow()` 在这个 host 里找
* `tr:last-of-type` —— `:last-of-type` 按各自父元素判定,`<thead>` 里那个 `<tr>` 必然
* 「最后」,若 `<thead>` 排前面会被文档序优先命中:克隆出来的是表头文字。CSS 的
* `table-header-group` 排版规则保证 `<thead>` 视觉上永远在最前,不看 DOM 序 —— 呈现
* 一字不变,而选择器认文档序。证据与变异验证见
* `apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts`。
*/ */
const CHIP_TYPE_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_type">
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"><tr>
<td class="rn"><span class="jc-rn-num"></span></td>
<td>${ro('type_id', 'mono')}</td>
<td>${cell('type_name')}</td>
<td>${cell('description')}</td>
</tr></tbody>
<tbody><tr class="jc-row-static">
<td class="rn"><span class="jc-rn-num"></span></td>
<td><input class="jc-cell mono" data-col="type_id" placeholder="由系统生成" disabled></td>
<td><input class="jc-cell" data-col="type_name" placeholder="新类型名称"></td>
<td><input class="jc-cell" data-col="description" placeholder="这个类型是什么(供选型参考)"></td>
</tr></tbody>
<thead><tr>
<th style="width:38px">#</th>
<th data-h="类型编号">编号</th>
<th class="req" data-h="类型名称">类型名称</th>
<th data-h="类型说明">类型说明</th>
</tr></thead>
</table></div>`;
/**
* 块②b 芯片类型的核心参数字段清单 —— `parts.type_param`,复合主键 `type_id + param_name`。
* 新建的类型在这里把核心参数字段一条条定下来(名字 / 说明 / 硬软约束 / 单位)。
*
* `type_id` 是与块①同款的 `optionSelect`(选项从 `parts.chip_type` 现读,人挑的是
* **类型名称**):块②a 新建的类型一旦保存,这份下拉立刻带上它 —— 不必谁记住那个号。
* 静态种子行与块②a 同一套手法;这张表是唯一带「+加一行」/删行的一张,零行时那颗按钮
* 同样够不到岸,种子行同样补。
*
* **`<thead>` 排在两个 `<tbody>` 之后,同 `CHIP_TYPE_TABLE` 那条大注释** —— 这张表更
* 要紧:它的「加一行」在外层 `data-edit` 挂上之前本来是好的,是一次真回归。
*/
const TYPE_PARAM_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.type_param">
<tbody data-rows="mine:parts.type_param" data-edit="parts.type_param"><tr>
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="parts.type_param">×</button></td>
<td>
<span class="jc-vcell"><span data-rows="parts.chip_type" data-by="type_id=type_id"><i data-slot="type_name" data-blank></i></span></span>
${optionSelect('type_id', 'parts.chip_type', 'type_id', 'type_name', true)}
</td>
<td>${cell('param_name')}</td>
<td>${cell('param_desc')}</td>
<td>${badge('ct', 'constraint_type', true)}${select('constraint_type', [['hard', '硬约束'], ['soft', '软约束']], true)}</td>
<td>${cell('param_unit')}</td>
</tr></tbody>
<tbody><tr class="jc-row-static">
<td class="rn"><span class="jc-rn-num"></span></td>
<td>${optionSelect('type_id', 'parts.chip_type', 'type_id', 'type_name', true)}</td>
<td><input class="jc-cell" data-col="param_name" placeholder="参数名"></td>
<td><input class="jc-cell" data-col="param_desc" placeholder="这个参数为什么要紧"></td>
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
<td><input class="jc-cell" data-col="param_unit" placeholder="单位(选填)"></td>
</tr></tbody>
<thead><tr>
<th style="width:38px">#</th>
<th class="req" data-h="所属类型">所属类型</th>
<th class="req" data-h="参数名">参数名</th>
<th class="req" data-h="参数说明">参数说明</th>
<th class="req" data-h="约束类型">约束(硬/软)</th>
<th data-h="参考单位">参考单位</th>
</tr></thead>
</table></div>`;
export const INPUT_SUPPLEMENT = doc({ export const INPUT_SUPPLEMENT = doc({
accent: ACCENT_DATA, accent: ACCENT_DATA,
@@ -359,6 +282,7 @@ export const INPUT_SUPPLEMENT = doc({
title: '差异评估的输入还差几格', title: '差异评估的输入还差几格',
step: '步骤 1/4 · 校验入参与补数据', step: '步骤 1/4 · 校验入参与补数据',
stage: '', stage: '',
headTone: 'light',
footTag: '工单类型:数据', footTag: '工单类型:数据',
body: body:
gateBar() + gateBar() +
@@ -367,28 +291,15 @@ export const INPUT_SUPPLEMENT = doc({
'<span class="need">模板定位</span>:本单是「数据工单」——目标台账 = 芯片主数据(P-08 主记录 + 标准化参数子表),核心参数字段清单挂在芯片类型字典上。', '<span class="need">模板定位</span>:本单是「数据工单」——目标台账 = 芯片主数据(P-08 主记录 + 标准化参数子表),核心参数字段清单挂在芯片类型字典上。',
'<span class="need">触发原因</span>:下面电路板上两张档案卡就是本次要比对的原 PN 与候选 PN —— 至少有一颗不在库、两颗归的类型不一致、或必填格还空着,顶部那条红带才会出现。', '<span class="need">触发原因</span>:下面电路板上两张档案卡就是本次要比对的原 PN 与候选 PN —— 至少有一颗不在库、两颗归的类型不一致、或必填格还空着,顶部那条红带才会出现。',
'<span class="done">已绑定</span>:两颗料的内部料号已按启动参数铺上档案卡,不必你重填料号本身,只需核对 / 补齐其余各格;在库的料现值已回填,只是查漏补缺。', '<span class="done">已绑定</span>:两颗料的内部料号已按启动参数铺上档案卡,不必你重填料号本身,只需核对 / 补齐其余各格;在库的料现值已回填,只是查漏补缺。',
'<span class="need">纪律</span>:两颗料必须归到同一个芯片类型下才谈得上逐项比对;类型下拉里没有合适的就在下面的抽屉里新建一个,并把它的核心参数字段一并定下来;查不到的参数值留空(会被红带点名),不要编一个值顶替。', '<span class="need">纪律</span>:两颗料必须归到同一个芯片类型下才谈得上逐项比对;类型只从字典里选,本活动不新增类型 —— 字典里没有合适的,先请数据管理员补进字典;查不到的参数值留空(会被红带点名),不要编一个值顶替。',
]) + ]) +
`</details>` + `</details>` +
zone('比对基准', '芯片类型 + 它的核心参数字段 —— 两颗料归到哪个类型,就按这些字段逐项比;档案卡上改选,这张卡当场跟着换', ZIC_TABLE) + zone('芯片档案', '一颗芯片一张卡:正面主数据、背面核心参数值 —— 点卡翻面看另一面;右上角的笔 = 编辑当前这一面,软盘 = 保存', ZIC_TABLE) +
BASIS_CARD +
zone('芯片档案', '一颗芯片一张卡:正面主数据、背面核心参数值 —— 点卡翻面看另一面', ZIC_TABLE) +
editGate('parts.profile', '正面主数据核对 / 补齐 —— 开编辑后卡停在正面') +
editGate('parts.parameter', '背面参数值核对 / 补齐 —— 开编辑后两张卡自动翻到背面') +
gapbar('parts_profile', '必填') + gapbar('parts_profile', '必填') +
gapbar('parts_parameter', '必填') + gapbar('parts_parameter', '必填') +
DOSSIERS + DOSSIERS +
gapbar('parts_chip_type', '必填') + zone('比对基准', '芯片类型 + 它的核心参数字段 —— 两颗料归到哪个类型,就按这些字段逐项比;字段说明悬浮即看', ZIC_TABLE) +
gapbar('parts_type_param', '必填') + BASIS_CARD +
`<details class="lab"><summary class="lab-sm"><b>类型库里没有合适的?</b>展开新建一个类型,并把它的核心参数字段定下来 —— 定完回到上面的档案卡挑它<span class="lab-caret"></span></summary><div class="lab-bd">` +
editGate('parts.chip_type', '新建一个类型:表尾那一行填类型名称与说明,保存后立刻出现在档案卡的下拉里') +
CHIP_TYPE_TABLE +
addrow('parts.chip_type', '+ 再建一个类型', '两颗料若都要新建、且类型不同,先各自保存,「加一行」能再建一个') +
`<p class="lab-sub">这个类型逐条比对时要看哪些参数 —— 名字 / 说明 / 硬软约束(*为必填列)</p>` +
editGate('parts.type_param', '字段名字 / 说明 / 硬软约束,点「编辑」核对;表尾那一行 / 「加一行」补字段') +
TYPE_PARAM_TABLE +
addrow('parts.type_param', '+ 加一行(补一个核心参数字段)', '所属类型 / 参数名 / 参数说明 / 约束类型必须写;参考单位选填') +
`</div></details>` +
flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区的【执行卡】中进行。确认后活动自动从「等待数据」唤醒。'), flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区的【执行卡】中进行。确认后活动自动从「等待数据」唤醒。'),
}); });
@@ -110,6 +110,15 @@ i{font-style:normal}
.seal-sum{flex:1 1 240px;min-width:200px;max-width:420px;margin:0;font-size:13px;line-height:1.85;color:var(--hero-dim)} .seal-sum{flex:1 1 240px;min-width:200px;max-width:420px;margin:0;font-size:13px;line-height:1.85;color:var(--hero-dim)}
.seal-sum:empty::after{content:"—"} .seal-sum:empty::after{content:"—"}
/* ── 浅色题头变体(件① 2026-08-31 用户:「标题背景还是改成之前的那种蓝色渐变」——
渐变取自原目标件 card-head 那一条,深色电路夜景只留给验收两件)── */
.hd-light .card-head{background:linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%);color:var(--ink);border-bottom:2px solid var(--ink)}
.hd-light .hero-sys{color:var(--ink-4)}
.hd-light .hero-title{color:var(--ink)}
.hd-light .hero-step{color:var(--ink-3);border-color:var(--line)}
.hd-light .hero-meta{color:var(--ink-3)}
.hd-light .hero-meta b{color:var(--ink)}
/* ================= 正文分区 ================= */ /* ================= 正文分区 ================= */
.card-bd{padding:20px 22px 8px} .card-bd{padding:20px 22px 8px}
.sec{display:flex;align-items:baseline;gap:9px;margin:26px 0 10px;flex-wrap:wrap} .sec{display:flex;align-items:baseline;gap:9px;margin:26px 0 10px;flex-wrap:wrap}
@@ -324,6 +333,23 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va
.dossiers:not(.on) .dossier:hover:has(.flip-tg:checked):not(:has(.pback.on)) .flip-in{transform:rotateY(168deg)} .dossiers:not(.on) .dossier:hover:has(.flip-tg:checked):not(:has(.pback.on)) .flip-in{transform:rotateY(168deg)}
} }
/* ── 卡角编辑钮:笔=编辑当前这一面,软盘=保存,叉=放弃(2026-08-31 用户定,顶掉两行编辑闸)──
笔只在待处理态露面(html.editable,与从前的闸同一条纪律);软盘/叉只在对应面的编辑态露面。 */
.fabs{position:absolute;top:10px;right:10px;z-index:5;display:flex;gap:6px}
.fab{display:none;width:32px;height:32px;border-radius:999px;border:1px solid var(--acc-line);background:#fff;color:var(--acc-deep);cursor:pointer;place-items:center;padding:0;box-shadow:0 4px 12px rgba(18,38,63,.14);transition:border-color .16s,color .16s,box-shadow .16s}
.fab:hover{border-color:var(--acc);box-shadow:0 6px 16px rgba(18,38,63,.22)}
.fab:active{transform:scale(.94)}
.fic{width:15px;height:15px;display:block;margin:auto}
.fab.pen{color:var(--acc-deep)}
.fab.disk{background:var(--acc);border-color:var(--acc);color:#fff}
.fab.xbtn{color:var(--ink-3)}
html.editable .dossiers:not(.on) .dossier:not(:has(.pback.on)):not(:has(.flip-tg:checked)) .pen-f{display:grid}
html.editable .dossiers:not(.on) .dossier:has(.flip-tg:checked):not(:has(.pback.on)) .pen-b{display:grid}
.dossiers.on .disk-f{display:grid}
.dossiers.on .x-f{display:grid}
.dossier:has(.pback.on) .disk-b{display:grid}
.dossier:has(.pback.on) .x-b{display:grid}
/* ── 卡背:参数瓷贴 ── */ /* ── 卡背:参数瓷贴 ── */
.pb-hd{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap} .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-hd b{font-size:14px;font-weight:800;color:var(--ink)}
@@ -337,7 +363,8 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va
.pt-note .jc-vcell{font-size:10.5px;color:var(--ink-3)} .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 .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} /* 说明空着**什么都不摆**(2026-08-31 用户:「没写就什么都不要展示」)—— 连兜底破折号一起收 */
.ptile .sg .jc-vcell:empty{display:none}
/* 参数编辑态:值与说明换输入,参数名照旧当标签摆着;两枚主键孪生件藏死(钥匙不是编辑面)。 /* 参数编辑态:值与说明换输入,参数名照旧当标签摆着;两枚主键孪生件藏死(钥匙不是编辑面)。
藏钥匙用包装类(.pt-name / .rowkey),不用「data-col 属性选择器」—— 那段选择器文本会原样 藏钥匙用包装类(.pt-name / .rowkey),不用「data-col 属性选择器」—— 那段选择器文本会原样
出现在共享样式表里,把「落账确认全只读」那类字面断言踩翻(2026-08-31 踩过,连注释也不许写)。 */ 出现在共享样式表里,把「落账确认全只读」那类字面断言踩翻(2026-08-31 踩过,连注释也不许写)。 */
@@ -359,13 +386,19 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va
.basis-hd .csvg{width:56px;flex:none} .basis-hd .csvg{width:56px;flex:none}
.basis-id{display:flex;flex-direction:column;gap:5px;min-width:0;flex:1 1 auto} .basis-id{display:flex;flex-direction:column;gap:5px;min-width:0;flex:1 1 auto}
.basis-in:has(.ftiles:empty) .ftiles{display:none} .basis-in:has(.ftiles:empty) .ftiles{display:none}
.ftiles{display:grid;grid-template-columns:1fr 1fr;gap:8px} /* 降噪(2026-08-31 用户:「乱糟糟…没必要显示全就不要显示完全,鼠标指上去后再显示」):
.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} .ftiles{display:flex;flex-wrap:wrap;gap:8px}
.ft-hd b{font-size:12.5px;font-weight:800;color:var(--ink)} .ftile{position:relative;display:flex;flex-direction:row;align-items:center;gap:7px;border:1px solid var(--line-soft);border-radius:999px;background:#fff;padding:6px 13px;min-width:0;transition:border-color .16s,box-shadow .16s}
.ftile:hover{border-color:var(--acc-line);box-shadow:0 4px 12px rgba(18,38,63,.1)}
.ft-hd{display:flex;align-items:baseline;gap:7px;white-space:nowrap}
.ft-hd b{font-size:12px;font-weight:800;color:var(--ink)}
.ft-unit{font-style:normal;font-size:10px;color:var(--ink-4);font-family:var(--mono)} .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{display:none;position:absolute;z-index:6;top:calc(100% + 7px);left:0;width:max-content;max-width:280px;margin:0;font-size:11px;line-height:1.65;color:var(--ink-2);background:#fff;border:1px solid var(--line);border-radius:9px;padding:8px 11px;box-shadow:0 10px 24px rgba(18,38,63,.16)}
.ft-desc:empty{display:none} .ftile:hover .ft-desc:not(:empty){display:block}
/* 类型说明:默认一行截断,悬浮题头才放开 */
.basis-desc{display:block;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.basis-hd:hover .basis-desc{white-space:normal}
/* ================= 新建类型工作抽屉(details,零脚本)================= */ /* ================= 新建类型工作抽屉(details,零脚本)================= */
.lab{margin:0 0 6px;border:1.5px dashed var(--line);border-radius:12px;background:var(--soft)} .lab{margin:0 0 6px;border:1.5px dashed var(--line);border-radius:12px;background:var(--soft)}
@@ -377,7 +410,6 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va
.lab-caret{margin-left:auto;flex:none;width:8px;height:8px;border-right:2px solid var(--ink-4);border-bottom:2px solid var(--ink-4);transform:rotate(45deg);transition:transform .16s} .lab-caret{margin-left:auto;flex:none;width:8px;height:8px;border-right:2px solid var(--ink-4);border-bottom:2px solid var(--ink-4);transform:rotate(45deg);transition:transform .16s}
.lab[open] .lab-caret{transform:rotate(-135deg)} .lab[open] .lab-caret{transform:rotate(-135deg)}
.lab-bd{padding:2px 14px 14px} .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{margin:0 0 16px}
.lab-brief .brief{margin:2px 14px 14px;border-left-width:3px} .lab-brief .brief{margin:2px 14px 14px;border-left-width:3px}
@@ -542,3 +542,17 @@ describe('data-options:下拉的选项从台账现读', () => {
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>'); expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
}); });
}); });
describe('孪生件占位提示:data-ph 落成 input 的 placeholder(2026-08-31,W05 参数瓷贴)', () => {
it('带 data-ph 的可改格,孪生 input 有 placeholder;不带的没有', () => {
const html =
'<div data-rows="mine:parts.parameter" data-edit="parts.parameter"><p>' +
'<span class="jc-vcell" data-slot="param_value" data-col="param_value" data-ph="规格书上的值,查不到留空" data-blank></span>' +
'<span class="jc-vcell" data-slot="param_note" data-col="param_note" data-blank></span>' +
'</p></div>';
const out = renderView(html, { 'mine:parts.parameter': [{ param_value: '', param_note: '' }] });
expect(out).toContain('placeholder="规格书上的值,查不到留空"');
const notePart = out.slice(out.indexOf('data-col="param_note"'));
expect(notePart.slice(0, notePart.indexOf('/>'))).not.toContain('placeholder=');
});
});
@@ -263,7 +263,9 @@ function twinInputs(inner: string, rowIndex: number): string {
.split(/\s+/) .split(/\s+/)
.filter((c) => c === 'mono' || c === 'num') .filter((c) => c === 'mono' || c === 'num')
.join(' '); .join(' ');
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}" data-value/>`; /* `data-ph`:模版给这一格的输入孪生件配一句占位提示(2026-08-31,W05 参数瓷贴要「短短一句说清这格填什么」) */
const ph = /\bdata-ph\s*=\s*"([^"]*)"/.exec(attrs)?.[1];
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}"${ph === undefined || ph === '' ? '' : ` placeholder="${ph}"`} data-value/>`;
// 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音 // 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音
return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`; return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`;
}); });
@@ -1051,7 +1051,8 @@ function ExecForm({ order, onReplyOrder, onLocate, orderValues, onField, face =
</button> </button>
<p className="jc-fc-q">{order.ask}</p> <p className="jc-fc-q">{order.ask}</p>
<Guide steps={order.steps} standard={order.standard} /> <Guide steps={order.steps} standard={order.standard} />
{order.basis.length > 0 && <Kv items={order.basis} />} {/* 参考信息不再上卡(2026-08-31 用户定:「派发的工单参考信息这个板块不要显示」)——
basis 数据照旧随卡走(工单详情 / 归档件仍有),只是对话区这张卡不摆 */}
{order.fields.map((f) => ( {order.fields.map((f) => (
<label key={f.key} className="jc-fc-fld"> <label key={f.key} className="jc-fc-fld">
<span> <span>
@@ -12,7 +12,7 @@
*/ */
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { collect, setOpenIn } from '../src/ViewContainer.tsx'; import { collect, mirrorTwins, setOpenIn } from '../src/ViewContainer.tsx';
/** 两张芯片卡:正面是主数据格(profile),背面各住一个参数宿主(parameter) */ /** 两张芯片卡:正面是主数据格(profile),背面各住一个参数宿主(parameter) */
const FLIP_CARDS = const FLIP_CARDS =
@@ -73,3 +73,31 @@ describe('多宿主 collect():同一地址的宿主都数进来,格子只归最
expect(hosts.every((h) => !h.classList.contains('on'))).toBe(true); expect(hosts.every((h) => !h.classList.contains('on'))).toBe(true);
}); });
}); });
describe('mirrorTwins:下拉镜像的是选项文案,不是 value(2026-08-31「保存一瞬间变数字」)', () => {
const SEL =
'<div data-edit="parts.profile"><div class="row">' +
'<span class="jc-vcell tsel">旧名</span>' +
'<select class="jc-cell" data-col="chip_type_id">' +
'<option value=""></option><option value="1">DC-DC 降压稳压器</option><option value="2">LDO 线性稳压器</option>' +
'</select>' +
'</div></div>';
it('镜进孪生件的是「DC-DC 降压稳压器」,不是「1」;selected 属性拨到人选的那一项(守卫 CSS 认它)', () => {
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = SEL;
const sel = doc.querySelector('select') as HTMLSelectElement;
sel.value = '1';
mirrorTwins(doc, 'parts.profile');
expect(doc.querySelector('.tsel')?.textContent).toBe('DC-DC 降压稳压器');
expect(doc.querySelector('option[value="1"]')?.hasAttribute('selected')).toBe(true);
expect(doc.querySelector('option[value="2"]')?.hasAttribute('selected')).toBe(false);
});
it('没选(空占位)= 没答:孪生件一个字不动', () => {
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = SEL;
mirrorTwins(doc, 'parts.profile');
expect(doc.querySelector('.tsel')?.textContent).toBe('旧名');
});
});
+14 -1
View File
@@ -280,12 +280,25 @@ export function collect(doc: Document, address: string): Array<Record<string, st
* 而 `twinInputs` 是把 input **紧接着**只读那份写出来的,前一个兄弟就是它的孪生件。 * 而 `twinInputs` 是把 input **紧接着**只读那份写出来的,前一个兄弟就是它的孪生件。
* 这条是在真单的 DOM 上查出来的:合成 fixture 里我两份都写了 data-slot,于是照样绿。 * 这条是在真单的 DOM 上查出来的:合成 fixture 里我两份都写了 data-slot,于是照样绿。
*/ */
function mirrorTwins(doc: Document, address: string): void { export function mirrorTwins(doc: Document, address: string): void {
for (const cell of cellsOf(doc, address)) { for (const cell of cellsOf(doc, address)) {
// 与 collect 同一条规矩:没答的不算话(「没点/没选」= 不改) // 与 collect 同一条规矩:没答的不算话(「没点/没选」= 不改)
if (unanswered(cell)) continue; if (unanswered(cell)) continue;
const twin = cell.previousElementSibling; const twin = cell.previousElementSibling;
if (twin === null || twin.tagName === 'INPUT' || twin.tagName === 'SELECT' || twin.tagName === 'TEXTAREA') continue; if (twin === null || twin.tagName === 'INPUT' || twin.tagName === 'SELECT' || twin.tagName === 'TEXTAREA') continue;
/*
* **下拉镜像的是选项文案,不是 value**(2026-08-31 用户:「保存的一瞬间芯片类型
* 下拉框的值会变成数字」)—— 下拉的 value 是机器码(type_id、tally 值),
* 摆给人看的向来是人读名;顺手把 selected 属性拨到人选的那一项:守卫类 CSS
* (:has(option[selected]))与「取消后再编辑」的回显都认它,不拨的话保存后到
* 重推回来那一段,屏上仍按旧选择说话。
*/
if (cell.tagName === 'SELECT') {
const sel = cell as HTMLSelectElement;
for (const o of Array.from(sel.options)) o.toggleAttribute('selected', o.value === sel.value && sel.value !== '');
twin.textContent = sel.selectedOptions[0]?.textContent ?? sel.value;
continue;
}
twin.textContent = cell.value; twin.textContent = cell.value;
} }
} }
+8 -49
View File
@@ -604,22 +604,9 @@ const SUITES = {
edits: [], edits: [],
extraTables: [ extraTables: [
/* /*
* `parts.chip_type` **没有任何 `data-edit` 绑定**(manifest.ts / case-model.ts * 2026-08-31「只选不建」:parts.chip_type / parts.type_param 两条随功能退役 ——
* 头注明写:新建类型时人在 `parts.type_param` 那张表加行,`parts.chip_type` 那 * S1 的写授权面(writes)已收窄,再喂这两张表会被 400 拒。字典只在治理侧维护。
* 张字典表本身没有编辑面 —— `ViewContainer.addRow` 靠克隆末行实现「加一行」,
* 而它在任何一单里都是从零行起,没有可克隆的样子)。写的授权面是**台账声明**
* 不是「当下卡上有没有绑定」(`writesOf` 只认 `declarations.ledger`),所以这条
* 写入走得通 —— 只是脚本代脚本走 API,不是真人代替点了一个并不存在的按钮。
* 只有样例 0(候选缺库·新建类型)会喂 `edits`,其余案例这里留空、这张表不写。
*/ */
{ address: 'parts.chip_type', rowKey: 'type_id', blankCol: 'type_name', edits: [] },
{
// 复合主键 (type_id, param_name) —— 同一类型两个字段不能靠单列区分。
address: 'parts.type_param',
rowKey: ['type_id', 'param_name'],
blankCol: 'param_desc',
edits: [],
},
{ {
// 复合主键 (internal_pn, param_name) —— 两颗料同名参数(如都有「工作温度」) // 复合主键 (internal_pn, param_name) —— 两颗料同名参数(如都有「工作温度」)
// 不这样区分,rows.find 会永远命中第一条同名行、把不同料的值互相顶掉。 // 不这样区分,rows.find 会永远命中第一条同名行、把不同料的值互相顶掉。
@@ -634,8 +621,8 @@ const SUITES = {
cases: [ cases: [
{ {
sample: 0, sample: 0,
name: '候选缺库 · XL2596S-5.0 新建芯片类型(自增 type_id,字段清单沿用原型口径)入库,' name: '候选缺库 · XL2596S-5.0 建档并选已有类型(type_1,2026-08-31 起本活动只选不建)'
+ '原 PN 一并转挂新类型才够得上 distinct=1 · 审核先点名意见驳回再通过', + '· 审核先点名意见驳回再通过',
decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'], decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'],
expectDecision: true, expectDecision: true,
expectPort: 'assessment-released', expectPort: 'assessment-released',
@@ -660,39 +647,13 @@ const SUITES = {
* `type_id`,只是绕开这一条平台坑去验证后续链路,不代表浏览器里的真人也这样能走通。** * `type_id`,只是绕开这一条平台坑去验证后续链路,不代表浏览器里的真人也这样能走通。**
*/ */
customFill: async (taskId) => { customFill: async (taskId) => {
const typeId = '90'; /* 2026-08-31「只选不建」:两颗料都挂到既有的 type_1(DC-DC)—— 新建类型那条路
// ① 新建芯片类型:显式给 type_id(见上方大注释,不走 autoKeys 续号) (显式 type_id=90 绕全表一致坑的那整套)随功能移除,史见 git 2.9.0 及更早。 */
const typeId = '1';
await api(`/api/desk/tasks/${taskId}/table`, { await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
tables: { tables: {
'parts.chip_type': [
{
type_id: typeId,
type_name: 'DC-DC 降压稳压器(评估自定义)',
description: '本次评估自定义的降压转换器类型,核心参数字段清单沿用原类型口径,供原 PN 与候选料共用比对。',
},
],
},
}),
});
console.log(` 新建类型已保存,显式指定 type_id=${typeId}(绕开全表一致平台坑,已报未改)`);
// ③ 核心参数字段清单(逐字照抄 type_id=1 的字典行,见 seed-mvp-data.ts)+
// 两颗料都转挂新类型(distinct 要求同一个值)+ 候选料的参数值。
// LM2596S-5.0 的既有参数值按同名 lookup 自动带过来,不必在这里重填。
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({
tables: {
'parts.type_param': [
{ type_id: typeId, param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' },
{ type_id: typeId, param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' },
{ type_id: typeId, param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' },
{ type_id: typeId, param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' },
{ type_id: typeId, param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' },
{ type_id: typeId, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' },
{ type_id: typeId, param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' },
],
'parts.profile': [ 'parts.profile': [
{ internal_pn: 'LM2596S-5.0', chip_type_id: typeId }, { internal_pn: 'LM2596S-5.0', chip_type_id: typeId },
{ {
@@ -768,10 +729,8 @@ const SUITES = {
await db.query("delete from ledger.validation__record where validation_internal_pn = 'XL2596S-5.0'"); await db.query("delete from ledger.validation__record where validation_internal_pn = 'XL2596S-5.0'");
await db.query("delete from ledger.parts__parameter where internal_pn = 'XL2596S-5.0'"); await db.query("delete from ledger.parts__parameter where internal_pn = 'XL2596S-5.0'");
await db.query("delete from ledger.parts__profile where internal_pn = 'XL2596S-5.0'"); await db.query("delete from ledger.parts__profile where internal_pn = 'XL2596S-5.0'");
await db.query("delete from ledger.parts__type_param where type_id = '90'");
await db.query("delete from ledger.parts__chip_type where type_id = '90'");
}); });
console.log(' 跨案例隔离:LM2596S-5.0 已转回 type_1;这一例全新建的 XL2596S-5.0(候选料本身、评估结果、type_id=90 字典行)已整体清空'); console.log(' 跨案例隔离:XL2596S-5.0(这一例建档的候选料与它的评估结果)已整体清空;LM2596S-5.0 类型兜底归位 type_1');
}, },
}, },
{ {