Преглед на файлове

feat:添加动土作业票审批流程

xieyong преди 4 дни
родител
ревизия
20ba6512c1

+ 625 - 0
scripts/check-break-ground-work-flow-nodes.js

@@ -0,0 +1,625 @@
+/**
+ * 动土作业票审批流校验
+ *
+ * 用法:node scripts/check-break-ground-work-flow-nodes.js
+ *
+ * 校验内容:
+ *   1) 动火、受限空间、高处作业、吊装作业、临时用电作业的既有实现文件未被改动
+ *      (与 HEAD 完全一致)
+ *   2) detail.vue 只做「新增」:既有作业类型的分发逻辑一行未删
+ *   3) 动土作业 9 个后续环节的 code / name 与源系统一致
+ *   4) 各环节的表单设计与源系统 formJson 一致(字段类型 / 列 / 必填项)
+ *   5) 环节匹配:按 code / 别名 / 名称 / 双向包含都能命中
+ *   6) 默认安全措施清单与源系统 formData 逐条一致
+ *   7) 运行时辅助(回显 / payload / 流程变量)的取值口径正确
+ *   8) 组件模板与脚本一致:模板里调用的方法必须都已在组件中定义
+ *      (漏定义会在渲染到该分支时抛 "is not a function",整个环节区域渲染不出来)
+ *
+ * 源系统依据:
+ *   https://www.acwlkj.com/IntellSecurity-web/flow/getFlowDefineListByEnterprise.do
+ *     -> 流程定义 HO_PERMIT_BREAK_GROUND(id 3767,名称「危险作业-动土作业」)
+ *   https://www.acwlkj.com/IntellSecurity-web/flow/getFlowNodeDefineByFlowid.do?flow_id=3767
+ *     -> 节点定义(共 10 个节点,1 级为作业申请)
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const babel = require('@babel/core');
+const Module = require('module');
+
+const ROOT = path.resolve(__dirname, '..');
+const SRC = path.join(
+  ROOT,
+  'src/views/bpm/handleTask/components/dangerousWorkTicket'
+);
+const OUT = path.join(os.tmpdir(), 'kd-aiot-check-break-ground-work');
+
+// 动火、受限空间、高处作业、吊装作业、临时用电作业的既有实现文件:
+// 动土作业实现不得改动它们(路径相对仓库根,便于 git diff 精确比对)
+const EXISTING_FILES = [
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/fireFlowNodes.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/fireFlowTask.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/confinedSpaceFlowNodes.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/confinedSpaceFlowTask.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/heightFlowNodes.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/heightFlowTask.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/hoistingFlowNodes.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/hoistingFlowTask.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/electricityFlowNodes.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/electricityFlowTask.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/components/fireTicketDetail.vue',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/components/flowNodeForm.vue',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/components/confinedSpaceFlow.vue',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/components/heightFlow.vue',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/components/hoistingFlow.vue',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/components/electricityFlow.vue',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/templates.js',
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/workTypes.js'
+];
+
+let failed = 0;
+function check(name, cond, extra) {
+  if (cond) {
+    console.log('  PASS  ' + name);
+  } else {
+    failed++;
+    console.log(
+      '  FAIL  ' + name + (extra ? ' -> ' + JSON.stringify(extra) : '')
+    );
+  }
+}
+
+/**
+ * 取文件的 git diff 文本
+ * @param {string} file 相对仓库根的文件路径
+ * @returns {string}
+ */
+function gitDiff(file) {
+  try {
+    return execFileSync('git', ['diff', 'HEAD', '--', file], {
+      cwd: ROOT,
+      encoding: 'utf8',
+      maxBuffer: 1024 * 1024 * 20
+    });
+  } catch (e) {
+    return '';
+  }
+}
+
+/**
+ * 取 diff 里被删除的行(去掉 diff 头部的 --- / ---)
+ * @param {string} diff
+ * @returns {string[]}
+ */
+function removedLines(diff) {
+  return (diff || '')
+    .split(/\r?\n/)
+    .filter((line) => line.startsWith('-') && !line.startsWith('---'))
+    .map((line) => line.slice(1).trim());
+}
+
+// 编译 ESM -> CJS
+// breakGroundFlowTask.js 引用了 fireFlowTask.js(只读复用),
+// fireFlowTask.js 又引用了 fireFlowNodes.js,一并编译进来
+fs.mkdirSync(OUT, { recursive: true });
+[
+  'breakGroundFlowNodes.js',
+  'breakGroundFlowTask.js',
+  'fireFlowNodes.js',
+  'fireFlowTask.js'
+].forEach((file) => {
+  const code = fs.readFileSync(path.join(SRC, file), 'utf8');
+  const res = babel.transformSync(code, {
+    filename: file,
+    babelrc: false,
+    configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  });
+  fs.writeFileSync(path.join(OUT, file), res.code);
+});
+
+// 动土作业运行时辅助会引用 @/api/*,这里用桩模块代替,避免校验脚本依赖后端接口
+const STUB = path.join(OUT, 'stub.js');
+fs.writeFileSync(
+  STUB,
+  'module.exports = {\n' +
+    '  getTaskListByProcessInstanceId: function () { return Promise.resolve([]); },\n' +
+    '  getProcessInstance: function () { return Promise.resolve({}); }\n' +
+    '};\n'
+);
+const originalResolve = Module._resolveFilename;
+Module._resolveFilename = function (request, ...args) {
+  if (request.startsWith('@/')) {
+    return STUB;
+  }
+  return originalResolve.call(this, request, ...args);
+};
+
+const bg = require(path.join(OUT, 'breakGroundFlowNodes.js'));
+const bt = require(path.join(OUT, 'breakGroundFlowTask.js'));
+
+console.log(
+  '\n[1] 动火 / 受限空间 / 高处作业 / 吊装作业 / 临时用电作业既有实现未被改动'
+);
+EXISTING_FILES.forEach((file) => {
+  let changed = true;
+  try {
+    execFileSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
+      cwd: ROOT,
+      stdio: 'ignore'
+    });
+    changed = false;
+  } catch (e) {
+    changed = true;
+  }
+  check(file.split('/').pop() + ' 与 HEAD 一致', !changed);
+});
+
+console.log('\n[2] detail.vue 只做新增(既有作业类型的分发逻辑一行未删)');
+const DETAIL =
+  'src/views/bpm/handleTask/components/dangerousWorkTicket/detail.vue';
+const detailDiff = gitDiff(DETAIL);
+const detailRemoved = removedLines(detailDiff);
+// 允许删除的行:仅文件头注释里的「五类作业」说明,switchToFlowTab 中
+// 临时用电分支补 return 后的收尾行,以及组件注册里因补逗号而重排的 ElectricityFlow
+const ALLOWED_REMOVED = [
+  '动火、受限空间、高处作业、吊装作业、临时用电作业五类作业共用本页面与 submit.vue,按作业类型分发到各自的组件:',
+  "this.activeTab = 'flowElectricity';",
+  'ElectricityFlow',
+  '}'
+];
+const unexpectedRemoved = detailRemoved.filter(
+  (line) => !ALLOWED_REMOVED.includes(line)
+);
+check(
+  `detail.vue 的删除行仅为预期的 ${ALLOWED_REMOVED.length} 行`,
+  unexpectedRemoved.length === 0,
+  unexpectedRemoved
+);
+check(
+  'detail.vue 新增了动土作业的 tab 分发',
+  /<break-ground-flow[\s\S]{0,300}ref="breakGroundFlow"/.test(detailDiff)
+);
+check(
+  'detail.vue 新增了动土作业的当前环节计算属性',
+  /currentBreakGroundNode\(\)\s*\{[\s\S]{0,200}matchBreakGroundFlowNode/.test(
+    detailDiff
+  )
+);
+check(
+  'detail.vue 新增了动土作业的取数 / 保存方法',
+  /getBreakGroundTableValue\(\)/.test(detailDiff) &&
+    /saveBreakGround\(data\)/.test(detailDiff) &&
+    /validateBreakGroundNodeForm\(\)/.test(detailDiff)
+);
+check(
+  'detail.vue 的自动切 tab 在当前环节识别完成之后执行(未被破坏)',
+  /loadCurrentNode\(\)\.then\(\(\) => \{[\s\S]{0,120}switchToFlowTab\(\)/.test(
+    detailSrc()
+  ) &&
+    /await this\.loadCurrentNode\(\);[\s\S]{0,120}switchToFlowTab\(\)/.test(
+      detailSrc()
+    )
+);
+
+/**
+ * 读取 detail.vue 源码
+ * @returns {string}
+ */
+function detailSrc() {
+  return fs.readFileSync(path.join(SRC, 'detail.vue'), 'utf8');
+}
+
+console.log(
+  '\n[3] 动土作业环节(源系统 HO_PERMIT_BREAK_GROUND 共 10 节点,1 级为作业申请模板)'
+);
+const expected = [
+  ['HO_PERMIT_BREAK_GROUND_TWOTH', '风险辨识'],
+  ['HO_PERMIT_BREAK_GROUND_THREETH', '现场安全措施确认'],
+  ['HO_PERMIT_BREAK_GROUND_FOURTH', '安全交底'],
+  ['HO_PERMIT_BREAK_GROUND_FIVETH', '作业负责人意见'],
+  ['HO_PERMIT_BREAK_GROUND_SIXTH', '所在单位意见'],
+  ['HO_PERMIT_BREAK_GROUND_SEVENTH', '有关部门会签意见'],
+  ['HO_PERMIT_BREAK_GROUND_EIGHTTH', '审批部门意见'],
+  ['HO_PERMIT_BREAK_GROUND_NINETH', '作业实施'],
+  ['HO_PERMIT_BREAK_GROUND_TENTH', '完工验收']
+];
+check('环节数量为 9', bg.BREAK_GROUND_FLOW_NODES.length === 9, {
+  len: bg.BREAK_GROUND_FLOW_NODES.length
+});
+expected.forEach(([code, name], i) => {
+  const node = bg.BREAK_GROUND_FLOW_NODES[i];
+  check(
+    '第' + (i + 2) + '级 ' + code + ' / ' + name,
+    node.code === code && node.name === name,
+    { code: node.code, name: node.name }
+  );
+});
+
+console.log('\n[4] 与源系统 formJson 一致性');
+const nodeOf = (k) => bg.BREAK_GROUND_FLOW_NODES.find((n) => n.key === k);
+const colsOf = (k, fk) => {
+  const node = nodeOf(k);
+  const field = node.fields.find((f) => f.key === fk);
+  return (field.columns || []).map((c) => c.label);
+};
+check(
+  '风险辨识:结果为多选 20 项(源系统 checkbox)',
+  nodeOf('risk').fields[0].type === 'checkbox' &&
+    nodeOf('risk').fields[0].options.length === 20 &&
+    nodeOf('risk').fields[0].options[0].label === '物体打击' &&
+    nodeOf('risk').fields[0].options[19].label === '其它伤害'
+);
+check(
+  '风险辨识:说明字段为单行文本(源系统 input)',
+  nodeOf('risk').fields[1].type === 'text'
+);
+check(
+  '风险辨识:说明字段别名不与多选框 key 冲突',
+  !nodeOf('risk').fields[1].alias.includes('risk_result') &&
+    !nodeOf('risk').fields[1].alias.includes('riskResult')
+);
+check(
+  '现场安全措施确认:表名「安全措施确认表」,3 列(安全措施/是否确认/确认人)',
+  nodeOf('measure').fields[0].label === '安全措施确认表' &&
+    JSON.stringify(colsOf('measure', 'measure_list')) ===
+      JSON.stringify(['安全措施', '是否确认', '确认人'])
+);
+check(
+  '现场安全措施确认:编制人签字 + 确认人签字',
+  JSON.stringify(nodeOf('measure').fields.map((f) => f.label)) ===
+    JSON.stringify(['安全措施确认表', '编制人签字', '确认人签字'])
+);
+check(
+  '现场安全措施确认:确认列为单选(不涉及 / 已确认)',
+  nodeOf('measure')
+    .fields[0].columns.find((c) => c.key === 'measure_confirm')
+    .options.map((o) => o.label)
+    .join(',') === '不涉及,已确认'
+);
+check(
+  '安全交底:两个签字(安全交底人签字 + 接受交底人签字)',
+  JSON.stringify(nodeOf('brief').fields.map((f) => f.label)) ===
+    JSON.stringify(['安全交底人签字', '接受交底人签字']) &&
+    nodeOf('brief').fields.every((f) => f.type === 'sign')
+);
+[
+  ['leader_opinion', '作业负责人意见'],
+  ['unit_opinion', '所在单位意见'],
+  ['countersign_opinion', '有关部门会签意见'],
+  ['approver_opinion', '审批部门意见']
+].forEach(([key, label]) => {
+  const node = nodeOf(key);
+  check(
+    label + ':意见 + 签名',
+    node.fields.length === 2 &&
+      node.fields[0].type === 'textarea' &&
+      node.fields[1].type === 'sign',
+    { labels: node.fields.map((f) => f.label) }
+  );
+});
+check(
+  '意见类环节的取值 key 互不相同(源系统四处复制同一 model,不能互相覆盖)',
+  new Set(
+    bg.BREAK_GROUND_FLOW_NODES.filter(
+      (n) => n.fields[0].type === 'textarea'
+    ).map((n) => n.fields[0].key)
+  ).size === 5
+);
+check(
+  '作业实施:实施开始时间 + 实施结束时间 + 事中记录表',
+  JSON.stringify(nodeOf('execute').fields.map((f) => f.label)) ===
+    JSON.stringify(['作业实施开始时间', '作业实施结束时间', '事中记录表'])
+);
+check(
+  '事中记录表 3 列(时间/记录内容/记录人)',
+  JSON.stringify(colsOf('execute', 'during_record_list')) ===
+    JSON.stringify(['时间', '记录内容', '记录人'])
+);
+check(
+  '完工验收:意见 + 签名',
+  nodeOf('acceptance').fields.length === 2 &&
+    nodeOf('acceptance').fields[0].label === '意见' &&
+    nodeOf('acceptance').fields[1].type === 'sign'
+);
+check(
+  '安全措施默认清单 10 条与源系统 formData 逐条一致',
+  bg.DEFAULT_BREAK_GROUND_MEASURES.length === 10 &&
+    bg.DEFAULT_BREAK_GROUND_MEASURES[0] ===
+      '地下电力电缆、通信电(光)缆、局域网络电(光)缆已确认,保护措施已落实' &&
+    bg.DEFAULT_BREAK_GROUND_MEASURES[4] === '已进行放坡处理和固壁支撑' &&
+    bg.DEFAULT_BREAK_GROUND_MEASURES[6] ===
+      '现场夜间有充足照明:A.36 V.24V.12 V 防水型灯:B.36V、24 V.12 V 防爆型灯' &&
+    bg.DEFAULT_BREAK_GROUND_MEASURES[8] ===
+      '易燃易爆、有毒气体存在的场所动土深度超过1.2m,已按照受限空间作业要求采取了措施' &&
+    bg.DEFAULT_BREAK_GROUND_MEASURES[9] ===
+      '其他相关特殊作业已办理相应安全作业票'
+);
+check(
+  '默认行挂在 measure_list 字段上(defaultRows)',
+  Array.isArray(nodeOf('measure').fields[0].defaultRows) &&
+    nodeOf('measure').fields[0].defaultRows.length === 10 &&
+    nodeOf('measure').fields[0].defaultRows[0].measure_content ===
+      bg.DEFAULT_BREAK_GROUND_MEASURES[0]
+);
+
+console.log('\n[5] 环节匹配(多级匹配:code / 别名 / 名称 / 双向包含)');
+const m = bg.matchBreakGroundFlowNode('HO_PERMIT_BREAK_GROUND_THREETH');
+check('按任务定义 key 精确命中', m && m.key === 'measure', {
+  key: m && m.key
+});
+check(
+  '按节点名称命中',
+  (bg.matchBreakGroundFlowNode('现场安全措施确认') || {}).key === 'measure'
+);
+check(
+  '按别名命中',
+  (bg.matchBreakGroundFlowNode('危险辨识') || {}).key === 'risk'
+);
+check(
+  '按平台自定义 key 反向包含命中',
+  (bg.matchBreakGroundFlowNode('break_ground_risk') || {}).key === 'risk'
+);
+check(
+  '流程定义 key / 一级作业申请 key 不误命中后续环节',
+  bg.matchBreakGroundFlowNode('HO_PERMIT_BREAK_GROUND') === null &&
+    bg.matchBreakGroundFlowNode('HO_PERMIT_BREAK_GROUND_ONETH') === null
+);
+check('空标识返回 null', bg.matchBreakGroundFlowNode('', null) === null);
+
+console.log('\n[6] 运行时辅助(回显 / payload / 流程变量)');
+const measureNode = nodeOf('measure');
+const source = {
+  measure_list: [
+    { measure_content: 'x', measure_confirm: '2', measure_confirmer: '张三' }
+  ],
+  measure_preparer_sign: '/tmp/a.png'
+};
+const loaded = bt.loadNodeValues(measureNode, [source]);
+check(
+  '回显:按自身 key 取到已保存内容',
+  loaded.measure_list.length === 1 &&
+    loaded.measure_preparer_sign === '/tmp/a.png'
+);
+check(
+  '回显:未保存的字段为 undefined',
+  loaded.measure_confirmer_sign === undefined
+);
+const payload = bt.buildNodePayload(measureNode, {
+  measure_list: [{ measure_content: 'x' }],
+  measure_preparer_sign: '/tmp/a.png'
+});
+check(
+  'payload:保留原始类型(数组不转字符串)',
+  Array.isArray(payload.measure_list) && payload.measure_list.length === 1
+);
+check(
+  'payload:空值不写入',
+  !('measure_confirmer_sign' in payload) && !('measure_confirm' in payload)
+);
+const vars = bt.buildNodeVariables(measureNode, {
+  measure_list: [{ measure_content: 'x' }],
+  measure_preparer_sign: '/tmp/a.png'
+});
+check(
+  '流程变量:子表转 JSON 字符串、其余转字符串',
+  typeof vars.measure_list === 'string' &&
+    vars.measure_preparer_sign === '/tmp/a.png'
+);
+check(
+  '回显:多选框的值不会串到风险辨识说明',
+  bt.loadNodeValues(nodeOf('risk'), [{ risk_result: '9' }]).risk_result_desc ===
+    undefined
+);
+const opinionPayload = bt.buildNodePayload(nodeOf('leader_opinion'), {
+  leader_opinion: '同意',
+  leader_sign: '/tmp/leader.png'
+});
+check(
+  '作业负责人意见:意见 + 签名写入 payload 与流程变量',
+  opinionPayload.leader_opinion === '同意' &&
+    opinionPayload.leader_sign === '/tmp/leader.png' &&
+    bt.buildNodeVariables(nodeOf('leader_opinion'), {
+      leader_opinion: '同意'
+    }).leader_opinion === '同意'
+);
+check(
+  '四个意见类环节的 payload 互不覆盖',
+  bt.buildNodePayload(nodeOf('unit_opinion'), { unit_opinion: '单位意见' })
+    .unit_opinion === '单位意见' &&
+    bt.buildNodePayload(nodeOf('approver_opinion'), {
+      approver_opinion: '审批意见'
+    }).approver_opinion === '审批意见'
+);
+
+console.log('\n[7] 组件模板与脚本一致性(模板调用的方法必须已在组件中定义)');
+const compiler = require('vue-template-compiler');
+const parser = require('@babel/parser');
+
+/**
+ * 收集组件脚本里已定义的成员名(props / data / computed / methods)
+ * @param {string} script script 源码
+ * @returns {Set<string>} 成员名集合
+ */
+function collectComponentMembers(script) {
+  const names = new Set();
+  const ast = parser.parse(script, {
+    sourceType: 'module',
+    plugins: [
+      'optionalChaining',
+      'objectRestSpread',
+      'nullishCoalescingOperator'
+    ]
+  });
+  let component = null;
+  for (const node of ast.program.body) {
+    if (node.type === 'ExportDefaultDeclaration') {
+      component = node.declaration;
+    }
+  }
+  const collectObjectKeys = (obj) => {
+    ((obj && obj.properties) || []).forEach((prop) => {
+      if (!prop.key) {
+        return;
+      }
+      names.add(prop.key.name || prop.key.value);
+    });
+  };
+  ((component && component.properties) || []).forEach((prop) => {
+    if (!prop.key) {
+      return;
+    }
+    const key = prop.key.name;
+    const value = prop.value;
+    if (key === 'props' || key === 'methods' || key === 'computed') {
+      collectObjectKeys(value);
+      return;
+    }
+    if (key === 'data') {
+      // data() { return { ... } }
+      const body = value && value.body;
+      if (!body) {
+        return;
+      }
+      const ret = body.body.find(
+        (stmt) => stmt.type === 'ReturnStatement' && stmt.argument
+      );
+      if (ret) {
+        collectObjectKeys(ret.argument);
+      }
+    }
+  });
+  return names;
+}
+
+/**
+ * 取模板里被调用的成员名
+ *
+ * 注意:Vue 2 编译后的 render 用 with(this) 解析标识符,
+ * 方法调用编译成裸函数名(如 getRows(field)),不带 vm. 前缀,
+ * 因此这里直接扫描模板表达式,而不是扫描编译产物。
+ *
+ * @param {string} template 模板源码
+ * @returns {Set<string>} 被调用的成员名集合
+ */
+function collectTemplateCalls(template) {
+  const calls = new Set();
+  // 1) 插值 {{ expr }}
+  // 2) 指令 / 事件绑定属性值: :xxx="expr"、v-xxx="expr"、v-xxx:arg="expr"、@xxx="expr"
+  const attrRe = /(?:[:@][\w.-]+|v-[\w-]+(?::[\w.-]+)?)\s*=\s*"([^"]*)"/g;
+  const interpRe = /\{\{([\s\S]*?)\}\}/g;
+  const exprs = [];
+  let m;
+  while ((m = attrRe.exec(template)) !== null) {
+    exprs.push(m[1]);
+  }
+  while ((m = interpRe.exec(template)) !== null) {
+    exprs.push(m[1]);
+  }
+  // 全局函数与 Vue 内部函数,不属于组件成员
+  const globals = new Set([
+    'String',
+    'Number',
+    'Boolean',
+    'Array',
+    'Object',
+    'JSON',
+    'Math',
+    'Date',
+    'parseInt',
+    'parseFloat',
+    'isNaN',
+    'require'
+  ]);
+  // 标识符后紧跟 "(" 即为函数调用;排除属性访问(xxx.method())与内部变量
+  const callRe = /(^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g;
+  exprs.forEach((expr) => {
+    let hit;
+    callRe.lastIndex = 0;
+    while ((hit = callRe.exec(expr)) !== null) {
+      const name = hit[2];
+      if (name.startsWith('$') || name.startsWith('_') || globals.has(name)) {
+        continue;
+      }
+      calls.add(name);
+    }
+  });
+  return { calls, errors: compiler.compile(template).errors || [] };
+}
+
+const sfcPath = path.join(SRC, 'components/breakGroundFlow.vue');
+const sfcSource = fs.readFileSync(sfcPath, 'utf8');
+const sfc = compiler.parseComponent(sfcSource);
+const templateCheck = collectTemplateCalls(sfc.template.content);
+const members = collectComponentMembers(sfc.script.content);
+check(
+  'breakGroundFlow.vue 模板编译无错误',
+  templateCheck.errors.length === 0,
+  templateCheck.errors
+);
+const missing = [...templateCheck.calls].filter((name) => !members.has(name));
+check(
+  `模板调用的 ${templateCheck.calls.size} 个方法均已定义`,
+  missing.length === 0,
+  missing
+);
+check(
+  '模板确实调用了 getConfirmColumn / confirmAll(子表「全部确认」按钮)',
+  templateCheck.calls.has('getConfirmColumn') &&
+    templateCheck.calls.has('confirmAll')
+);
+check(
+  '未识别到环节时展示当前任务名 / key(便于排查流程模型配置)',
+  /taskLabel/.test(sfcSource)
+);
+check(
+  '子表日期列渲染为日期时间选择器(el-date-picker)',
+  /<el-date-picker[^>]*column\.type === 'date'/.test(sfcSource)
+);
+check(
+  '作业实施:事中记录表「时间」列类型为 date',
+  nodeOf('execute')
+    .fields.find((f) => f.key === 'during_record_list')
+    .columns.some((c) => c.key === 'record_time' && c.type === 'date')
+);
+check(
+  '组件对外提供 validate / getPayload / getVariables',
+  templateCheck.calls.size >= 0 &&
+    /validate\(\)\s*\{/.test(sfc.script.content) &&
+    /getPayload\(\)\s*\{/.test(sfc.script.content) &&
+    /getVariables\(\)\s*\{/.test(sfc.script.content)
+);
+check(
+  '组件引用的节点 schema 与运行时辅助均来自动土作业自己的文件',
+  /from '\.\.\/breakGroundFlowNodes'/.test(sfc.script.content) &&
+    /from '\.\.\/breakGroundFlowTask'/.test(sfc.script.content)
+);
+
+// 其余作业类型组件只做只读体检,不参与断言(既有实现不做任何改动)
+[
+  'confinedSpaceFlow.vue',
+  'heightFlow.vue',
+  'hoistingFlow.vue',
+  'electricityFlow.vue'
+].forEach((name) => {
+  const otherSfc = compiler.parseComponent(
+    fs.readFileSync(path.join(SRC, 'components', name), 'utf8')
+  );
+  const otherCheck = collectTemplateCalls(otherSfc.template.content);
+  const otherMembers = collectComponentMembers(otherSfc.script.content);
+  const otherMissing = [...otherCheck.calls].filter(
+    (n) => !otherMembers.has(n)
+  );
+  console.log(
+    '  INFO  ' +
+      name +
+      ' 模板调用体检:缺失 ' +
+      otherMissing.length +
+      ' 个' +
+      (otherMissing.length ? ' -> ' + JSON.stringify(otherMissing) : '')
+  );
+});
+
+console.log('\n' + (failed ? 'FAILED: ' + failed + ' 项' : 'ALL PASSED'));
+process.exit(failed ? 1 : 0);

+ 398 - 0
src/views/bpm/handleTask/components/dangerousWorkTicket/breakGroundFlowNodes.js

@@ -0,0 +1,398 @@
+/**
+ * 动土作业票后续环节(源系统 HO_PERMIT_BREAK_GROUND 流程节点)
+ *
+ * 来源:源系统(IntellSecurity)流程定义接口
+ *   /flow/getFlowDefineListByEnterprise.do      -> 流程定义 HO_PERMIT_BREAK_GROUND(id 3767,名称「危险作业-动土作业」)
+ *   /flow/getFlowNodeDefineByFlowid.do?flow_id=3767 -> 节点定义(共 10 个节点)
+ *
+ * 流程定义 define_code = HO_PERMIT_BREAK_GROUND,编号前缀 DTZY,共 10 个节点:
+ *   一级:作业申请(对应 EHS 作业票模板 templates.js 的 break_ground)
+ *   以下为「后面的内容」:
+ *     二级 风险辨识          HO_PERMIT_BREAK_GROUND_TWOTH
+ *     三级 现场安全措施确认  HO_PERMIT_BREAK_GROUND_THREETH
+ *     四级 安全交底          HO_PERMIT_BREAK_GROUND_FOURTH
+ *     五级 作业负责人意见    HO_PERMIT_BREAK_GROUND_FIVETH
+ *     六级 所在单位意见      HO_PERMIT_BREAK_GROUND_SIXTH
+ *     七级 有关部门会签意见  HO_PERMIT_BREAK_GROUND_SEVENTH
+ *     八级 审批部门意见      HO_PERMIT_BREAK_GROUND_EIGHTTH
+ *     九级 作业实施          HO_PERMIT_BREAK_GROUND_NINETH
+ *     十级 完工验收          HO_PERMIT_BREAK_GROUND_TENTH
+ *
+ * 与受限空间作业(HO_PERMIT_CONFINED_SPACE,共 8 节点)的差异:
+ *   - 没有「气体分析」环节
+ *   - 多了「作业负责人意见 / 有关部门会签意见 / 审批部门意见」三个意见类环节
+ *   - 现场安全措施默认清单为 10 条(动土专属),与受限空间的 14 条不同
+ *
+ * 源系统表单还原说明:
+ *   - 「现场安全措施确认」节点的子表在源系统里的标题是表单设计器的默认占位值「动态表格」,
+ *     这里按节点语义命名为「安全措施确认表」,与受限空间、高处作业等同族流程的叫法保持一致;
+ *   - 「风险辨识」节点里多选框与说明框的标题在源系统中同名(都是「风险辨识结果」),
+ *     这里说明框按语义命名为「风险辨识说明」,并避免把多选框自身的 key 列为别名,
+ *     防止回显时多选框的值串到说明里;
+ *   - 源系统里「作业负责人意见 / 所在单位意见 / 审批部门意见 / 完工验收」四个节点的表单
+ *     是复制粘贴而来,字段标题都只写「意见」、字段 model 也完全相同
+ *     (textarea_17175761562662 / ng-esign_17175761705502)。
+ *     这里按节点语义分别给各自的取值 key,避免四个环节的内容互相覆盖;
+ *   - 现场安全措施默认清单 10 条按源系统 formData 逐字保留(含原文的半角标点)。
+ *
+ * 本文件只服务动土作业,与动火(fireFlowNodes.js)、
+ * 受限空间(confinedSpaceFlowNodes.js)、高处作业(heightFlowNodes.js)、
+ * 吊装作业(hoistingFlowNodes.js)、临时用电(electricityFlowNodes.js)的实现完全独立,
+ * 既有实现不做任何改动。
+ *
+ * 字段类型 type:
+ *   text      普通文本
+ *   textarea  多行文本
+ *   select    下拉(options 静态选项)
+ *   radio     单选(options 静态选项)
+ *   checkbox  多选(options 静态选项)
+ *   date      日期时间
+ *   sign      手写签名
+ *   table     子表(columns 为列定义)
+ */
+
+/**
+ * 风险辨识结果选项(源系统 checkbox 固定 20 项)
+ */
+export const RISK_ITEMS = [
+  { value: '1', label: '物体打击' },
+  { value: '2', label: '车辆伤害' },
+  { value: '3', label: '机械伤害' },
+  { value: '4', label: '起重伤害' },
+  { value: '5', label: '触电' },
+  { value: '6', label: '淹溺' },
+  { value: '7', label: '灼烫' },
+  { value: '8', label: '火灾' },
+  { value: '9', label: '高处坠落' },
+  { value: '10', label: '坍塌' },
+  { value: '11', label: '冒顶片帮' },
+  { value: '12', label: '透水' },
+  { value: '13', label: '放炮' },
+  { value: '14', label: '火药爆炸' },
+  { value: '15', label: '瓦斯爆炸' },
+  { value: '16', label: '锅炉爆炸' },
+  { value: '17', label: '容器爆炸' },
+  { value: '18', label: '其它爆炸' },
+  { value: '19', label: '中毒和窒息' },
+  { value: '20', label: '其它伤害' }
+];
+
+/**
+ * 是否确认(源系统 radio)
+ */
+export const CONFIRM_OPTIONS = [
+  { value: '1', label: '不涉及' },
+  { value: '2', label: '已确认' }
+];
+
+/**
+ * 动土现场安全措施清单(源系统「现场安全措施确认」节点 formData 默认带入的 10 条)
+ *
+ * 注意:按源系统逐字保留(含原文的半角冒号、半角句点与括号)。
+ */
+export const DEFAULT_BREAK_GROUND_MEASURES = [
+  '地下电力电缆、通信电(光)缆、局域网络电(光)缆已确认,保护措施已落实',
+  '地下供排水、消防管线、工艺管线已确认,保护措施已落实',
+  '已按作业方案图划线和立桩',
+  '作业现场围栏、警戒线、警告牌,夜间警示灯已按要求设置',
+  '已进行放坡处理和固壁支撑',
+  '道路施工作业已报:交通、消防、安全监督部门、应急中心',
+  '现场夜间有充足照明:A.36 V.24V.12 V 防水型灯:B.36V、24 V.12 V 防爆型灯',
+  '作业人员配备有必要的个人防护装备',
+  '易燃易爆、有毒气体存在的场所动土深度超过1.2m,已按照受限空间作业要求采取了措施',
+  '其他相关特殊作业已办理相应安全作业票'
+];
+
+/**
+ * 动土作业票后续环节(源系统第二级 ~ 第十级节点)
+ * key:作业票内容(extensionJson / 流程变量)中的取值 key
+ * alias:取值时的兼容 key(中文标签等),按顺序匹配
+ */
+export const BREAK_GROUND_FLOW_NODES = [
+  {
+    key: 'risk',
+    code: 'HO_PERMIT_BREAK_GROUND_TWOTH',
+    name: '风险辨识',
+    aliases: ['风险辨识', '风险识别', '危险辨识', '风险辨识结果'],
+    fields: [
+      {
+        key: 'risk_result',
+        label: '风险辨识结果',
+        type: 'checkbox',
+        options: RISK_ITEMS,
+        alias: ['风险辨识结果', 'risk_identification', 'riskResult']
+      },
+      {
+        key: 'risk_result_desc',
+        label: '风险辨识说明',
+        type: 'text',
+        // 源系统该字段的标题与上面的多选框同名(都是「风险辨识结果」),
+        // 这里不把多选框自身的 key(risk_result / riskResult)列为别名,
+        // 避免回显时多选框的值串到说明里
+        alias: ['风险辨识说明', 'risk_result_desc', 'riskResultDesc']
+      }
+    ]
+  },
+  {
+    key: 'measure',
+    code: 'HO_PERMIT_BREAK_GROUND_THREETH',
+    name: '现场安全措施确认',
+    aliases: [
+      '现场安全措施确认',
+      '安全措施确认',
+      '现场安全措施',
+      '安全措施清单'
+    ],
+    fields: [
+      {
+        key: 'measure_list',
+        label: '安全措施确认表',
+        type: 'table',
+        alias: ['安全措施确认表', 'break_ground_measures', 'measureList'],
+        // 未填写时带出源系统默认的动土安全措施清单
+        defaultRows: DEFAULT_BREAK_GROUND_MEASURES.map((content) => ({
+          measure_content: content
+        })),
+        columns: [
+          { key: 'measure_content', label: '安全措施', type: 'textarea' },
+          {
+            key: 'measure_confirm',
+            label: '是否确认',
+            type: 'radio',
+            options: CONFIRM_OPTIONS
+          },
+          { key: 'measure_confirmer', label: '确认人', type: 'textarea' }
+        ]
+      },
+      {
+        key: 'measure_preparer_sign',
+        label: '编制人签字',
+        type: 'sign',
+        alias: ['编制人签字', 'preparer_sign']
+      },
+      {
+        key: 'measure_confirmer_sign',
+        label: '确认人签字',
+        type: 'sign',
+        alias: ['确认人签字', 'confirmer_sign']
+      }
+    ]
+  },
+  {
+    key: 'brief',
+    code: 'HO_PERMIT_BREAK_GROUND_FOURTH',
+    name: '安全交底',
+    aliases: ['安全交底', '安全技术交底', '交底'],
+    fields: [
+      {
+        key: 'brief_sign',
+        label: '安全交底人签字',
+        type: 'sign',
+        alias: ['安全交底人签字', 'safety_briefer_sign']
+      },
+      {
+        key: 'brief_receiver_sign',
+        label: '接受交底人签字',
+        type: 'sign',
+        alias: ['接受交底人签字', 'safety_receiver_sign']
+      }
+    ]
+  },
+  {
+    key: 'leader_opinion',
+    code: 'HO_PERMIT_BREAK_GROUND_FIVETH',
+    name: '作业负责人意见',
+    aliases: ['作业负责人意见', '作业负责人', '负责人意见'],
+    fields: [
+      {
+        key: 'leader_opinion',
+        label: '作业负责人意见',
+        type: 'textarea',
+        alias: ['作业负责人意见', 'work_leader_opinion']
+      },
+      {
+        key: 'leader_sign',
+        label: '手写签名',
+        type: 'sign',
+        alias: ['作业负责人签名', 'leader_sign']
+      }
+    ]
+  },
+  {
+    key: 'unit_opinion',
+    code: 'HO_PERMIT_BREAK_GROUND_SIXTH',
+    name: '所在单位意见',
+    aliases: ['所在单位意见', '单位意见', '属地单位意见'],
+    fields: [
+      {
+        key: 'unit_opinion',
+        label: '所在单位意见',
+        type: 'textarea',
+        alias: ['所在单位意见', 'dept_opinion']
+      },
+      {
+        key: 'unit_sign',
+        label: '手写签名',
+        type: 'sign',
+        alias: ['所在单位签字', '所在单位签名', 'unit_sign']
+      }
+    ]
+  },
+  {
+    key: 'countersign_opinion',
+    code: 'HO_PERMIT_BREAK_GROUND_SEVENTH',
+    name: '有关部门会签意见',
+    aliases: ['有关部门会签意见', '会签意见', '有关部门意见'],
+    fields: [
+      {
+        key: 'countersign_opinion',
+        label: '有关部门会签意见',
+        type: 'textarea',
+        alias: ['有关部门会签意见', 'countersign_opinion']
+      },
+      {
+        key: 'countersign_sign',
+        label: '手写签名',
+        type: 'sign',
+        alias: ['有关部门会签签字', 'countersign_sign']
+      }
+    ]
+  },
+  {
+    key: 'approver_opinion',
+    code: 'HO_PERMIT_BREAK_GROUND_EIGHTTH',
+    name: '审批部门意见',
+    aliases: ['审批部门意见', '审批部门', '审批人意见'],
+    fields: [
+      {
+        key: 'approver_opinion',
+        label: '审批部门意见',
+        type: 'textarea',
+        alias: ['审批部门意见', 'approver_opinion']
+      },
+      {
+        key: 'approver_sign',
+        label: '手写签名',
+        type: 'sign',
+        alias: ['审批部门签名', 'approver_sign']
+      }
+    ]
+  },
+  {
+    key: 'execute',
+    code: 'HO_PERMIT_BREAK_GROUND_NINETH',
+    name: '作业实施',
+    aliases: ['作业实施', '作业进行中', '实施'],
+    fields: [
+      {
+        key: 'work_start_time',
+        label: '作业实施开始时间',
+        type: 'date',
+        alias: ['作业实施开始时间', 'execute_start_time']
+      },
+      {
+        key: 'work_end_time',
+        label: '作业实施结束时间',
+        type: 'date',
+        alias: ['作业实施结束时间', 'execute_end_time']
+      },
+      {
+        key: 'during_record_list',
+        label: '事中记录表',
+        type: 'table',
+        alias: ['事中记录表', 'during_record', 'record_list'],
+        columns: [
+          { key: 'record_time', label: '时间', type: 'date' },
+          { key: 'record_content', label: '记录内容', type: 'textarea' },
+          { key: 'record_person', label: '记录人', type: 'text' }
+        ]
+      }
+    ]
+  },
+  {
+    key: 'acceptance',
+    code: 'HO_PERMIT_BREAK_GROUND_TENTH',
+    name: '完工验收',
+    aliases: ['完工验收', '验收', '完工确认'],
+    fields: [
+      {
+        key: 'acceptance_opinion',
+        label: '意见',
+        type: 'textarea',
+        alias: ['完工验收意见', 'acceptance_opinion']
+      },
+      {
+        key: 'acceptance_sign',
+        label: '手写签名',
+        type: 'sign',
+        alias: ['完工验收签名', 'acceptance_sign']
+      }
+    ]
+  }
+];
+
+/**
+ * 取字段的取值 key 候选(自身 key + 兼容别名)
+ * @param field 字段定义
+ * @returns 候选 key 数组
+ */
+export function getFieldKeys(field) {
+  const keys = [field.key];
+  (field.alias || []).forEach((item) => {
+    if (!keys.includes(item)) {
+      keys.push(item);
+    }
+  });
+  return keys;
+}
+
+/**
+ * 匹配动土作业票的流程环节
+ *
+ * 任务定义 key 在不同平台并不一致:源系统(IntellSecurity)用 HO_PERMIT_BREAK_GROUND_TWOTH,
+ * 本平台流程模型里的 key 由流程设计器决定,可能完全不同,
+ * 因此按「精确 -> 双向包含」多级匹配,并用任务名称一起参与匹配。
+ *
+ * 匹配顺序:
+ *   1) 节点 code / key 精确命中
+ *   2) 别名精确命中
+ *   3) 节点名称精确命中
+ *   4) 双向包含(节点名、别名、节点 key 与标识互相包含)
+ *
+ * 说明:第 4 步只把节点 key 纳入比较,不纳入节点 code。
+ *   节点 code 是源系统编码(如 HO_PERMIT_BREAK_GROUND_TWOTH),
+ *   流程定义 key(HO_PERMIT_BREAK_GROUND)是它的前缀,
+ *   纳入比较会让「一级作业申请」的任务 key 误命中第一个后续环节。
+ *
+ * @param  {...any} candidates 候选标识(任务定义 key、任务名称等)
+ * @returns {object|null} 匹配到的节点,未匹配返回 null
+ */
+export function matchBreakGroundFlowNode(...candidates) {
+  const keys = candidates
+    .filter((item) => item !== undefined && item !== null && item !== '')
+    .map((item) => String(item).trim())
+    .filter((item) => item);
+  if (!keys.length) {
+    return null;
+  }
+  for (const node of BREAK_GROUND_FLOW_NODES) {
+    if (keys.includes(node.code) || keys.includes(node.key)) {
+      return node;
+    }
+    if ((node.aliases || []).some((alias) => keys.includes(alias))) {
+      return node;
+    }
+    if (keys.includes(node.name)) {
+      return node;
+    }
+  }
+  for (const node of BREAK_GROUND_FLOW_NODES) {
+    const names = [node.name, ...(node.aliases || []), node.key];
+    for (const key of keys) {
+      if (names.some((name) => name.includes(key) || key.includes(name))) {
+        return node;
+      }
+    }
+  }
+  return null;
+}

+ 113 - 0
src/views/bpm/handleTask/components/dangerousWorkTicket/breakGroundFlowTask.js

@@ -0,0 +1,113 @@
+/**
+ * 动土作业票流程环节的运行时辅助
+ *
+ * 职责:
+ *   1) 取流程实例当前待办任务的信息(任务定义 key + 任务名称)
+ *   2) 按动土节点 schema 从数据源取值(回显)
+ *   3) 生成环节内容(写入作业票内容 extensionJson 的 payload)
+ *   4) 生成随审批提交的流程变量(字符串化,兼容流程变量类型限制)
+ *
+ * 与动火实现的关系:
+ *   - fireFlowTask.js 未做任何改动,这里仅「只读」复用其中与作业类型无关的部分
+ *     (取待办任务、读流程变量、合并作业票内容、组装作业票修改参数);
+ *   - 取值口径(字段 key 候选)由动土自己的 breakGroundFlowNodes.js 提供,
+ *     不依赖动火的 fireFlowNodes.js。
+ */
+import {
+  getCurrentTaskInfo,
+  loadProcessFormVariables,
+  mergeExtension,
+  buildTicketUpdateParams,
+  parseJsonObject
+} from './fireFlowTask';
+import { getFieldKeys } from './breakGroundFlowNodes';
+
+// 通用能力直接复用(动火实现本身未改动)
+export {
+  getCurrentTaskInfo,
+  loadProcessFormVariables,
+  mergeExtension,
+  buildTicketUpdateParams,
+  parseJsonObject
+};
+
+/**
+ * 按节点 schema 从数据源中取值(回显)
+ * @param {object} node 节点定义
+ * @param {Array<object>} sources 数据源,按优先级从高到低
+ * @returns {object} { [field.key]: value }
+ */
+export function loadNodeValues(node, sources) {
+  const values = {};
+  (node?.fields || []).forEach((field) => {
+    let value;
+    const keys = getFieldKeys(field);
+    for (const source of sources || []) {
+      for (const key of keys) {
+        const item = source && source[key];
+        if (item !== undefined && item !== null && item !== '') {
+          value = item;
+          break;
+        }
+      }
+      if (value !== undefined) {
+        break;
+      }
+    }
+    values[field.key] = value;
+  });
+  return values;
+}
+
+/**
+ * 环节内容转成流程变量(字符串化,兼容流程变量的类型限制)
+ * @param {object} node 节点定义
+ * @param {object} values 环节字段值
+ * @returns {object} 流程变量
+ */
+export function buildNodeVariables(node, values) {
+  const vars = {};
+  (node?.fields || []).forEach((field) => {
+    const value = values ? values[field.key] : undefined;
+    if (value === undefined || value === null || value === '') {
+      return;
+    }
+    if (field.type === 'table') {
+      if (!Array.isArray(value) || !value.length) {
+        return;
+      }
+      vars[field.key] = JSON.stringify(value);
+      return;
+    }
+    if (Array.isArray(value)) {
+      if (!value.length) {
+        return;
+      }
+      vars[field.key] = value.join(',');
+      return;
+    }
+    vars[field.key] = String(value);
+  });
+  return vars;
+}
+
+/**
+ * 环节内容(保留原始类型,便于详情页按数组/对象回显)
+ * @param {object} node 节点定义
+ * @param {object} values 环节字段值
+ * @returns {object}
+ */
+export function buildNodePayload(node, values) {
+  const payload = {};
+  (node?.fields || []).forEach((field) => {
+    const value = values ? values[field.key] : undefined;
+    if (value === undefined || value === null || value === '') {
+      return;
+    }
+    if (Array.isArray(value) && !value.length) {
+      return;
+    }
+    payload[field.key] = value;
+  });
+  return payload;
+}

+ 932 - 0
src/views/bpm/handleTask/components/dangerousWorkTicket/components/breakGroundFlow.vue

@@ -0,0 +1,932 @@
+<!--
+  动土作业票「后续环节」组件(当前环节可填写 + 全部环节只读)
+
+  职责:把动土作业票的后续环节逻辑集中在这一个组件里,
+        动火作业的实现(flowNodeForm.vue / fireTicketDetail.vue)、
+        受限空间作业的实现(confinedSpaceFlow.vue)、
+        高处作业 / 吊装作业 / 临时用电作业的实现不做任何改动。
+
+  环节结构照搬源系统(IntellSecurity)HO_PERMIT_BREAK_GROUND 流程:
+    风险辨识 → 现场安全措施确认 → 安全交底 → 作业负责人意见
+    → 所在单位意见 → 有关部门会签意见 → 审批部门意见
+    → 作业实施 → 完工验收
+
+  字段类型 type:
+    text / textarea / select / radio / checkbox / date / sign / table
+  子表未填写时带出 schema 里配置的默认行(field.defaultRows,如现场安全措施清单)
+
+  对外方法:
+    validate()     校验当前环节表单,返回 Promise<boolean>
+    getPayload()   取当前环节内容(保留原始类型,供写入作业票内容 extensionJson)
+    getVariables() 取当前环节内容转成的流程变量
+
+  表单状态由本组件自己维护(键为 schema 的 field.key),
+  初始值从作业票内容(extensionJson)与流程变量回显。
+-->
+<template>
+  <div class="break-ground-flow">
+    <!-- 当前处理环节:可填写 -->
+    <div v-if="node" class="current-node-box">
+      <div class="node-form-header">
+        <span class="node-form-title">
+          <i class="el-icon-s-operation" />
+          {{ node.name }}
+        </span>
+        <el-tag type="warning" size="mini">当前环节</el-tag>
+      </div>
+
+      <el-form
+        ref="formRef"
+        :model="form"
+        label-width="140px"
+        size="small"
+        class="node-form"
+      >
+        <el-form-item
+          v-for="field in node.fields || []"
+          :key="field.key"
+          :label="field.label + ':'"
+          :prop="field.key"
+          :rules="rulesMap[field.key] || []"
+        >
+          <!-- 子表 -->
+          <template v-if="field.type === 'table'">
+            <el-table
+              :data="getRows(field)"
+              border
+              size="mini"
+              class="node-table"
+            >
+              <el-table-column
+                v-for="column in field.columns || []"
+                :key="column.key"
+                :label="column.label"
+                :prop="column.key"
+                min-width="150"
+              >
+                <template v-slot="{ row, $index }">
+                  <!-- 列内单选 -->
+                  <el-radio-group
+                    v-if="column.type === 'radio'"
+                    :value="row[column.key]"
+                    size="mini"
+                    @input="
+                      (val) => updateCell(field.key, $index, column.key, val)
+                    "
+                  >
+                    <el-radio
+                      v-for="option in column.options || []"
+                      :key="option.value"
+                      :label="option.value"
+                    >
+                      {{ option.label }}
+                    </el-radio>
+                  </el-radio-group>
+                  <!-- 列内日期时间 -->
+                  <el-date-picker
+                    v-else-if="column.type === 'date'"
+                    :value="row[column.key]"
+                    type="datetime"
+                    placeholder="请选择"
+                    value-format="yyyy-MM-dd HH:mm:ss"
+                    style="width: 100%"
+                    @input="
+                      (val) => updateCell(field.key, $index, column.key, val)
+                    "
+                  />
+                  <!-- 列内多行文本 -->
+                  <el-input
+                    v-else-if="column.type === 'textarea'"
+                    :value="row[column.key]"
+                    type="textarea"
+                    :rows="2"
+                    placeholder="请输入"
+                    @input="
+                      (val) => updateCell(field.key, $index, column.key, val)
+                    "
+                  />
+                  <!-- 列内普通文本 -->
+                  <el-input
+                    v-else
+                    :value="row[column.key]"
+                    placeholder="请输入"
+                    @input="
+                      (val) => updateCell(field.key, $index, column.key, val)
+                    "
+                  />
+                </template>
+              </el-table-column>
+              <el-table-column label="操作" width="70" align="center">
+                <template v-slot="{ $index }">
+                  <el-button
+                    type="text"
+                    class="danger-text"
+                    @click="removeRow(field.key, $index)"
+                    >删除</el-button
+                  >
+                </template>
+              </el-table-column>
+            </el-table>
+            <div class="table-actions">
+              <el-button
+                v-if="getConfirmColumn(field)"
+                size="mini"
+                type="success"
+                icon="el-icon-check"
+                class="confirm-all-btn"
+                @click="confirmAll(field)"
+                >全部确认</el-button
+              >
+              <el-button
+                size="mini"
+                icon="el-icon-plus"
+                class="add-row-btn"
+                @click="addRow(field)"
+                >新增一行</el-button
+              >
+            </div>
+          </template>
+
+          <!-- 多选 -->
+          <el-checkbox-group
+            v-else-if="field.type === 'checkbox'"
+            :value="getCodes(field)"
+            @input="(val) => update(field.key, val)"
+          >
+            <el-checkbox
+              v-for="option in field.options || []"
+              :key="option.value"
+              :label="option.value"
+            >
+              {{ option.label }}
+            </el-checkbox>
+          </el-checkbox-group>
+
+          <!-- 单选 -->
+          <el-radio-group
+            v-else-if="field.type === 'radio'"
+            :value="form[field.key]"
+            @input="(val) => update(field.key, val)"
+          >
+            <el-radio
+              v-for="option in field.options || []"
+              :key="option.value"
+              :label="option.value"
+            >
+              {{ option.label }}
+            </el-radio>
+          </el-radio-group>
+
+          <!-- 下拉 -->
+          <el-select
+            v-else-if="field.type === 'select'"
+            :value="form[field.key]"
+            placeholder="请选择"
+            style="width: 100%"
+            @input="(val) => update(field.key, val)"
+          >
+            <el-option
+              v-for="option in field.options || []"
+              :key="option.value"
+              :label="option.label"
+              :value="option.value"
+            />
+          </el-select>
+
+          <!-- 手写签名 -->
+          <sign-canvas
+            v-else-if="field.type === 'sign'"
+            :value="form[field.key]"
+            @input="(val) => update(field.key, val)"
+          />
+
+          <!-- 日期时间 -->
+          <el-date-picker
+            v-else-if="field.type === 'date'"
+            :value="form[field.key]"
+            type="datetime"
+            placeholder="请选择"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width: 100%"
+            @input="(val) => update(field.key, val)"
+          />
+
+          <!-- 多行文本 -->
+          <el-input
+            v-else-if="field.type === 'textarea'"
+            :value="form[field.key]"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入"
+            @input="(val) => update(field.key, val)"
+          />
+
+          <!-- 普通文本 -->
+          <el-input
+            v-else
+            :value="form[field.key]"
+            placeholder="请输入"
+            @input="(val) => update(field.key, val)"
+          />
+        </el-form-item>
+      </el-form>
+    </div>
+    <div v-else class="node-tip">
+      未识别到当前处理环节{{
+        taskLabel ? '(当前任务:' + taskLabel + ')' : ''
+      }},以下为全部环节的查看内容
+    </div>
+
+    <!-- 全部环节(查看态,当前环节已在上方单独展示,这里排除避免重复) -->
+    <div class="all-nodes-box">
+      <el-collapse v-model="activePanels" accordion>
+        <el-collapse-item
+          v-for="item in displayNodes"
+          :key="item.key"
+          :name="item.key"
+        >
+          <template slot="title">
+            <span class="node-title">{{ item.name }}</span>
+            <el-tag v-if="isCurrent(item)" type="warning" size="mini">
+              当前环节
+            </el-tag>
+            <el-tag v-if="nodeDone(item)" type="success" size="mini">
+              已填写
+            </el-tag>
+          </template>
+
+          <div class="ticket-section">
+            <el-form
+              :model="formData"
+              label-position="top"
+              size="small"
+              class="section-form"
+              disabled
+            >
+              <div
+                v-for="field in item.fields"
+                :key="field.key"
+                class="field-cell"
+              >
+                <!-- 子表 -->
+                <template v-if="field.type === 'table'">
+                  <div class="field-label">{{ field.label }}</div>
+                  <el-table
+                    :data="getViewRows(field)"
+                    size="mini"
+                    border
+                    class="ticket-table"
+                  >
+                    <el-table-column
+                      v-for="column in field.columns || []"
+                      :key="column.key"
+                      :label="column.label"
+                      :prop="column.key"
+                      min-width="140"
+                    >
+                      <template v-slot="{ row }">
+                        <span>{{ getCellText(column, row) }}</span>
+                      </template>
+                    </el-table-column>
+                  </el-table>
+                  <div v-if="!getViewRows(field).length" class="empty-text">
+                    暂无数据
+                  </div>
+                </template>
+                <!-- 普通字段 -->
+                <template v-else>
+                  <div class="field-label">{{ field.label }}</div>
+                  <div class="field-value">
+                    <!-- 多选 -->
+                    <el-checkbox-group
+                      v-if="field.type === 'checkbox'"
+                      :value="getViewCodes(field)"
+                    >
+                      <el-checkbox
+                        v-for="option in field.options || []"
+                        :key="option.value"
+                        :label="option.value"
+                      >
+                        {{ option.label }}
+                      </el-checkbox>
+                    </el-checkbox-group>
+                    <!-- 单选 -->
+                    <el-radio-group
+                      v-else-if="field.type === 'radio'"
+                      :value="getRawValue(field)"
+                    >
+                      <el-radio
+                        v-for="option in field.options || []"
+                        :key="option.value"
+                        :label="option.value"
+                      >
+                        {{ option.label }}
+                      </el-radio>
+                    </el-radio-group>
+                    <!-- 签名 -->
+                    <template v-else-if="field.type === 'sign'">
+                      <span v-if="!getRawValue(field)">-</span>
+                      <img
+                        v-else-if="!imgErrors[field.key]"
+                        :src="getPreviewUrl(getRawValue(field))"
+                        class="sign-image"
+                        alt="签名"
+                        @error="handleImgError(field)"
+                      />
+                      <span v-else>{{ getRawValue(field) }}</span>
+                    </template>
+                    <!-- 日期 -->
+                    <el-date-picker
+                      v-else-if="field.type === 'date'"
+                      :value="getViewText(field)"
+                      type="datetime"
+                      placeholder="-"
+                      value-format="yyyy-MM-dd HH:mm:ss"
+                      style="width: 100%"
+                    />
+                    <!-- 多行文本 -->
+                    <el-input
+                      v-else-if="field.type === 'textarea'"
+                      :value="getViewText(field)"
+                      type="textarea"
+                      :rows="3"
+                      disabled
+                      placeholder="-"
+                    />
+                    <!-- 普通文本 -->
+                    <el-input
+                      v-else
+                      :value="getViewText(field)"
+                      disabled
+                      placeholder="-"
+                    />
+                  </div>
+                </template>
+              </div>
+            </el-form>
+          </div>
+        </el-collapse-item>
+      </el-collapse>
+    </div>
+  </div>
+</template>
+
+<script>
+  import SignCanvas from './signCanvas.vue';
+  import { getFieldKeys } from '../breakGroundFlowNodes';
+  import {
+    loadNodeValues,
+    buildNodePayload,
+    buildNodeVariables
+  } from '../breakGroundFlowTask';
+  import { getFilePreviewUrl as buildFilePreviewUrl } from '@/utils/file';
+
+  export default {
+    name: 'BreakGroundFlow',
+    components: { SignCanvas },
+    props: {
+      // 当前处理环节(节点定义),为空时只展示全部环节的查看内容
+      node: {
+        type: Object,
+        default: null
+      },
+      // 全部后续环节
+      nodes: {
+        type: Array,
+        default: () => []
+      },
+      // 作业票基本信息
+      ticket: {
+        type: Object,
+        default: () => ({})
+      },
+      // 作业票内容(extensionJson)
+      extension: {
+        type: Object,
+        default: () => ({})
+      },
+      // 流程变量(流程实例 formVariables)
+      variables: {
+        type: Object,
+        default: () => ({})
+      },
+      // 当前待办任务的名称 / key,未识别到环节时展示,便于排查流程模型配置
+      taskLabel: {
+        type: String,
+        default: ''
+      }
+    },
+    data() {
+      return {
+        // 当前环节的表单值
+        form: {},
+        // 各字段的校验规则(按节点缓存,避免每次渲染重建规则数组)
+        rulesMap: {},
+        // 展开的环节,默认全部折叠
+        activePanels: '',
+        // el-form 的 model,查看态仅用于结构占位
+        formData: {},
+        // 图片加载失败的字段(签名等),退回显示原始值
+        imgErrors: {}
+      };
+    },
+    computed: {
+      // 排除当前环节(当前环节已在页面上方单独展示,避免重复)
+      displayNodes() {
+        const list = this.nodes || [];
+        if (!this.node) {
+          return list;
+        }
+        return list.filter((item) => item.key !== this.node.key);
+      }
+    },
+    watch: {
+      // 当前环节变化时重新回显:
+      // 环节数据统一存放在作业票内容(extensionJson)里,流程变量仅作兼容兜底
+      node: {
+        immediate: true,
+        handler(node) {
+          this.form = this.buildForm(
+            node ? loadNodeValues(node, [this.extension, this.variables]) : {}
+          );
+          this.rulesMap = this.buildRules(node);
+        }
+      }
+    },
+    methods: {
+      /**
+       * 校验当前环节表单
+       * @returns {Promise<boolean>}
+       */
+      validate() {
+        return new Promise((resolve) => {
+          if (!this.node || !this.$refs.formRef) {
+            resolve(true);
+            return;
+          }
+          this.$refs.formRef.validate((valid) => resolve(valid));
+        });
+      },
+      /**
+       * 取当前环节内容(保留原始类型)
+       */
+      getPayload() {
+        return buildNodePayload(this.node, this.form);
+      },
+      /**
+       * 取当前环节内容转成的流程变量
+       */
+      getVariables() {
+        return buildNodeVariables(this.node, this.form);
+      },
+      /**
+       * 依据节点 schema 组装表单值(保证每个字段都有键,表单校验才生效)
+       */
+      buildForm(source) {
+        const data = source || {};
+        const form = {};
+        (this.node?.fields || []).forEach((field) => {
+          const value = data[field.key];
+          if (field.type === 'checkbox') {
+            form[field.key] = this.toArray(value);
+          } else if (field.type === 'table') {
+            let rows = this.toArray(value);
+            // 未填写时带出 schema 里配置的默认行(如现场安全措施清单)
+            if (!rows.length && Array.isArray(field.defaultRows)) {
+              rows = field.defaultRows.map((row) => ({ ...row }));
+            }
+            form[field.key] = rows;
+          } else {
+            form[field.key] =
+              value === undefined || value === null ? '' : value;
+          }
+        });
+        return form;
+      },
+      /**
+       * 值统一成数组(兼容逗号分隔字符串、JSON 字符串)
+       */
+      toArray(value) {
+        if (Array.isArray(value)) {
+          return value;
+        }
+        if (value === undefined || value === null || value === '') {
+          return [];
+        }
+        if (typeof value === 'string') {
+          const text = value.trim();
+          if (text.startsWith('[')) {
+            try {
+              const parsed = JSON.parse(text);
+              return Array.isArray(parsed) ? parsed : [];
+            } catch (e) {
+              return [];
+            }
+          }
+          return text
+            .split(',')
+            .map((item) => item.trim())
+            .filter((item) => item);
+        }
+        return [];
+      },
+      /**
+       * 更新字段值
+       */
+      update(key, val) {
+        this.form = { ...this.form, [key]: val };
+      },
+      /**
+       * 更新子表单元格
+       */
+      updateCell(fieldKey, index, columnKey, val) {
+        const rows = [...(this.form[fieldKey] || [])];
+        const row = { ...(rows[index] || {}) };
+        row[columnKey] = val;
+        rows[index] = row;
+        this.update(fieldKey, rows);
+      },
+      /**
+       * 新增子表行
+       */
+      addRow(field) {
+        const row = {};
+        (field.columns || []).forEach((column) => {
+          row[column.key] = '';
+        });
+        this.update(field.key, [...(this.form[field.key] || []), row]);
+      },
+      /**
+       * 取子表中的确认列(单选型列)
+       * 没有确认列的子表(如事中记录表)不显示「全部确认」
+       */
+      getConfirmColumn(field) {
+        return (field.columns || []).find(
+          (column) => column.type === 'radio' && (column.options || []).length
+        );
+      },
+      /**
+       * 取确认列中「已确认」对应的选项值
+       */
+      getConfirmValue(column) {
+        const options = column?.options || [];
+        const hit = options.find(
+          (option) =>
+            /确认/.test(option.label) &&
+            !/不涉及|无需|不需要/.test(option.label)
+        );
+        if (hit) {
+          return hit.value;
+        }
+        return options.length ? options[options.length - 1].value : '';
+      },
+      /**
+       * 全部确认:把确认列的所有行一次性置为「已确认」
+       */
+      confirmAll(field) {
+        const column = this.getConfirmColumn(field);
+        if (!column) {
+          return;
+        }
+        const value = this.getConfirmValue(column);
+        const rows = (this.form[field.key] || []).map((row) => ({
+          ...row,
+          [column.key]: value
+        }));
+        if (!rows.length) {
+          this.$message.warning('没有可确认的内容');
+          return;
+        }
+        this.update(field.key, rows);
+      },
+      /**
+       * 删除子表行
+       */
+      removeRow(fieldKey, index) {
+        const rows = [...(this.form[fieldKey] || [])];
+        rows.splice(index, 1);
+        this.update(fieldKey, rows);
+      },
+      /**
+       * 子表行数据(可编辑表单用)
+       */
+      getRows(field) {
+        const rows = this.form[field.key];
+        return Array.isArray(rows) ? rows : [];
+      },
+      /**
+       * 多选选中值(可编辑表单用)
+       */
+      getCodes(field) {
+        return this.toArray(this.form[field.key]);
+      },
+      /**
+       * 校验规则:环节内所有字段均为必填
+       */
+      buildRules(node) {
+        const map = {};
+        (node?.fields || []).forEach((field) => {
+          map[field.key] = this.buildFieldRules(field);
+        });
+        return map;
+      },
+      /**
+       * 单个字段的校验规则
+       */
+      buildFieldRules(field) {
+        const label = field.label || '内容';
+        // 子表:至少一行,且每行每列都要填写
+        if (field.type === 'table') {
+          return [
+            {
+              validator: (rule, value, callback) => {
+                this.validateTableRows(field, value, callback);
+              },
+              trigger: 'change'
+            }
+          ];
+        }
+        if (field.type === 'checkbox') {
+          return [
+            {
+              required: true,
+              message: `请勾选${label}`,
+              trigger: 'change'
+            }
+          ];
+        }
+        if (field.type === 'radio' || field.type === 'select') {
+          return [
+            {
+              required: true,
+              message: `请选择${label}`,
+              trigger: 'change'
+            }
+          ];
+        }
+        if (field.type === 'sign') {
+          return [
+            {
+              required: true,
+              message: `请完成${label}`,
+              trigger: 'change'
+            }
+          ];
+        }
+        return [
+          {
+            required: true,
+            message: `请填写${label}`,
+            trigger: 'change'
+          }
+        ];
+      },
+      /**
+       * 子表必填校验:至少一行,且每行的每个列都要有值
+       */
+      validateTableRows(field, value, callback) {
+        const label = field.label || '内容';
+        const rows = Array.isArray(value) ? value : [];
+        if (!rows.length) {
+          callback(new Error(`请填写${label}`));
+          return;
+        }
+        const columns = field.columns || [];
+        for (let i = 0; i < rows.length; i++) {
+          const row = rows[i] || {};
+          for (const column of columns) {
+            const cell = row[column.key];
+            if (cell === undefined || cell === null || cell === '') {
+              callback(
+                new Error(`${label}第 ${i + 1} 行「${column.label}」未填写`)
+              );
+              return;
+            }
+          }
+        }
+        callback();
+      },
+      /**
+       * 取字段原始值:先作业票内容,再流程变量
+       */
+      getRawValue(field) {
+        const keys = getFieldKeys(field);
+        // 环节数据统一存放在作业票内容(extensionJson)里,流程变量仅作兼容兜底
+        const sources = [this.extension, this.variables];
+        for (const source of sources) {
+          for (const key of keys) {
+            const value = source?.[key];
+            if (value !== undefined && value !== null && value !== '') {
+              return value;
+            }
+          }
+        }
+        return '';
+      },
+      /**
+       * 多选字段的选中值(数组)
+       * 兼容三种存储形态:数组 / 逗号分隔字符串 / JSON 字符串
+       */
+      getViewCodes(field) {
+        return this.toArray(this.getRawValue(field));
+      },
+      /**
+       * 取字段展示值
+       */
+      getViewText(field) {
+        const raw = this.getRawValue(field);
+        if (raw === '') {
+          return '';
+        }
+        if (field.type === 'radio' || field.type === 'select') {
+          const option = (field.options || []).find(
+            (item) => String(item.value) === String(raw)
+          );
+          return option ? option.label : String(raw);
+        }
+        if (Array.isArray(raw)) {
+          return raw.join('、');
+        }
+        return String(raw);
+      },
+      /**
+       * 子表行数据(只读查看用)
+       * 未填写时展示 schema 里配置的默认行(如现场安全措施清单,defaultRows)
+       */
+      getViewRows(field) {
+        const raw = this.getRawValue(field);
+        if (Array.isArray(raw) && raw.length) {
+          return raw;
+        }
+        if (typeof raw === 'string' && raw.trim().startsWith('[')) {
+          try {
+            const parsed = JSON.parse(raw);
+            if (Array.isArray(parsed) && parsed.length) {
+              return parsed;
+            }
+          } catch (e) {
+            // 解析失败按空处理
+          }
+        }
+        if (Array.isArray(field.defaultRows) && field.defaultRows.length) {
+          return field.defaultRows.map((row) => ({ ...row }));
+        }
+        return [];
+      },
+      /**
+       * 子表单元格展示值
+       */
+      getCellText(column, row) {
+        const value = row?.[column.key];
+        if (value === undefined || value === null || value === '') {
+          return '';
+        }
+        if (column.type === 'radio' || column.type === 'select') {
+          const option = (column.options || []).find(
+            (item) => String(item.value) === String(value)
+          );
+          return option ? option.label : String(value);
+        }
+        return String(value);
+      },
+      /**
+       * 图片预览地址
+       * 注意:Vue 2 的模板是在 with(this) 作用域里解析标识符,
+       * 模块里 import 进来的函数必须通过 methods 暴露后才能在模板中调用
+       */
+      getPreviewUrl(path) {
+        return buildFilePreviewUrl(path);
+      },
+      /**
+       * 图片加载失败时退回显示原始值
+       */
+      handleImgError(field) {
+        this.$set(this.imgErrors, field.key, true);
+      },
+      /**
+       * 是否当前处理环节
+       */
+      isCurrent(node) {
+        if (!this.node || !this.node.key) {
+          return false;
+        }
+        return node.key === this.node.key;
+      },
+      /**
+       * 环节是否已填写内容
+       */
+      nodeDone(node) {
+        return node.fields.some((field) => {
+          const raw = this.getRawValue(field);
+          if (raw === '') {
+            return false;
+          }
+          if (Array.isArray(raw)) {
+            return raw.length > 0;
+          }
+          return true;
+        });
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .current-node-box {
+    padding: 16px;
+    margin-bottom: 16px;
+    border: 1px solid #d9ecff;
+    border-radius: 4px;
+    background: #f5faff;
+  }
+  .node-form-header {
+    display: flex;
+    align-items: center;
+    padding-bottom: 10px;
+    border-bottom: 1px solid #ebeef5;
+    margin-bottom: 14px;
+  }
+  .node-form-title {
+    font-size: 15px;
+    font-weight: 500;
+    color: #303133;
+    margin-right: 8px;
+    i {
+      color: #409eff;
+      margin-right: 4px;
+    }
+  }
+  .node-form {
+    ::v-deep .el-form-item {
+      margin-bottom: 16px;
+    }
+    ::v-deep .el-checkbox {
+      margin-right: 16px;
+      margin-bottom: 6px;
+    }
+  }
+  .node-table {
+    margin-bottom: 8px;
+  }
+  .table-actions {
+    display: flex;
+    align-items: center;
+    margin-top: 2px;
+  }
+  .confirm-all-btn {
+    margin-right: 8px;
+  }
+  .danger-text {
+    color: #f56c6c;
+  }
+  .node-tip {
+    padding: 10px 0;
+    font-size: 13px;
+    color: #909399;
+  }
+  /* 与作业票内容一致的分区卡片 */
+  .ticket-section {
+    border: 1px solid #ebeef5;
+    border-radius: 4px;
+    padding: 10px 16px 4px;
+    margin-bottom: 12px;
+    background: #fff;
+  }
+  .field-cell {
+    margin-bottom: 14px;
+  }
+  .field-label {
+    color: #606266;
+    font-size: 13px;
+    line-height: 22px;
+  }
+  .field-value {
+    color: #303133;
+    font-size: 13px;
+    line-height: 22px;
+    word-break: break-all;
+    ::v-deep .el-input,
+    ::v-deep .el-textarea,
+    ::v-deep .el-date-editor,
+    ::v-deep .el-select {
+      width: 100%;
+    }
+  }
+  .ticket-table {
+    margin-bottom: 8px;
+  }
+  .empty-text {
+    padding: 4px 0 8px;
+    font-size: 13px;
+    color: #909399;
+  }
+  .sign-image {
+    max-height: 100px;
+    border: 1px solid #ebeef5;
+  }
+  .node-title {
+    font-weight: 500;
+    color: #303133;
+    margin-right: 8px;
+  }
+</style>

+ 140 - 2
src/views/bpm/handleTask/components/dangerousWorkTicket/detail.vue

@@ -6,12 +6,13 @@
     / 作业开始时间 / 作业结束时间 / 作业票模板 / 作业票内容 / 备注
   作业票内容按 EHS 内置作业票模板渲染(不同作业类型套用不同模板);
   页面用 tab 区分「作业票信息」与「后续环节」(tab 栏在上方);
-  动火、受限空间、高处作业、吊装作业、临时用电作业五类作业共用本页面与 submit.vue,按作业类型分发到各自的组件:
+  动火、受限空间、高处作业、吊装作业、临时用电作业、动土作业六类作业共用本页面与 submit.vue,按作业类型分发到各自的组件:
     动火作业   fireFlowNodes.js + components/fireTicketDetail.vue + components/flowNodeForm.vue
     受限空间   confinedSpaceFlowNodes.js + components/confinedSpaceFlow.vue
     高处作业   heightFlowNodes.js + components/heightFlow.vue
     吊装作业   hoistingFlowNodes.js + components/hoistingFlow.vue
     临时用电   electricityFlowNodes.js + components/electricityFlow.vue
+    动土作业   breakGroundFlowNodes.js + components/breakGroundFlow.vue
   「后续环节」tab 顶部为当前处理环节(可填写),下方为全部环节的查看内容;
   环节填写值通过 getTableValue 提供给审批组件(submit.vue),
   在审批通过前合并写入作业票内容(extensionJson)
@@ -250,6 +251,23 @@
           :task-label="currentTaskName || taskDefinitionKey"
         />
       </el-tab-pane>
+
+      <!-- 作业类型专项内容(动土作业票,独立组件实现) -->
+      <el-tab-pane
+        v-if="isBreakGroundWork || currentBreakGroundNode"
+        label="后续环节"
+        name="flowBreakGround"
+      >
+        <break-ground-flow
+          ref="breakGroundFlow"
+          :node="currentBreakGroundNode"
+          :nodes="breakGroundFlowNodes"
+          :ticket="ticket"
+          :extension="extension"
+          :variables="variables"
+          :task-label="currentTaskName || taskDefinitionKey"
+        />
+      </el-tab-pane>
     </el-tabs>
   </div>
 </template>
@@ -291,6 +309,11 @@
     matchElectricityFlowNode,
     ELECTRICITY_FLOW_NODES
   } from './electricityFlowNodes';
+  // 动土作业:独立实现,其它作业类型相关文件不做任何改动
+  import {
+    matchBreakGroundFlowNode,
+    BREAK_GROUND_FLOW_NODES
+  } from './breakGroundFlowNodes';
   import TicketContentView from './components/ticketContentView.vue';
   import FireTicketDetail from './components/fireTicketDetail.vue';
   import FlowNodeForm from './components/flowNodeForm.vue';
@@ -298,6 +321,7 @@
   import HeightFlow from './components/heightFlow.vue';
   import HoistingFlow from './components/hoistingFlow.vue';
   import ElectricityFlow from './components/electricityFlow.vue';
+  import BreakGroundFlow from './components/breakGroundFlow.vue';
 
   // 作业票状态:1-待提交、2-审批中、3-已通过、4-已驳回
   const TICKET_STATUS = {
@@ -319,7 +343,8 @@
       ConfinedSpaceFlow,
       HeightFlow,
       HoistingFlow,
-      ElectricityFlow
+      ElectricityFlow,
+      BreakGroundFlow
     },
     props: {
       // 作业票 id
@@ -520,6 +545,31 @@
           this.currentTaskName
         );
       },
+      /**
+       * 是否动土作业
+       */
+      isBreakGroundWork() {
+        return this.template?.key === 'break_ground';
+      },
+      /**
+       * 动土作业的全部后续环节
+       */
+      breakGroundFlowNodes() {
+        return BREAK_GROUND_FLOW_NODES;
+      },
+      /**
+       * 当前处理环节(动土作业)
+       * 任务定义 key 在不同平台不一致,用「任务定义 key + 任务名称」一起匹配
+       */
+      currentBreakGroundNode() {
+        if (!this.isBreakGroundWork) {
+          return null;
+        }
+        return matchBreakGroundFlowNode(
+          this.taskDefinitionKey,
+          this.currentTaskName
+        );
+      },
       // 作业票状态文案
       statusText() {
         return (
@@ -637,6 +687,10 @@
         if (this.currentElectricityNode) {
           return;
         }
+        // 动土作业:环节内容由动土作业自己的组件回显,这里不参与
+        if (this.currentBreakGroundNode) {
+          return;
+        }
         const node = this.currentFireNode;
         if (!node) {
           return;
@@ -669,6 +723,10 @@
         }
         if (this.currentElectricityNode) {
           this.activeTab = 'flowElectricity';
+          return;
+        }
+        if (this.currentBreakGroundNode) {
+          this.activeTab = 'flowBreakGround';
         }
       },
       // 环节填写值变化
@@ -691,6 +749,9 @@
         if (this.isElectricityWork) {
           return this.validateElectricityNodeForm();
         }
+        if (this.isBreakGroundWork) {
+          return this.validateBreakGroundNodeForm();
+        }
         if (!this.currentFireNode || !this.$refs.nodeForm) {
           return true;
         }
@@ -732,6 +793,15 @@
         }
         return this.$refs.electricityFlow.validate();
       },
+      /**
+       * 动土作业的当前环节表单校验
+       */
+      async validateBreakGroundNodeForm() {
+        if (!this.currentBreakGroundNode || !this.$refs.breakGroundFlow) {
+          return true;
+        }
+        return this.$refs.breakGroundFlow.validate();
+      },
       /**
        * 供审批组件(submit.vue)取当前环节数据
        * 取数的同时把环节内容合并进作业票内容(extensionJson)并落库,
@@ -751,6 +821,9 @@
         if (this.isElectricityWork) {
           return this.getElectricityTableValue();
         }
+        if (this.isBreakGroundWork) {
+          return this.getBreakGroundTableValue();
+        }
         if (!this.currentFireNode) {
           return {
             valid: true,
@@ -936,6 +1009,44 @@
           message: saved.message
         };
       },
+      /**
+       * 取动土作业当前环节的数据(供审批组件 submit.vue 使用)
+       * 取数的同时把环节内容合并进作业票内容(extensionJson)并落库,
+       * 保证审批通过后数据已经落在作业票上
+       * @returns {Promise<{valid: boolean, variables: object, payload: object, saved: boolean, message: string}>}
+       */
+      async getBreakGroundTableValue() {
+        const flow = this.$refs.breakGroundFlow;
+        if (!this.currentBreakGroundNode || !flow) {
+          return {
+            valid: true,
+            variables: {},
+            payload: {},
+            saved: true,
+            message: ''
+          };
+        }
+        const valid = await this.validateBreakGroundNodeForm();
+        const payload = flow.getPayload();
+        const variables = flow.getVariables();
+        if (!valid) {
+          return {
+            valid,
+            variables,
+            payload,
+            saved: false,
+            message: '请填写当前环节的必填内容'
+          };
+        }
+        const saved = await this.updateExtension(payload);
+        return {
+          valid,
+          variables,
+          payload,
+          saved: saved.saved,
+          message: saved.message
+        };
+      },
       /**
        * 把环节内容合并进作业票内容(extensionJson)并保存
        */
@@ -978,6 +1089,9 @@
         if (this.isElectricityWork) {
           return this.saveElectricity(data);
         }
+        if (this.isBreakGroundWork) {
+          return this.saveBreakGround(data);
+        }
         const payload =
           data && data.payload
             ? data.payload
@@ -1091,6 +1205,30 @@
         }
         return data;
       },
+      /**
+       * 动土作业:供审批组件提交时调用,保存业务数据
+       */
+      async saveBreakGround(data) {
+        const flow = this.$refs.breakGroundFlow;
+        const payload =
+          data && data.payload
+            ? data.payload
+            : flow
+            ? flow.getPayload()
+            : {};
+        if (!Object.keys(payload).length) {
+          return data;
+        }
+        try {
+          const res = await this.updateExtension(payload);
+          if (!res.saved) {
+            console.error('保存环节内容失败', res.message);
+          }
+        } catch (e) {
+          console.error('保存环节内容失败', e);
+        }
+        return data;
+      },
       // 作业类型字典:{dictCode, dictValue} 形式
       async loadWorkTypeDict() {
         try {