From c4219a7b7dfa7c1a757bd51e25e655084d488c1f Mon Sep 17 00:00:00 2001 From: xcs Date: Mon, 31 Aug 2026 17:43:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(w05+platform):=20=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E9=97=B8=E6=9D=A1=E4=BB=B6=E6=A1=A3=20gateWhen=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=B1=BB=E5=9E=8B=E4=B8=8D=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E4=B8=8D=E8=AE=B8=E7=A1=AE=E8=AE=A4;=E9=87=8F=E9=AB=98?= =?UTF-8?q?=E6=94=B9=E5=8F=AF=E7=BC=A9;=E5=8D=A1=E9=97=B4=E4=BA=92?= =?UTF-8?q?=E6=8D=A2=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-31 用户三连: ① 「提示两颗料不是同一个芯片类型…就不应该能写入并继续下一步」—— 缺格闸只数必填空不空,管不了跨行一致性。平台加**确认闸条件档**:配方 card 声明 gateWhen(S-FEEL,只读基座读数)+ gateWhenSay,受理回传前在 submitGate 现算 (rows/missing/tally/distinct 全套读数按声明表现读),为假 400、拒回用发卡人那句话; 装包静态审与动作级 when 同一套审;评估不出的条件不成墙(坏表达式装包就拦)。 W05 钉 parts_profile_distinct_chip_type_id <= 1 —— 钉 profile 本表(草稿现读零滞后; 钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦),profile.chip_type_id 加 distinct 声明。e2e 案例 2 的空手探针从此吃的就是这道闸。 ② 「页面底部没东西还能向下滑一截」—— ViewContainer 量高从 max(documentElement.scrollHeight, body) 改 body 优先:documentElement 以 iframe 自身 高度为下限,内容缩回后它缩不回去,量高**只涨不缩**(参数编辑态把卡背拉长过一次, 底部从此留一截空白滚动)。body(margin 0)才是内容自己的高。 ③ 「两张卡片中间做出可替换的动画」—— 单点单向信号换成**对向双信号点**(左右互穿, 走在卡后、只在缝里现身)+ 中央 ⇄ 徽轻呼吸 —— 这界面是判「可否替换」的,动画说的 就是这件事。reduced-motion 全静。 测:契约 gateWhen 解析 + runner 透传(变异红)+ conditionGateOf 3 例(含坏表达式不成墙) + steps/activity 锚;2531 全仓四门绿;2.12.0 装库核过(gateWhen 入库/swapfx); :8787 已重建重启。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/activity.test.ts | 6 +++ .../__tests__/steps.test.ts | 6 +++ .../src/case-model.ts | 9 +++++ .../src/manifest.ts | 4 +- .../src/views/report.ts | 2 +- .../src/views/style.ts | 13 ++++++- .../packages/contracts/src/api/workbench.d.ts | 8 ++++ .../packages/contracts/src/auto-recipe.d.ts | 3 ++ api-report/packages/contracts/src/card.d.ts | 4 ++ .../src/app/__tests__/view-table-edit.test.ts | 17 ++++++++ apps/api/src/app/routes/desk.ts | 39 ++++++++++++++++++- .../internal/domain/static-audit.ts | 2 + apps/api/src/modules/loop-engine/CONTRACT.md | 4 ++ .../loop-engine/__tests__/auto-runner.test.ts | 5 +++ .../internal/usecase/auto-runner.ts | 3 ++ .../src/__tests__/auto-recipe.test.ts | 7 ++++ packages/contracts/src/auto-recipe.ts | 5 +++ packages/contracts/src/card.ts | 8 ++++ packages/ui-kit/src/ViewContainer.tsx | 11 +++++- tools/e2e-a2.mjs | 6 +-- 20 files changed, 151 insertions(+), 11 deletions(-) diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index 5c395c0..75eac39 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -628,6 +628,12 @@ describe('件① 评估输入补全', () => { expect(html).toContain('.dossier:has(.pback.on) .flip-in{transform:rotateY(180deg)}'); /* 悬浮提示丸:看得出点击可翻 */ expect(html).toContain('点击翻面'); + /* 两卡之间的「可替换」动画:对向双信号点 + 中央 ⇄ 徽(2026-08-31 用户定) */ + expect(html).toContain('class="swapfx"'); + expect(html).toContain('class="sw-dot sw-ltr"'); + expect(html).toContain('class="sw-dot sw-rtl"'); + expect(html).toContain('@keyframes swltr'); + expect(html).toContain('@keyframes swrtl'); }); it('整件零表格(2026-08-31 用户:「这个界面都不再需要表格来展示数据了」;抽屉连表带功能一并退役)', () => { diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index efe70da..9451424 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -241,6 +241,12 @@ describe('S1 配方', () => { expect(card?.kind === 'card' && card.as).toBe('execution'); expect(card?.kind === 'card' && card.variant).toBe('data'); expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.profile', 'parts.parameter']); + /* + * 类型不一致不许确认(2026-08-31 用户定)。钉 parts.profile 本表的 distinct —— + * 提交闸从草稿现读零滞后;钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦。 + */ + expect(card?.kind === 'card' && card.gateWhen).toBe('parts_profile_distinct_chip_type_id <= 1'); + expect(card?.kind === 'card' && typeof card.gateWhenSay === 'string' && card.gateWhenSay.includes('不是同一个芯片类型')).toBe(true); expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0); expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3); }); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 21daf83..42f7872 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -543,6 +543,15 @@ export const ASSESSMENT_STEPS = [ * 落地无对象、mine: 恒为空 —— 留在名单上反而会被「零行 = 没干完」的闸误拦死。 */ gateLedgers: ['parts.profile', 'parts.parameter'], + /* + * **类型不一致不许确认**(2026-08-31 用户:「提示两颗料不是同一个芯片类型…就不应该 + * 能写入并继续下一步」)。缺格闸管不了这件事(它只数必填空不空),条件档在受理 + * 回传前现算 —— 钉 **parts.profile 本表**的 distinct(提交闸从草稿现读,零滞后; + * 钉 sub_eval.part 的那份要等配方 tick 同步,人存完就点确认会被误拦)。 + * <= 1:两颗同型(1)或都还没选(0,那时缺格闸已经拦着)都放;>= 2 才是这堵墙。 + */ + gateWhen: 'parts_profile_distinct_chip_type_id <= 1', + gateWhenSay: '两颗料不是同一个芯片类型 —— 逐项比对无从谈起:换一颗料,或把归错的那颗类型改对,再来确认。', }, { kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' }, ], diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index c7708a7..f5c3075 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.11.0', + packageVersion: '2.12.0', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', @@ -347,7 +347,7 @@ export const manifest: ActivityManifest = { fields: [ col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }), /* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text —— 见该字段的大注释 */ - col({ name: 'chip_type_id', type: 'text', label: '芯片类型', required: true, refersTo: 'parts.chip_type.type_id' }), + col({ name: 'chip_type_id', type: 'text', label: '芯片类型', required: true, distinct: true, refersTo: 'parts.chip_type.type_id' }), col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }), col({ name: 'lifecycle_status', type: 'text', label: '生命周期状态', required: true, tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] }), col({ name: 'specification_description', type: 'text', label: '规格描述', expect: true }), diff --git a/activities/part-substitution-assessment/src/views/report.ts b/activities/part-substitution-assessment/src/views/report.ts index e8d1ccb..a7ceedd 100644 --- a/activities/part-substitution-assessment/src/views/report.ts +++ b/activities/part-substitution-assessment/src/views/report.ts @@ -208,7 +208,7 @@ ${badgeCss('.rl', [ * data-edit 祖先」,packages/ui-kit/__tests__/view-container-multihost.test.ts 钉着); * 开哪面的编辑,卡就停在哪面(主数据编辑锁正面、参数编辑自动翻背面,CSS 兜底)。 */ -const DOSSIERS = `
+const DOSSIERS = `
点击翻面 · 核心参数点击翻回 · 主数据 diff --git a/activities/part-substitution-assessment/src/views/style.ts b/activities/part-substitution-assessment/src/views/style.ts index 3f539d4..9c4628b 100644 --- a/activities/part-substitution-assessment/src/views/style.ts +++ b/activities/part-substitution-assessment/src/views/style.ts @@ -269,7 +269,17 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va repeating-linear-gradient(90deg, rgba(46,107,255,.045) 0 1px, transparent 1px 26px), linear-gradient(158deg, var(--acc-wash) 0%, #fff 62%, var(--acc-wash) 100%)} .board::before{content:"";position:absolute;left:4%;right:4%;top:50%;border-top:2px dashed var(--acc-line)} -.board::after{content:"";position:absolute;top:50%;left:4%;width:8px;height:8px;margin-top:-4px;border-radius:999px;background:var(--acc);box-shadow:0 0 8px 2px var(--acc-soft);animation:sig 3.4s linear infinite} +/* 「可替换」动画(2026-08-31 用户定:两卡之间要做出可互换的效果)—— + 两枚信号点沿总线**对向穿行**(左料换到右、右料换到左),中央一枚 ⇄ 徽轻呼吸; + 点走在卡片背后(z 序),只在两卡之间的缝里现身 —— 像两颗料在板上换位。 */ +.swapfx{position:absolute;left:0;right:0;top:50%;height:0;z-index:0;pointer-events:none;--swx:min(26vw,240px)} +.sw-ic{position:absolute;left:50%;top:0;transform:translate(-50%,-50%);width:34px;height:34px;padding:7px;box-sizing:border-box;color:var(--acc);background:#fff;border:1px solid var(--acc-line);border-radius:999px;box-shadow:0 4px 12px rgba(18,38,63,.14);z-index:2;animation:swbreathe 2.8s ease-in-out infinite} +.sw-dot{position:absolute;left:50%;top:-13px;width:8px;height:8px;margin-left:-4px;border-radius:999px;background:var(--acc);box-shadow:0 0 8px 2px var(--acc-soft)} +.sw-ltr{animation:swltr 3.2s linear infinite} +.sw-rtl{top:7px;animation:swrtl 3.2s linear infinite} +@keyframes swltr{0%{transform:translateX(calc(-1 * var(--swx)));opacity:0}12%{opacity:1}88%{opacity:1}100%{transform:translateX(var(--swx));opacity:0}} +@keyframes swrtl{0%{transform:translateX(var(--swx));opacity:0}12%{opacity:1}88%{opacity:1}100%{transform:translateX(calc(-1 * var(--swx)));opacity:0}} +@keyframes swbreathe{0%,100%{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.1)}} .dossiers{position:relative;z-index:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(272px,1fr));gap:16px} .dossier{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);transition:box-shadow .18s,transform .18s} .dossier:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(18,38,63,.13)} @@ -428,6 +438,7 @@ html.editable .dossier:not(.on):not(:has(.pback.on)):has(.flip-tg:checked) .pen- @media(max-width:620px){ .card-head{padding:16px 14px 18px} .dz-grid{grid-template-columns:1fr} + .swapfx{display:none} .pback{grid-template-columns:1fr} .ftiles{grid-template-columns:1fr} .card-bd{padding:16px 14px 6px} diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index 63f391d..50777ef 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -907,6 +907,8 @@ export declare const taskDetailSchema: z.ZodObject<{ }, z.core.$strip>>; submitLabel: z.ZodOptional; gateLedgers: z.ZodOptional>; + gateWhen: z.ZodOptional; + gateWhenSay: z.ZodOptional; gate: z.ZodOptional>; submitLabel: z.ZodOptional; gateLedgers: z.ZodOptional>; + gateWhen: z.ZodOptional; + gateWhenSay: z.ZodOptional; gate: z.ZodOptional>; submitLabel: z.ZodOptional; gateLedgers: z.ZodOptional>; + gateWhen: z.ZodOptional; + gateWhenSay: z.ZodOptional; gate: z.ZodOptional>; submitLabel: z.ZodOptional; gateLedgers: z.ZodOptional>; + gateWhen: z.ZodOptional; + gateWhenSay: z.ZodOptional; gate: z.ZodOptional>; submitLabel: z.ZodOptional; gateLedgers: z.ZodOptional>; + gateWhen: z.ZodOptional; + gateWhenSay: z.ZodOptional; gate: z.ZodOptional>; submitLabel: z.ZodOptional; gateLedgers: z.ZodOptional>; + gateWhen: z.ZodOptional; + gateWhenSay: z.ZodOptional; gate: z.ZodOptional { expect(decisionReadings([])).toEqual({ decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 }); }); }); + +describe('conditionGateOf:确认闸的条件档(2026-08-31,「类型不一致还能确认」逼出)', () => { + it('条件为假 → 不受理,拒回用发卡人那句话;为真 / 没声明 → 不拦', async () => { + const { conditionGateOf } = await import('../routes/desk.ts'); + const readings = { parts_profile_distinct_chip_type_id: 2 }; + const blocked = conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '两颗料不是同一个芯片类型 —— 先改对再确认。', readings); + expect(blocked?.ready).toBe(false); + expect(blocked?.why).toContain('不是同一个芯片类型'); + expect(conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '话', { parts_profile_distinct_chip_type_id: 1 })).toBeUndefined(); + expect(conditionGateOf(undefined, undefined, readings)).toBeUndefined(); + }); + + it('表达式炸了不成墙(装包静态审管坏表达式;运行时评估不出的条件不该锁死一单)', async () => { + const { conditionGateOf } = await import('../routes/desk.ts'); + expect(conditionGateOf('((((', '话', {})).toBeUndefined(); + }); +}); diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index 3e06034..29b0838 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -45,7 +45,8 @@ function rowsOf(result: unknown): T[] { } import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; -import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts'; +import { evaluateCondition } from '@sfeel'; +import { coerceToColumn, distinctReadings, missingReadings, rowLabel, rowReadings, tallyReadings } from '../wiring/engines.ts'; import { orderChat } from '../chat-order.ts'; @@ -816,7 +817,41 @@ async function submitGate( for (const draft of await container.ledger.taskDrafts(taskId)) { (mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch }); } - return emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched); + const base = emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched); + if (!base.ready) return base; + /* + * **条件档**(2026-08-31,W05「两颗料不是同一个芯片类型还能确认」逼出):缺格闸都过了, + * 发卡的人还声明了一条 S-FEEL 条件的,再按**全部声明表的基座读数**现算一遍 —— 为假就 + * 不受理。读数从本单草稿现读(rows/missing/tally/distinct 同一套函数),没有 tick 滞后: + * 人刚在件上改完类型、点确认,算的就是改完之后的样子。 + */ + return ( + conditionGateOf(order.gateWhen, order.gateWhenSay, { + ...rowReadings(mine, manifest.declarations.ledger), + ...missingReadings(mine, manifest.declarations.ledger), + ...tallyReadings(mine, manifest.declarations.ledger), + ...distinctReadings(mine, manifest.declarations.ledger), + }) ?? base + ); +} + +/** + * 确认闸的条件档 —— S-FEEL 为假就交不了,拒回用发卡人写的那句话(display 与服务端同一段)。 + * 条件只读基座读数(机检纪律)。表达式炸了按「不拦」:坏表达式装包时静态审就该拦 + * (checkWhen 已审),运行时评估不出的条件不该成墙(「没有依据的时候不拦人」)。 + */ +export function conditionGateOf( + when: string | undefined, + say: string | undefined, + readings: Readonly>, +): { ready: boolean; why: string; blanks: string[] } | undefined { + if (when === undefined || when.trim() === '') return undefined; + try { + if (evaluateCondition(when, readings)) return undefined; + } catch { + return undefined; + } + return { ready: false, why: say ?? '这张卡声明的确认条件还不成立 —— 回件上把它办对再来确认。', blanks: [] }; } /** diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts index fe77e2f..4111f5c 100644 --- a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -612,6 +612,8 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi const where = `${at}.do[${index}]`; /* 动作级 `when`:语法 + 名字都审(它看得见的只有 scope 与 let,没有行内列) */ if (action.when !== undefined) checkWhen(action.when, where, letNames); + /* 确认闸的条件档与动作级 when 同一作用域(基座读数),同一套审 */ + if (action.kind === 'card' && action.gateWhen !== undefined) checkWhen(action.gateWhen, where, letNames); if (action.kind === 'project') { checkAddress(action.into, where); walkSource(action.from, where); diff --git a/apps/api/src/modules/loop-engine/CONTRACT.md b/apps/api/src/modules/loop-engine/CONTRACT.md index 84949c0..0a7d5ab 100644 --- a/apps/api/src/modules/loop-engine/CONTRACT.md +++ b/apps/api/src/modules/loop-engine/CONTRACT.md @@ -183,6 +183,10 @@ (2026-08-30 e2e 案例 2 的死锁,当时以「W05 ②③ 两笔不带 prune」绕开,代价是**没落地的** 陈旧行也不清 —— 2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」)。 模型那一路不传:那句「已入库撤不掉」的拒回是它的行动指南,一个字不变。 +- 配方 `card` 多两个可选键 `gateWhen` / `gateWhenSay`(2026-08-31):确认闸的**条件档** —— + 除了「必填缺几格」,发卡的人还可声明一条 S-FEEL 条件(只读基座读数,装包静态审同 + `when` 一套审),受理回传前在 app 层 `submitGate` 现算,为假不受理、拒回用发卡人那句话 + (W05 第一例:两颗料不是同一个芯片类型不许确认)。运行时评估不出的条件不成墙。 同批三条不变量: - **配方由 `liveStep` 驱动,不由 `current` 的兜底驱动**:兜底(在场的第一个有剧本的项)不过滤 diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index d5c2e38..6c5790c 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -398,6 +398,8 @@ describe('card 与 submit', () => { standard: '必填格全部填上,红带消失', basis: [{ k: '候选料号', v: { in: 'pn' } }], gateLedgers: ['parts.profile', 'parts.parameter'], + gateWhen: 'parts_profile_distinct_chip_type_id <= 1', + gateWhenSay: '两颗料不是同一个芯片类型 —— 先改对再确认。', }, ], }, @@ -415,6 +417,9 @@ describe('card 与 submit', () => { standard: '必填格全部填上,红带消失', basis: [{ k: '候选料号', v: 'XL2596S-5.0' }], gateLedgers: ['parts.profile', 'parts.parameter'], + /* 确认闸条件档随卡走(2026-08-31)—— 服务端 submitGate 靠它拦「类型不一致还确认」 */ + gateWhen: 'parts_profile_distinct_chip_type_id <= 1', + gateWhenSay: '两颗料不是同一个芯片类型 —— 先改对再确认。', fields: [], }, }); diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index a84ec8d..f9cb4aa 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -371,6 +371,9 @@ export function createAutoRunner(deps: AutoRunnerDeps): { ...(action.guide === undefined ? {} : { steps: [...action.guide] }), ...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }), ...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }), + ...(action.gateWhen === undefined ? {} : { gateWhen: action.gateWhen }), + + ...(action.gateWhenSay === undefined ? {} : { gateWhenSay: action.gateWhenSay }), }, }); return { wrote: 0, dropped: 0, cardId: id, stop: true }; diff --git a/packages/contracts/src/__tests__/auto-recipe.test.ts b/packages/contracts/src/__tests__/auto-recipe.test.ts index 44668ca..6de8b95 100644 --- a/packages/contracts/src/__tests__/auto-recipe.test.ts +++ b/packages/contracts/src/__tests__/auto-recipe.test.ts @@ -35,6 +35,13 @@ describe('auto 配方契约', () => { expect(junk.success).toBe(false); }); + it('card 收确认闸条件档 gateWhen/gateWhenSay(2026-08-31,类型不一致不许确认)', () => { + const ok = autoRecipeSchema.safeParse({ + do: [{ kind: 'card', as: 'execution', title: 't', gateWhen: 'a_b_distinct_c <= 1', gateWhenSay: '先改对再确认' }], + }); + expect(ok.success).toBe(true); + }); + it('prune 只许挂在 project 上', () => { const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'say', text: 'x', prune: true }] }); expect(parsed.success).toBe(false); diff --git a/packages/contracts/src/auto-recipe.ts b/packages/contracts/src/auto-recipe.ts index f175687..73d5426 100644 --- a/packages/contracts/src/auto-recipe.ts +++ b/packages/contracts/src/auto-recipe.ts @@ -139,6 +139,9 @@ export type AutoAction = readonly basis?: readonly { readonly k: string; readonly v: AutoExpr }[] | undefined; /** 这张卡的提交闸盯哪几张表 */ readonly gateLedgers?: readonly string[] | undefined; + /** 确认闸的条件档(S-FEEL,只读基座读数;为假不受理确认)+ 拒回那句话 —— 见 card.ts 同名字段 */ + readonly gateWhen?: string | undefined; + readonly gateWhenSay?: string | undefined; /** 决策卡的选项;`variant:'review'` 时允许只有一个 */ readonly options?: | readonly { readonly value: string; readonly label: string; readonly detail?: string | undefined }[] @@ -181,6 +184,8 @@ export const autoActionSchema: z.ZodType = z.discriminatedUnion('kin standard: autoExprSchema.optional(), basis: z.array(z.object({ k: z.string().min(1), v: autoExprSchema }).strict()).optional(), gateLedgers: z.array(z.string().min(1)).optional(), + gateWhen: z.string().min(1).optional(), + gateWhenSay: z.string().min(1).optional(), options: z .array(z.object({ value: z.string().min(1), label: z.string().min(1), detail: z.string().optional() }).strict()) .optional(), diff --git a/packages/contracts/src/card.ts b/packages/contracts/src/card.ts index f7f5367..8598fa3 100644 --- a/packages/contracts/src/card.ts +++ b/packages/contracts/src/card.ts @@ -378,6 +378,14 @@ export const executionWorkOrderSchema = z.object({ * 优先级:卡上绑了表(`blocks`)> 这一项 > 必交付物声明。 */ gateLedgers: z.array(z.string()).optional(), + /** + * **确认闸的条件档**(2026-08-31,W05「类型不一致还能确认」逼出):除了「必填缺几格」, + * 发卡的人还可以声明一条 S-FEEL 条件(只读基座读数),为假就不受理确认 —— + * 服务端在受理回传前现算(与缺格闸同一段 submitGate),display 与拒回同一句话 + * (`gateWhenSay`)。机检只读基座数的纪律在这儿同样成立:条件里只许出现读数名。 + */ + gateWhen: z.string().optional(), + gateWhenSay: z.string().optional(), gate: z .object({ /** 能交了吗 */ diff --git a/packages/ui-kit/src/ViewContainer.tsx b/packages/ui-kit/src/ViewContainer.tsx index f04c863..c5cf0e4 100644 --- a/packages/ui-kit/src/ViewContainer.tsx +++ b/packages/ui-kit/src/ViewContainer.tsx @@ -476,8 +476,15 @@ export function ViewContainer({ html, title = '活动视图', editable = false, * 填回表的尾巴(实测 A2 工作单:量得 2402、实际 2524)。 */ bindRef.current(); - // 取 body 与 documentElement 的较大者:两者谁撑开高度随文档结构而异 - const next = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0); + /* + * **量 body,不量 documentElement**(2026-08-31 用户:「页面底部都没东西了, + * 怎么还可以向下滑动一截」):documentElement.scrollHeight 以 iframe 自身高度为下限 —— + * 内容一旦把 iframe 撑高过一次(比如参数编辑态输入框把卡背拉长),收起后它也缩不回去, + * 量高从此**只涨不缩**,底部留一截空白滚动。body(margin 0、height auto)的 scrollHeight + * 才是内容自己的高;body 读不出(异常文档)再退回 documentElement。 + */ + const body = doc.body?.scrollHeight ?? 0; + const next = body > 0 ? body : doc.documentElement.scrollHeight; if (next > 0) setHeight(next); }, []); diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index 3ad53f6..e6f1ec9 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -747,9 +747,9 @@ const SUITES = { name: '两颗都在库但分属不同芯片类型 · 硬拦(填别的格子绕不过去,只有改类型才行)· 审核通过', // gate_reason 要点名「不是同一个芯片类型」——数据卡一出现就查这句话,latch 住 expectGateReason: /芯片类型/, - // 唯一的修复是把其中一颗转挂到另一颗的类型上;缺的那一个新必填参数一并补上, - // 不改类型光填别的格子过不去(S1 的 doneWhen/distinct 与提交闸的必填检查不是同一件事—— - // 提交闸不查 distinct,这条路真正的硬拦在 S2 的入场哨兵,详见任务报告)。 + // 唯一的修复是把其中一颗转挂到另一颗的类型上;缺的那一个新必填参数一并补上。 + // 2026-08-31 起确认闸多了条件档(gateWhen 钉 parts_profile_distinct_chip_type_id): + // 类型不一致时确认直接 400 —— 上面的空手交卷探针吃到的就是它;修好类型再确认才收。 stageEdits: { 'parts.profile': [{ internal_pn: 'LM2596S-5.0', chip_type_id: '2' }], // 转去 LDO 类型后,LM2596S-5.0 多出一条它从没记过的必填参数「压差」