/** * T12 工单状态时间线 纯逻辑检查(票 12)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t12-work-order-timeline.mjs * * 六段: * A 状态字典:0–7 共 8 个码齐全、顺序即业务顺序、只有已结案(5)/已取消(7)是终态 * B 动作判定:推进 / 回退 / 取消 / 旁路(延期中) / 同态 / 认不出的码(**回退是动作,不是终态**) * C 轨迹组装:按 operatedAt 升序、走到过的最远处、回退计数、每张工单一条 * D 状态路径视图:码表一个不少、当前态与"走到过"分开、已取消与延期中都是路径外 * E 分区独立:时间线分区失败 ≠ 没有动作记录(界面必须说两件不同的事) * F 静态守卫:没有伪状态、没有写操作、组件不认后端裸字段 * * 契约依据:后端 `WorkOrderStatusEnum`(`com.zksy.ops.enums`,0–7 是唯一真理)、 * `CockpitWorkOrderStateDictionary`、`CockpitWorkOrderTimelineResponse/Entry` * (`E:\pipe-ner-server\...\cockpit\workorder\`)。 * * ⚠️ 票 20 修正:本脚本原先按旧的 1–7「七态」断言,并有一条 `B6-no-delay-action-exists` * 要求"没有延期档"。但后端 0–7 里 **6 就是「延期中」**(处理旁路,`ACTIVE_CODES` 含它), * 所以该断言守的是一个已被后端推翻的假设。现按 0–7 重写,并新增旁路(BYPASS)覆盖。 */ import { readFileSync } from 'node:fs' import { createPinia, setActivePinia } from 'pinia' import { WORK_ORDER_STATES, workOrderStateName } from '../../../src/modules/cockpit/domain/workOrder.js' import { TIMELINE_ACTION, WORK_ORDER_CANCELLED_CODE, WORK_ORDER_PATH, isOnWorkOrderPath, isWorkOrderStateCode, pathOnlyTrack, pathView, stepAction, trackOf, tracksByOrder, } from '../../../src/modules/cockpit/domain/workOrderTimeline.js' import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js' import { timelineSource, workOrderRows } from '../../../src/modules/cockpit/domain/equipmentDetailView.js' import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js' import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js' import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js' const checks = [] /** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */ function check(name, pass, evidence) { checks.push({ name, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`) } const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } } const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url) const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8') /** 去掉注释只留代码:本文件的注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率。 */ const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') const readCode = (relative) => code(read(relative)) /** `.vue` 的注释是 ``(HTML),上面那个只剥 JS 注释的 `code()` 剥不掉。 */ const codeVue = (text) => code(text.replace(//g, '')) const readVueCode = (relative) => codeVue(read(relative)) setActivePinia(createPinia()) /** 造一条后端轨迹记录(字段名照抄 `CockpitWorkOrderTimelineEntry`)。 */ const entry = (logId, operationName, operatedAt, stateCode, stateName, extra = {}) => ({ logId, operationName, operationDesc: workOrderStateName(stateCode), operatorName: '系统', operatedAt, stateCode, stateName, ...extra, }) /** * E9b/E9c 的输入:一份**领域工单对象**(`toWorkOrder()` 的形状),走 `workOrderRows()` 成行视图, * 再喂给 `pathOnlyTrack()` —— 这条链正是界面在"只有轨迹分区失败"时走的路径。 */ const TIMELINE_ORDER_ROWS_FOR_TEST = [{ code: 'WO-PATH-ONLY', stateCode: 4, stateName: '待验收' }] /** 造一份 `toWorkOrderTimeline()` 形状的领域轨迹(本段只测纯函数,不经过适配层)。 */ const track = (code, entries) => trackOf({ workOrder: { code, stateCode: entries.length ? entries[entries.length - 1].stateCode : null, stateName: '' }, entries, }) // ================================================================ A 状态字典(后端 0–7) { check( 'A1-seven-states-complete', WORK_ORDER_STATES.length === 8 && WORK_ORDER_STATES.map((state) => state.code).join('') === '01234567' && WORK_ORDER_STATES.map((state) => state.name).join(',') === '待派单,待接单,已接单,处理中,待验收,已结案,延期中,已取消', `状态码表:${WORK_ORDER_STATES.map((state) => `${state.code}${state.name}`).join(' / ')}`, ) check( 'A2-only-closed-and-cancelled-are-terminal', WORK_ORDER_STATES.filter((state) => state.terminal).map((state) => state.code).join(',') === '5,7', '只有已结案(5)/已取消(7)是终态;**6 延期中不是终态而是处理旁路**(后端 ACTIVE_CODES 含它)', ) check( 'A2b-delay-is-bypass-not-terminal', WORK_ORDER_STATES.filter((state) => state.bypass).map((state) => state.code).join(',') === '6' && WORK_ORDER_STATES.find((state) => state.code === 6).name === '延期中', '票 20:6 延期中是正式状态(后端枚举旁路),既非终态也不在推进主线上', ) check( 'A3-path-excludes-cancelled-and-delay', WORK_ORDER_PATH.join(',') === '0,1,2,3,4,5' && !isOnWorkOrderPath(WORK_ORDER_CANCELLED_CODE) && !isOnWorkOrderPath(6), `推进主线 ${WORK_ORDER_PATH.join('→')};已取消(${WORK_ORDER_CANCELLED_CODE}) 与延期中(6) 都在主线外`, ) check( 'A4-unknown-code-not-a-state', isWorkOrderStateCode(8) === false && isWorkOrderStateCode(null) === false && isWorkOrderStateCode(3) === true, '认不出的码不算码表之一(不猜、不冒充状态名)', ) } // ================================================================ B 动作判定 { check( 'B1-forward', stepAction(0, 1) === TIMELINE_ACTION.FORWARD && stepAction(3, 4) === TIMELINE_ACTION.FORWARD && stepAction(4, 5) === TIMELINE_ACTION.FORWARD, '码变大 = 流转', ) // 本票的核心验收:验收驳回 = 4→3 的**回退动作**,不是多出来的一个状态。 check( 'B2-reject-is-rollback-not-terminal', stepAction(4, 3) === TIMELINE_ACTION.ROLLBACK && stepAction(3, 2) === TIMELINE_ACTION.ROLLBACK && TIMELINE_ACTION.ROLLBACK !== TIMELINE_ACTION.CANCEL, '待验收(4) → 处理中(3) 判成回退动作(不是终态、也不是取消)', ) check( 'B3-first-step-is-start', stepAction(null, 0) === TIMELINE_ACTION.START && stepAction(undefined, 4) === TIMELINE_ACTION.START, '轨迹第一条是"从无到有",不算回退', ) check( 'B4-cancel-and-after-cancel', stepAction(1, 7) === TIMELINE_ACTION.CANCEL && stepAction(7, 3) === TIMELINE_ACTION.UNKNOWN, '取消是路径外动作;取消之后又出现动作是认不出的形状(不猜)', ) check( 'B5-hold-and-unknown-code', stepAction(3, 3) === TIMELINE_ACTION.HOLD && stepAction(3, 99) === TIMELINE_ACTION.UNKNOWN && stepAction(3, null) === TIMELINE_ACTION.UNKNOWN, '同态不是回退;认不出的码既不往前算也不当回退', ) // 票 20 改写了本条:旧断言要求"没有延期档",但后端 0–7 里 6 就是延期中。 // 现在要守的是"延期是**旁路**,既不冒充主线前进、也不算回退/取消"。 check( 'B6-delay-is-bypass-action', stepAction(3, 6) === TIMELINE_ACTION.BYPASS && stepAction(6, 3) === TIMELINE_ACTION.BYPASS && stepAction(3, 6) !== TIMELINE_ACTION.FORWARD && stepAction(3, 6) !== TIMELINE_ACTION.ROLLBACK, `进入/离开延期中都判成旁路动作(${TIMELINE_ACTION.BYPASS}),不冒充主线前进`, ) } // ================================================================ C 轨迹组装 { const rollback = track('WO-RB', [ entry(1, '创建工单', '2026-09-02T09:00:00+08:00', 0, '待派单'), entry(2, '派单', '2026-09-02T09:10:00+08:00', 1, '待接单'), entry(3, '接单', '2026-09-02T09:20:00+08:00', 2, '已接单'), entry(4, '开始处置', '2026-09-02T09:40:00+08:00', 3, '处理中'), entry(5, '提交验收', '2026-09-02T11:30:00+08:00', 4, '待验收'), entry(6, '验收驳回', '2026-09-02T12:00:00+08:00', 3, '处理中'), ]) check( 'C1-steps-in-order', rollback.steps.map((step) => step.operationName).join(',') === '创建工单,派单,接单,开始处置,提交验收,验收驳回', `六步:${rollback.steps.map((step) => step.operationName).join(' → ')}`, ) check( 'C2-rollback-counted-and-flagged', rollback.rollbackCount === 1 && rollback.hasRollback === true && rollback.steps[5].isRollback === true, '回退恰好一次,且回退那一步带 isRollback 标记', ) check( 'C3-rollback-carries-from-state', rollback.steps[5].fromStateName === '待验收' && rollback.steps[5].stateName === '处理中' && rollback.steps[5].actionLabel === '回退', `回退记录如实说「自${rollback.steps[5].fromStateName}」→「${rollback.steps[5].stateName}」`, ) // 回退之后"当前态"是 3,但"走到过的最远处"是 4 —— 两者必须分开,否则回退看不见。 check( 'C4-reached-is-furthest-not-current', rollback.stateCode === 3 && rollback.reachedCode === 4, `当前 ${rollback.stateCode} 处理中,但走到过 ${rollback.reachedCode} 待验收(回退正是靠这个差表现)`, ) const cancelled = track('WO-CX', [ entry(1, '创建工单', '2026-09-02T07:00:00+08:00', 0, '待派单'), entry(2, '派单', '2026-09-02T07:10:00+08:00', 1, '待接单'), entry(3, '取消工单', '2026-09-02T07:30:00+08:00', 7, '已取消'), ]) check( 'C5-cancel-is-not-a-path-step', cancelled.reachedCode === 1 && cancelled.steps[2].action === TIMELINE_ACTION.CANCEL && cancelled.rollbackCount === 0, `取消轨迹:走到过 ${cancelled.reachedCode},取消不算回退、也不算推进到 7`, ) const unknown = track('WO-XX', [entry(1, '状态不明的动作', '2026-09-02T09:00:00+08:00', 99, '已驳回')]) check( 'C6-unknown-code-does-not-become-a-state', unknown.steps[0].action === TIMELINE_ACTION.UNKNOWN && unknown.steps[0].stateCode === null && unknown.steps[0].stateName === '', '后端给了码表外的码:如实留空动作与状态名,**不**把「已驳回」当多出来的状态接受', ) const empty = trackOf({ workOrder: { code: 'WO-E', stateCode: 5 }, entries: [] }) check( 'C7-empty-track-is-honest', empty.entryCount === 0 && empty.steps.length === 0 && empty.stateCode === 5 && empty.stateName === '已结案', '没有轨迹记录时条数为 0(界面说"没有动作记录",而当前态仍来自工单自己)', ) // 同一编码重复出现:保留第一条,但**不静默丢弃**多出来的那条。 const grouped = tracksByOrder([ { workOrder: { code: 'WO-1', stateCode: 3 }, entries: [entry(1, '创建工单', 'T1', 0, '待派单')] }, { workOrder: { code: 'WO-1', stateCode: 4 }, entries: [entry(2, '提交验收', 'T2', 4, '待验收')] }, { workOrder: { code: '', stateCode: 3 }, entries: [] }, ]) check( 'C8-grouping-keeps-first-and-reports-duplicates', !!grouped.byCode['WO-1'] && grouped.byCode['WO-1'].entryCount === 1 && grouped.duplicates.join(',') === 'WO-1' && grouped.orphans.length === 1, `每张工单一条:保留第一条、重复的进 duplicates(${grouped.duplicates.join(',')})、无编码的进 orphans`, ) } // ================================================================ D 状态路径视图 { const rollback = track('WO-RB', [ entry(1, '创建工单', '2026-09-02T09:00:00+08:00', 0, '待派单'), entry(2, '派单', '2026-09-02T09:10:00+08:00', 1, '待接单'), entry(3, '接单', '2026-09-02T09:20:00+08:00', 2, '已接单'), entry(4, '开始处置', '2026-09-02T09:40:00+08:00', 3, '处理中'), entry(5, '提交验收', '2026-09-02T11:30:00+08:00', 4, '待验收'), entry(6, '验收驳回', '2026-09-02T12:00:00+08:00', 3, '处理中'), ]) const nodes = pathView(rollback) check( 'D1-path-always-has-seven-nodes', nodes.length === 8 && nodes.map((node) => node.code).join('') === '01234567', `路径永远这八个节点:${nodes.map((node) => node.name).join('→')}(驳回**不**多出节点)`, ) check( 'D2-current-vs-reached', nodes.find((node) => node.code === 3).current === true && nodes.find((node) => node.code === 4).reached === true && nodes.find((node) => node.code === 4).current === false && nodes.find((node) => node.code === 5).reached === false, '当前态=处理中(3);待验收(4) 标"走到过";已结案(5) 没到过', ) check( 'D3-cancelled-node-is-off-path', nodes.find((node) => node.code === 7).offPath === true && nodes.filter((node) => node.onPath).length === 6, '已取消是**路径外**节点(不接在已结案后面冒充下一步)', ) // 票 20 新增:6 延期中也是路径外,且必须与"路径外终态"分开标注。 check( 'D3b-delay-node-is-off-path-bypass', nodes.find((node) => node.code === 6).offPath === true && nodes.find((node) => node.code === 6).bypass === true && nodes.find((node) => node.code === 7).bypass === false, '延期中(6) 是路径外的**旁路**(bypass),与已取消(7) 的"路径外终态"不是一回事', ) const cancelledNodes = pathView(track('WO-CX', [entry(1, '取消工单', 'T', 7, '已取消')])) check( 'D4-cancelled-current-is-off-path-only', cancelledNodes.find((node) => node.code === 7).current === true && cancelledNodes.filter((node) => node.reached).length === 1, '已取消工单:只有路径外的那个节点是亮的,推进主线六个节点都不算"走到过"', ) } // ================================================================ E 分区独立(真契约 mock 走一遍) { // 工单列表成功 + 时间线成功:每张工单一条独立轨迹(验收第 2 条)。 const store = useDetailStore() store.reset() store.configure({ config: mockConfig }) resetMockTransportsForTest() registerCockpitMocks({ scenario: 'work-order-timeline', handlers: { registerMockTransport, resetMockTransportsForTest } }) await store.selectDevice('TC-PS-WS-202609020012') const source = timelineSource(store.sections.equipmentAggregate) const codes = Object.keys(source.tracks).sort() check( 'E1-every-order-has-its-own-track', codes.join(',') === 'WO-TL-CANCELLED,WO-TL-DELAYED,WO-TL-FORWARD,WO-TL-ROLLBACK', `四张工单各自一条:${codes.join(' / ')}`, ) check( 'E2-tracks-differ-per-order', source.tracks['WO-TL-FORWARD'].entryCount === 5 && source.tracks['WO-TL-ROLLBACK'].entryCount === 6 && source.tracks['WO-TL-DELAYED'].entryCount === 6 && source.tracks['WO-TL-CANCELLED'].entryCount === 3, `条数各不相同(${['WO-TL-FORWARD', 'WO-TL-ROLLBACK', 'WO-TL-DELAYED', 'WO-TL-CANCELLED'].map((c) => source.tracks[c].entryCount).join('/')})—— 不是同一份轨迹抄四遍`, ) check( 'E3-rollback-order-is-the-rollback-shape', source.tracks['WO-TL-ROLLBACK'].hasRollback === true && source.tracks['WO-TL-ROLLBACK'].stateCode === 3 && source.tracks['WO-TL-ROLLBACK'].reachedCode === 4 && source.tracks['WO-TL-FORWARD'].hasRollback === false, '回退那张:当前处理中、走到过待验收、恰一次回退;推进那张零回退(对照,证明判据有牙)', ) // 票 20 新增:延期中那张的形态 —— 当前态是旁路 6,且**不算回退**(旁路不是倒退)。 check( 'E3b-delay-order-is-the-bypass-shape', source.tracks['WO-TL-DELAYED'].stateCode === 6 && source.tracks['WO-TL-DELAYED'].hasRollback === false && source.tracks['WO-TL-DELAYED'].steps[5].action === TIMELINE_ACTION.BYPASS && source.tracks['WO-TL-DELAYED'].reachedCode === 3, `延期中那张:当前态 6(旁路)、零回退、最后一步是旁路动作、走到过的最远处仍是主线 3`, ) check( 'E4-timeline-section-state-is-success', source.state === SECTION_STATE.SUCCESS && source.hasContent === true && source.placeholder === '', `时间线分区 ${source.state},没有占位说明`, ) // 只有时间线分区失败:界面必须说「轨迹没取到」,而不是「这张工单没有动作记录」。 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'work-order-timeline-failed', handlers: { registerMockTransport, resetMockTransportsForTest } }) const downStore = useDetailStore() downStore.reset() downStore.configure({ config: mockConfig }) await downStore.selectDevice('TC-PS-WS-202609020012') const downSource = timelineSource(downStore.sections.equipmentAggregate) const downPanels = downStore.sections.equipmentAggregate.data.sections check( 'E5-timeline-failure-is-its-own-state', downSource.state === SECTION_STATE.FAILED && downSource.hasContent === false && Object.keys(downSource.tracks).length === 0 && downSource.placeholder.includes('加载失败') && downSource.placeholder.includes('工单动作轨迹'), `轨迹分区失败时:占位说明「${downSource.placeholder}」,且**不**留下空轨迹表冒充有数据`, ) check( 'E6-order-list-still-succeeds-alongside', downPanels.workOrders.state === SECTION_STATE.SUCCESS && downPanels.timelines.state === SECTION_STATE.FAILED && downPanels.archive.state === SECTION_STATE.SUCCESS, `工单列表照常 ${downPanels.workOrders.state},只有轨迹分区 ${downPanels.timelines.state}(分区独立,不互相遮盖)`, ) check( 'E7-failure-text-differs-from-no-records', downSource.placeholder !== '' && downSource.placeholder !== '这张工单没有动作记录' && !/没有动作记录/.test(downSource.placeholder), `「${downSource.placeholder}」(没取到)与「这张工单没有动作记录」(确实没有)是两句不同的话`, ) // 空场景:时间线分区为空时也不画空轨迹。 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } }) const emptyStore = useDetailStore() emptyStore.reset() emptyStore.configure({ config: mockConfig }) await emptyStore.selectDevice('TC-PS-WS-202609020012') const emptySource = timelineSource(emptyStore.sections.equipmentAggregate) check( 'E8-empty-timeline-draws-nothing', emptySource.state === SECTION_STATE.EMPTY && emptySource.hasContent === false && Object.keys(emptySource.tracks).length === 0 && emptySource.placeholder.includes('该分区当前没有数据'), `空场景:没有轨迹可画,说的是「${emptySource.placeholder}」`, ) // 未加载:`timelineSource(null)` 不能抛(组件在设备还没选时也会拿到它)。 const unloaded = timelineSource(null) check( 'E9-unloaded-source-is-safe', unloaded.state === SECTION_STATE.FAILED && unloaded.tracks && typeof unloaded.tracks === 'object' && unloaded.hasContent === false, '未加载时回一条失败分区、轨迹表是空对象(组件不必写 ?. 兜底)', ) // E5 的反面:轨迹分区没拿到时,状态路径**仍要**铺得出来(验收第 1 条不因轨迹失败而丢)。 // 当前状态码来自工单列表分区(它照常成功),所以"这张工单现在在哪一态"本来就拿得到。 const orderRows = workOrderRows(TIMELINE_ORDER_ROWS_FOR_TEST) const pathOnly = pathOnlyTrack(orderRows[0]) const pathOnlyNodes = pathView(pathOnly) check( 'E9b-path-survives-when-only-timeline-failed', pathOnlyNodes.length === 8 && pathOnlyNodes.find((node) => node.code === 4).current === true && pathOnly.steps.length === 0 && pathOnly.entryCount === 0 && pathOnly.pathOnly === true, `轨迹没拿到时仍铺状态路径(当前态=${(pathOnlyNodes.find((n) => n.current) || {}).name}),只是没有动作流水`, ) check( 'E9c-path-only-does-not-fake-reached', pathOnly.reachedCode === null && // 走到过哪里根本无从知道,所以**没有**任何节点该被标成"走到过"(当前态单独用 current 标)。 pathOnlyNodes.filter((node) => node.reached).length === 0 && pathOnlyNodes.filter((node) => node.current).length === 1 && pathOnly.hasRollback === false, '只有路径时 reachedCode 为 null、零个节点标"走到过"(不拿当前态冒充"走到过最远处"),回退也无从谈起', ) // 单张工单的时间线端点也按编码回显(真接口语义:认不出的编码回 null)。 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) const { fetchWorkOrderTimeline } = await import('../../../src/modules/cockpit/services/detailApi.js') const oneFailed = await fetchWorkOrderTimeline('WO-UNKNOWN', { config: mockConfig }) const oneForward = await fetchWorkOrderTimeline('WO-TL-FORWARD', { config: mockConfig }) check( 'E10-timeline-endpoint-echoes-code', oneForward.state === SECTION_STATE.SUCCESS && oneForward.data.workOrder.code === 'WO-TL-FORWARD' && oneForward.data.entries.length === 5, `按编码取回 ${oneForward.data.workOrder.code} 自己的 5 条轨迹(不是永远同一张工单)`, ) check( 'E11-unknown-order-is-not-empty-success', oneFailed.state !== SECTION_STATE.SUCCESS, `认不出的工单编码不算成功(${oneFailed.state})—— 缺数据不能画成"没有动作"`, ) } // ================================================================ F 静态守卫(对抗式) { const timelineCode = readCode('domain/workOrderTimeline.js') const viewCode = readCode('domain/equipmentDetailView.js') const componentCode = readVueCode('components/WorkOrderTimeline.vue') const sectionsCode = readVueCode('components/EquipmentDetailSections.vue') const apiCode = readCode('services/detailApi.js') // 第八态与延期档:票 12 的两条否定式验收,用"全仓不许出现这些词"来守。 check( 'F1-no-eighth-state-vocabulary', !/已驳回|驳回态|第八态|REJECTED_STATE/.test(timelineCode + viewCode + componentCode + sectionsCode), '「已驳回」不作为状态词出现(它只能以**动作名**出现在样例数据里)', ) check( 'F2-no-delay-vocabulary', !/延期|逾期|超期|OVERDUE|DELAYED/.test(timelineCode + viewCode + componentCode + sectionsCode), '时间线里没有延期这一档(后端契约也没有该字段)', ) check( 'F3-timeline-module-is-pure', !/\bfetch\s*\(|XMLHttpRequest/.test(timelineCode) && !/from '\.\.\/services\//.test(timelineCode), '时间线域模块是纯函数:不发请求、不 import service', ) // 组件不许认后端裸字段(票 05 立下的守卫面,新组件必须一起守)。 check( 'F4-component-has-no-backend-field-names', !/timelineEntries|stateDictionary|workOrderCode|aggregateState|stateReason|deviceCode/.test(componentCode), '时间线组件只读视图对象字段(后端裸字段名只在 domain 适配层出现)', ) check( 'F5-component-does-not-build-api-paths', !/\/work-orders\/|\/equipments\//.test(componentCode + sectionsCode) && !/fetch\(/.test(componentCode), '接口路径只在 services/detailApi.js,组件不自己拼', ) check( 'F6-no-write-operations', !/method:\s*['"](POST|PUT|PATCH|DELETE)['"]/.test(apiCode) && !/@click="[^"]*(save|submit|update|delete|cancel|dispatch)/i.test(componentCode + sectionsCode), '驾驶舱只读:时间线不引入任何工单写操作(spec 验收第 6 条)', ) // 排序归 domain(后端顺序不保证时序):组件不许自己重排。 check( 'F7-component-does-not-sort', !/\.sort\(/.test(componentCode) && !/\.sort\(/.test(readVueCode('components/EquipmentDetailSections.vue')), '轨迹顺序由 domain 按 operatedAt 定,组件不重排', ) check( 'F8-timeline-section-is-no-longer-deferred', !/DEFERRED_SECTIONS\s*=\s*\[[^\]]*timelines/.test(viewCode) && /timelineSource/.test(viewCode), '`timelines` 已从延后清单移出并接到工单面板(票 11 写明归票 12)', ) // 「没取到」与「没有动作记录」在**模板上**必须是两条不同的分支 —— // 逻辑层的 E7 只能证明两句措辞不同,证明不了界面不会同时/错误地画它们。 // 这条守卫钉住:逐张工单的"没有动作记录"被 `timeline.hasContent` 包住, // 分区失败时的占位说明走另一条分支。 check( 'F9-no-records-note-is-gated-by-section-content', /v-if="trackOfOrder\(order\)"/.test(sectionsCode) && /v-else-if="timeline\.hasContent"/.test(sectionsCode) && /:path-only="!timeline\.hasContent"/.test(sectionsCode), '分区失败时仍铺状态路径(path-only),「这张工单没有动作记录」只在分区有内容时才可能渲染', ) check( 'F10-path-only-blocks-no-records-claim', // 必须是 `v-else-if="!pathOnly"`:裸 `v-else`(不带 pathOnly 守卫)就是无条件说「没有动作记录」。 // 正则要排除 `v-else-if` 本身,否则会把自己的守卫当成违规(第一版正是这么假红的)。 /v-else-if="!pathOnly"/.test(componentCode) && !/v-else(?!-if)[\s\S]{0,120}data-wo-steps-empty/.test(componentCode), '时间线自己也不许在 path-only 时说「没有动作记录」(把"没取到"说成"没有")', ) /** * **模板里用到的每个本地标识符都必须有定义**(import / const / props)。 * * 为什么需要这一条:真机走查第一轮就是被这类错误判红的 —— * `EquipmentDetailSections.vue` 用了 `pathOnlyTrack(order)` 却**忘了 import**, * 静态守卫全绿(它只查"有没有出现被禁的东西",不查"用的东西在不在"), * 逻辑闸门也全绿(它测的是 domain 层,压根不加载 .vue), * 只有真机渲染时才炸成 `pathOnlyTrack is not defined`,把整块工单面板打没。 * 所以在这里补一条**引用完整性**检查:模板表达式里出现的裸标识符, * 都要能在 `