Explorar el Código

feat:添加吊装作业票审批流程

xieyong hace 4 días
padre
commit
9f595a9911

+ 560 - 0
scripts/check-hoisting-work-flow-nodes.js

@@ -0,0 +1,560 @@
+/**
+ * 吊装作业票审批流校验
+ *
+ * 用法:node scripts/check-hoisting-work-flow-nodes.js
+ *
+ * 校验内容:
+ *   1) 动火作业、受限空间作业、高处作业的既有实现文件未被改动(与 HEAD 完全一致)
+ *   2) 吊装作业 9 个后续环节的 code / name / 字段与源系统一致
+ *   3) 环节匹配:按 code / 别名 / 名称 / 双向包含都能命中
+ *   4) 默认安全措施清单与源系统 formData 逐条一致
+ *   5) 吊装作业运行时辅助(回显 / payload / 流程变量)的取值口径正确
+ *   6) 组件模板与脚本一致:模板里调用的方法必须都已在组件中定义
+ *      (漏定义会在渲染到该分支时抛 "is not a function",整个环节区域渲染不出来)
+ *   7) detail.vue 的作业类型分发与「自动切到后续环节 tab」的时机正确
+ *
+ * 源系统依据:
+ *   https://www.acwlkj.com/IntellSecurity-web/flow/getFlowDefineListByEnterprise.do
+ *     -> 流程定义 HO_PERMIT_HOISTING(id 3765,名称「危险作业-吊装作业」,编号前缀 DZTY)
+ *   https://www.acwlkj.com/IntellSecurity-web/flow/getFlowNodeDefineByFlowid.do?flow_id=3765
+ *     -> 节点定义(共 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-hoisting-work');
+
+// 动火作业、受限空间作业、高处作业的既有实现文件:吊装作业实现不得改动它们
+const EXISTING_FILES = [
+  'fireFlowNodes.js',
+  'fireFlowTask.js',
+  'confinedSpaceFlowNodes.js',
+  'confinedSpaceFlowTask.js',
+  'heightFlowNodes.js',
+  'heightFlowTask.js',
+  'components/fireTicketDetail.vue',
+  'components/flowNodeForm.vue',
+  'components/confinedSpaceFlow.vue',
+  'components/heightFlow.vue'
+];
+
+let failed = 0;
+function check(name, cond, extra) {
+  if (cond) {
+    console.log('  PASS  ' + name);
+  } else {
+    failed++;
+    console.log(
+      '  FAIL  ' + name + (extra ? ' -> ' + JSON.stringify(extra) : '')
+    );
+  }
+}
+
+// 编译 ESM -> CJS
+// hoistingFlowTask.js 引用了 fireFlowTask.js(只读复用),
+// fireFlowTask.js 又引用了 fireFlowNodes.js,一并编译进来
+fs.mkdirSync(OUT, { recursive: true });
+[
+  'hoistingFlowNodes.js',
+  'hoistingFlowTask.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 hn = require(path.join(OUT, 'hoistingFlowNodes.js'));
+const ht = require(path.join(OUT, 'hoistingFlowTask.js'));
+
+console.log('\n[1] 动火作业、受限空间作业、高处作业既有实现未被改动');
+EXISTING_FILES.forEach((file) => {
+  let changed = true;
+  try {
+    execFileSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
+      cwd: path.join(SRC, '..'),
+      stdio: 'ignore'
+    });
+    changed = false;
+  } catch (e) {
+    changed = true;
+  }
+  check(file + ' 与 HEAD 一致', !changed);
+});
+
+console.log(
+  '\n[2] 吊装作业环节(源系统 HO_PERMIT_HOISTING 共 10 节点,1 级为作业申请模板)'
+);
+const expected = [
+  ['HO_PERMIT_HOISTING_TWOTH', '风险辨识'],
+  ['HO_PERMIT_HOISTING_THREETH', '现场安全措施确认'],
+  ['HO_PERMIT_HOISTING_FOURTH', '安全交底'],
+  ['HO_PERMIT_HOISTING_FIVETH', '作业负责人意见'],
+  ['HO_PERMIT_HOISTING_SIXTH', '所在单位意见'],
+  ['HO_PERMIT_HOISTING_SEVENTH', '审核部门意见'],
+  ['HO_PERMIT_HOISTING_EIGHTTH', '审批部门意见'],
+  ['HO_PERMIT_HOISTING_NINETH', '作业实施'],
+  ['HO_PERMIT_HOISTING_TENTH', '完工验收']
+];
+check('环节数量为 9', hn.HOISTING_FLOW_NODES.length === 9, {
+  len: hn.HOISTING_FLOW_NODES.length
+});
+expected.forEach(([code, name], i) => {
+  const node = hn.HOISTING_FLOW_NODES[i];
+  check(
+    '第' + (i + 2) + '级 ' + code + ' / ' + name,
+    node.code === code && node.name === name,
+    { code: node.code, name: node.name }
+  );
+});
+
+console.log('\n[3] 与源系统 formJson 一致性');
+const nodeOf = (k) => hn.HOISTING_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(
+  '风险辨识:结果为单选(源系统 radio,受限空间为多选)',
+  nodeOf('risk').fields[0].type === 'radio' &&
+    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(
+  '安全交底:安全交底人签字 + 接受交底人签字',
+  JSON.stringify(nodeOf('brief').fields.map((f) => f.label)) ===
+    JSON.stringify(['安全交底人签字', '接受交底人签字']) &&
+    nodeOf('brief').fields.every((f) => f.type === 'sign')
+);
+check(
+  '作业负责人意见:意见 + 签名',
+  nodeOf('leader_opinion').fields.length === 2 &&
+    nodeOf('leader_opinion').fields[0].type === 'textarea' &&
+    nodeOf('leader_opinion').fields[1].type === 'sign'
+);
+check(
+  '所在单位意见:意见 + 签名',
+  nodeOf('unit_opinion').fields.length === 2 &&
+    nodeOf('unit_opinion').fields[0].type === 'textarea' &&
+    nodeOf('unit_opinion').fields[1].type === 'sign'
+);
+check(
+  '审核部门意见:意见 + 签名',
+  nodeOf('audit_opinion').fields.length === 2 &&
+    nodeOf('audit_opinion').fields[0].type === 'textarea' &&
+    nodeOf('audit_opinion').fields[1].type === 'sign'
+);
+check(
+  '审批部门意见:意见 + 签名',
+  nodeOf('approver_opinion').fields.length === 2 &&
+    nodeOf('approver_opinion').fields[0].type === 'textarea' &&
+    nodeOf('approver_opinion').fields[1].type === 'sign'
+);
+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'
+);
+
+// 源系统「现场安全措施确认」节点 formData 的默认 20 条,逐条比对
+const SOURCE_MEASURES = [
+  '一、二级吊装作业已编制吊装作业方案,已经审查批准;吊装物体形状复杂、刚度小,长径比大、精密贵重,作业条件特殊的三级吊装作业,已编制吊装作业方案,已经审查批准',
+  '吊装场所如有含危险物料的设备、管道时,应制定详细吊装方案,并对设备、管道采取有效防护措施,必要时停车,放空物料,置换后再进行吊装作业',
+  '作业人员已按规定佩戴个体防护装备',
+  '已对起重吊装设备、钢丝绳、揽风绳、链条、吊钩等各种机具进行检查,安全可靠',
+  '已明确各自分工',
+  '坚守岗位,并统一规定联络信号',
+  '将建筑物、构筑物作为锚点,应经所属单位工程管理部门审查校算并批准',
+  '吊装绳索、揽风绳、拖拉绳等不应与带电线路接触,并保持安全距离',
+  '不应利用管道、管架、电杆、机电设备等作吊装锚点',
+  '吊物捆扎坚固,未见绳打结、绳不齐现象,棱角吊物已采取衬垫措施',
+  '起重机安全装置灵活好用',
+  '吊装作业人员持有有效的法定资格证书',
+  '地下通信电(光)缆、局域网络电(光)缆,排水沟的益板,承重吊装机械的负重量已确认,保护措施已落实',
+  '起吊物的质量(t)经确认,在吊装机械的承重范围内',
+  '在吊装高度的管线、电缆桥架已做好防护措施',
+  '作业现场围栏、警戒线、警告牌、夜同警示灯已按要求设置',
+  '作业高度和转臂范围内无架空线路',
+  '在爆炸危险场所内的作业,机动车排气管已装阻火器',
+  '露天作业,环境风力满足作业安全要求',
+  '其他相关特殊作业已办理相应安全作业票'
+];
+check(
+  '安全措施默认清单 20 条与源系统 formData 逐条一致',
+  JSON.stringify(hn.DEFAULT_HOISTING_MEASURES) ===
+    JSON.stringify(SOURCE_MEASURES),
+  {
+    len: hn.DEFAULT_HOISTING_MEASURES.length,
+    first: hn.DEFAULT_HOISTING_MEASURES[0],
+    last: hn.DEFAULT_HOISTING_MEASURES[hn.DEFAULT_HOISTING_MEASURES.length - 1]
+  }
+);
+check(
+  '默认行挂在 measure_list 字段上(defaultRows)',
+  Array.isArray(nodeOf('measure').fields[0].defaultRows) &&
+    nodeOf('measure').fields[0].defaultRows.length === 20 &&
+    nodeOf('measure').fields[0].defaultRows[0].measure_content ===
+      hn.DEFAULT_HOISTING_MEASURES[0]
+);
+
+console.log('\n[4] 环节匹配(多级匹配:code / 别名 / 名称 / 双向包含)');
+const m = hn.matchHoistingFlowNode('HO_PERMIT_HOISTING_THREETH');
+check('按任务定义 key 精确命中', m && m.key === 'measure', {
+  key: m && m.key
+});
+check(
+  '按节点名称命中',
+  (hn.matchHoistingFlowNode('现场安全措施确认') || {}).key === 'measure'
+);
+check(
+  '按别名命中',
+  (hn.matchHoistingFlowNode('危险辨识') || {}).key === 'risk'
+);
+check(
+  '按平台自定义 key 反向包含命中',
+  (hn.matchHoistingFlowNode('hoisting_risk') || {}).key === 'risk'
+);
+check(
+  '流程定义 key / 一级作业申请 key 不误命中后续环节',
+  hn.matchHoistingFlowNode('HO_PERMIT_HOISTING') === null &&
+    hn.matchHoistingFlowNode('HO_PERMIT_HOISTING_ONETH') === null
+);
+check('空标识返回 null', hn.matchHoistingFlowNode('', null) === null);
+
+console.log('\n[5] 运行时辅助(回显 / 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 = ht.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 = ht.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 = ht.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(
+  '回显:单选框的值不会串到风险辨识说明',
+  ht.loadNodeValues(nodeOf('risk'), [{ risk_result: '9' }]).risk_result_desc ===
+    undefined
+);
+const briefPayload = ht.buildNodePayload(nodeOf('brief'), {
+  brief_sign: '/tmp/briefer.png',
+  brief_receiver_sign: '/tmp/receiver.png'
+});
+check(
+  '安全交底:两个签字写入 payload 与流程变量',
+  briefPayload.brief_sign === '/tmp/briefer.png' &&
+    briefPayload.brief_receiver_sign === '/tmp/receiver.png' &&
+    ht.buildNodeVariables(nodeOf('brief'), {
+      brief_sign: '/tmp/briefer.png'
+    }).brief_sign === '/tmp/briefer.png'
+);
+
+console.log('\n[6] 组件模板与脚本一致性(模板调用的方法必须已在组件中定义)');
+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/hoistingFlow.vue');
+const sfcRaw = fs.readFileSync(sfcPath, 'utf8');
+const sfc = compiler.parseComponent(sfcRaw);
+const templateCheck = collectTemplateCalls(sfc.template.content);
+const members = collectComponentMembers(sfc.script.content);
+check(
+  'hoistingFlow.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(便于排查流程模型配置)',
+  templateCheck.calls.has('taskLabel') || /taskLabel/.test(sfcRaw)
+);
+check(
+  '子表日期列渲染为日期时间选择器(el-date-picker)',
+  /<el-date-picker[^>]*column\.type === 'date'/.test(sfcRaw)
+);
+check(
+  '作业实施:事中记录表「日期时间」列类型为 date',
+  nodeOf('execute')
+    .fields.find((f) => f.key === 'during_record_list')
+    .columns.some((c) => c.key === 'record_time' && c.type === 'date')
+);
+check(
+  '组件引用的是吊装作业自己的节点/任务文件',
+  /from '\.\.\/hoistingFlowNodes'/.test(sfc.script.content) &&
+    /from '\.\.\/hoistingFlowTask'/.test(sfc.script.content) &&
+    !/from '\.\.\/heightFlow/.test(sfc.script.content) &&
+    !/heightFlowNodes|heightFlowTask/.test(sfc.script.content)
+);
+
+console.log('\n[7] detail.vue:四类作业的分支互斥且都已接入');
+const detailSrc = fs.readFileSync(path.join(SRC, 'detail.vue'), 'utf8');
+check(
+  'detail.vue:按作业类型分发到各自的后续环节组件',
+  /isConfinedSpaceWork[\s\S]{0,200}getConfinedTableValue/.test(detailSrc) &&
+    /isHeightWork[\s\S]{0,200}getHeightTableValue/.test(detailSrc) &&
+    /isHeightWork[\s\S]{0,200}saveHeight/.test(detailSrc) &&
+    /isHoistingWork[\s\S]{0,200}getHoistingTableValue/.test(detailSrc) &&
+    /isHoistingWork[\s\S]{0,200}saveHoisting/.test(detailSrc)
+);
+check(
+  'detail.vue:吊装作业的 tab 与组件已注册',
+  /name="flowHoisting"/.test(detailSrc) &&
+    /<hoisting-flow/.test(detailSrc) &&
+    /import HoistingFlow from '\.\/components\/hoistingFlow\.vue'/.test(
+      detailSrc
+    )
+);
+check(
+  'detail.vue:作业票模板已包含吊装作业(templates.js key = hoisting)',
+  /key:\s*'hoisting'/.test(
+    fs.readFileSync(path.join(SRC, 'templates.js'), 'utf8')
+  )
+);
+check(
+  'detail.vue:自动切 tab 在当前环节识别完成之后执行',
+  /loadCurrentNode\(\)\.then\(\(\) => \{[\s\S]{0,120}switchToFlowTab\(\)/.test(
+    detailSrc
+  ) &&
+    /await this\.loadCurrentNode\(\);[\s\S]{0,120}switchToFlowTab\(\)/.test(
+      detailSrc
+    )
+);
+// 受限空间、高处作业组件只做只读体检,不参与断言(既有实现不做任何改动)
+['components/confinedSpaceFlow.vue', 'components/heightFlow.vue'].forEach(
+  (file) => {
+    const otherSfc = compiler.parseComponent(
+      fs.readFileSync(path.join(SRC, file), 'utf8')
+    );
+    const otherCheck = collectTemplateCalls(otherSfc.template.content);
+    const otherMembers = collectComponentMembers(otherSfc.script.content);
+    const otherMissing = [...otherCheck.calls].filter(
+      (name) => !otherMembers.has(name)
+    );
+    console.log(
+      '  INFO  ' +
+        file +
+        ' 模板调用体检:缺失 ' +
+        otherMissing.length +
+        ' 个' +
+        (otherMissing.length ? ' -> ' + JSON.stringify(otherMissing) : '')
+    );
+  }
+);
+
+console.log('\n' + (failed ? 'FAILED: ' + failed + ' 项' : 'ALL PASSED'));
+process.exit(failed ? 1 : 0);

+ 954 - 0
src/views/bpm/handleTask/components/dangerousWorkTicket/components/hoistingFlow.vue

@@ -0,0 +1,954 @@
+<!--
+  吊装作业票「后续环节」组件(当前环节可填写 + 全部环节只读)
+
+  职责:把吊装作业票的后续环节逻辑集中在这一个组件里,
+        动火作业(flowNodeForm.vue / fireTicketDetail.vue)、
+        受限空间作业(confinedSpaceFlow.vue)、
+        高处作业(heightFlow.vue)的实现不做任何改动。
+
+  样式与结构照搬受限空间作业(confinedSpaceFlow.vue)的实现。
+
+  环节结构照搬源系统(IntellSecurity)HO_PERMIT_HOISTING 流程:
+    风险辨识 → 现场安全措施确认 → 安全交底 → 作业负责人意见
+    → 所在单位意见 → 审核部门意见 → 审批部门意见
+    → 作业实施 → 完工验收
+
+  字段类型 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="hoisting-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>
+
+      <!-- 该节点未配置表单项时,只需填写审批意见 -->
+      <div v-if="!hasFields" class="node-empty-tip">
+        该环节无需填写内容,请直接在右侧填写审批意见
+      </div>
+
+      <el-form
+        v-else
+        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">
+            <!-- 未配置表单项的环节 -->
+            <div v-if="!item.fields || !item.fields.length" class="empty-text">
+              该环节无需填写内容
+            </div>
+            <el-form
+              v-else
+              :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 '../hoistingFlowNodes';
+  import {
+    loadNodeValues,
+    buildNodePayload,
+    buildNodeVariables
+  } from '../hoistingFlowTask';
+  import { getFilePreviewUrl as buildFilePreviewUrl } from '@/utils/file';
+
+  export default {
+    name: 'HoistingFlow',
+    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: {
+      // 当前环节是否配置了表单项
+      hasFields() {
+        return !!(this.node?.fields || []).length;
+      },
+      // 排除当前环节(当前环节已在页面上方单独展示,避免重复)
+      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.hasFields || !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;
+  }
+  .node-empty-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,10 +6,11 @@
     / 作业开始时间 / 作业结束时间 / 作业票模板 / 作业票内容 / 备注
   作业票内容按 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
   「后续环节」tab 顶部为当前处理环节(可填写),下方为全部环节的查看内容;
   环节填写值通过 getTableValue 提供给审批组件(submit.vue),
   在审批通过前合并写入作业票内容(extensionJson)
@@ -214,6 +215,23 @@
           :task-label="currentTaskName || taskDefinitionKey"
         />
       </el-tab-pane>
+
+      <!-- 作业类型专项内容(吊装作业票,独立组件实现) -->
+      <el-tab-pane
+        v-if="isHoistingWork || currentHoistingNode"
+        label="后续环节"
+        name="flowHoisting"
+      >
+        <hoisting-flow
+          ref="hoistingFlow"
+          :node="currentHoistingNode"
+          :nodes="hoistingFlowNodes"
+          :ticket="ticket"
+          :extension="extension"
+          :variables="variables"
+          :task-label="currentTaskName || taskDefinitionKey"
+        />
+      </el-tab-pane>
     </el-tabs>
   </div>
 </template>
@@ -245,11 +263,17 @@
   } from './confinedSpaceFlowNodes';
   // 高处作业:独立实现,动火 / 受限空间相关文件不做任何改动
   import { matchHeightFlowNode, HEIGHT_FLOW_NODES } from './heightFlowNodes';
+  // 吊装作业:独立实现,动火 / 受限空间 / 高处作业相关文件不做任何改动
+  import {
+    matchHoistingFlowNode,
+    HOISTING_FLOW_NODES
+  } from './hoistingFlowNodes';
   import TicketContentView from './components/ticketContentView.vue';
   import FireTicketDetail from './components/fireTicketDetail.vue';
   import FlowNodeForm from './components/flowNodeForm.vue';
   import ConfinedSpaceFlow from './components/confinedSpaceFlow.vue';
   import HeightFlow from './components/heightFlow.vue';
+  import HoistingFlow from './components/hoistingFlow.vue';
 
   // 作业票状态:1-待提交、2-审批中、3-已通过、4-已驳回
   const TICKET_STATUS = {
@@ -269,7 +293,8 @@
       FireTicketDetail,
       FlowNodeForm,
       ConfinedSpaceFlow,
-      HeightFlow
+      HeightFlow,
+      HoistingFlow
     },
     props: {
       // 作业票 id
@@ -420,6 +445,31 @@
         }
         return matchHeightFlowNode(this.taskDefinitionKey, this.currentTaskName);
       },
+      /**
+       * 是否吊装作业
+       */
+      isHoistingWork() {
+        return this.template?.key === 'hoisting';
+      },
+      /**
+       * 吊装作业的全部后续环节
+       */
+      hoistingFlowNodes() {
+        return HOISTING_FLOW_NODES;
+      },
+      /**
+       * 当前处理环节(吊装作业)
+       * 任务定义 key 在不同平台不一致,用「任务定义 key + 任务名称」一起匹配
+       */
+      currentHoistingNode() {
+        if (!this.isHoistingWork) {
+          return null;
+        }
+        return matchHoistingFlowNode(
+          this.taskDefinitionKey,
+          this.currentTaskName
+        );
+      },
       // 作业票状态文案
       statusText() {
         return (
@@ -529,6 +579,10 @@
         if (this.currentHeightNode) {
           return;
         }
+        // 吊装作业:环节内容由吊装作业自己的组件回显,这里不参与
+        if (this.currentHoistingNode) {
+          return;
+        }
         const node = this.currentFireNode;
         if (!node) {
           return;
@@ -553,6 +607,10 @@
         }
         if (this.currentHeightNode) {
           this.activeTab = 'flowHeight';
+          return;
+        }
+        if (this.currentHoistingNode) {
+          this.activeTab = 'flowHoisting';
         }
       },
       // 环节填写值变化
@@ -569,6 +627,9 @@
         if (this.isHeightWork) {
           return this.validateHeightNodeForm();
         }
+        if (this.isHoistingWork) {
+          return this.validateHoistingNodeForm();
+        }
         if (!this.currentFireNode || !this.$refs.nodeForm) {
           return true;
         }
@@ -592,6 +653,15 @@
         }
         return this.$refs.heightFlow.validate();
       },
+      /**
+       * 吊装作业的当前环节表单校验
+       */
+      async validateHoistingNodeForm() {
+        if (!this.currentHoistingNode || !this.$refs.hoistingFlow) {
+          return true;
+        }
+        return this.$refs.hoistingFlow.validate();
+      },
       /**
        * 供审批组件(submit.vue)取当前环节数据
        * 取数的同时把环节内容合并进作业票内容(extensionJson)并落库,
@@ -605,6 +675,9 @@
         if (this.isHeightWork) {
           return this.getHeightTableValue();
         }
+        if (this.isHoistingWork) {
+          return this.getHoistingTableValue();
+        }
         if (!this.currentFireNode) {
           return {
             valid: true,
@@ -714,6 +787,44 @@
           message: saved.message
         };
       },
+      /**
+       * 取吊装作业当前环节的数据(供审批组件 submit.vue 使用)
+       * 取数的同时把环节内容合并进作业票内容(extensionJson)并落库,
+       * 保证审批通过后数据已经落在作业票上
+       * @returns {Promise<{valid: boolean, variables: object, payload: object, saved: boolean, message: string}>}
+       */
+      async getHoistingTableValue() {
+        const flow = this.$refs.hoistingFlow;
+        if (!this.currentHoistingNode || !flow) {
+          return {
+            valid: true,
+            variables: {},
+            payload: {},
+            saved: true,
+            message: ''
+          };
+        }
+        const valid = await this.validateHoistingNodeForm();
+        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)并保存
        */
@@ -750,6 +861,9 @@
         if (this.isHeightWork) {
           return this.saveHeight(data);
         }
+        if (this.isHoistingWork) {
+          return this.saveHoisting(data);
+        }
         const payload =
           data && data.payload
             ? data.payload
@@ -815,6 +929,30 @@
         }
         return data;
       },
+      /**
+       * 吊装作业:供审批组件提交时调用,保存业务数据
+       */
+      async saveHoisting(data) {
+        const flow = this.$refs.hoistingFlow;
+        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 {

+ 403 - 0
src/views/bpm/handleTask/components/dangerousWorkTicket/hoistingFlowNodes.js

@@ -0,0 +1,403 @@
+/**
+ * 吊装作业票后续环节(源系统 HO_PERMIT_HOISTING 流程节点)
+ *
+ * 来源:源系统(IntellSecurity)流程定义接口
+ *   /flow/getFlowDefineListByEnterprise.do      -> 流程定义 HO_PERMIT_HOISTING(id 3765,名称「危险作业-吊装作业」)
+ *   /flow/getFlowNodeDefineByFlowid.do?flow_id=3765 -> 节点定义(共 10 个节点)
+ *
+ * 流程定义 define_code = HO_PERMIT_HOISTING,编号前缀 DZTY,共 10 个节点:
+ *   一级:作业申请(对应 EHS 作业票模板 templates.js 的 hoisting)
+ *   以下为「后面的内容」:
+ *     二级 风险辨识          HO_PERMIT_HOISTING_TWOTH
+ *     三级 现场安全措施确认  HO_PERMIT_HOISTING_THREETH
+ *     四级 安全交底          HO_PERMIT_HOISTING_FOURTH
+ *     五级 作业负责人意见    HO_PERMIT_HOISTING_FIVETH
+ *     六级 所在单位意见      HO_PERMIT_HOISTING_SIXTH
+ *     七级 审核部门意见      HO_PERMIT_HOISTING_SEVENTH
+ *     八级 审批部门意见      HO_PERMIT_HOISTING_EIGHTTH
+ *     九级 作业实施          HO_PERMIT_HOISTING_NINETH
+ *     十级 完工验收          HO_PERMIT_HOISTING_TENTH
+ *
+ * 与受限空间作业(HO_PERMIT_CONFINED_SPACE,共 8 节点)的差异:
+ *   - 没有「气体分析」环节,也没有「气体分析记录表」子表
+ *   - 风险辨识结果是 radio(单选),受限空间是 checkbox(多选)
+ *   - 风险辨识说明在源系统是 input(单行文本),受限空间是 textarea
+ *   - 现场安全措施默认清单为 20 条(吊装作业专属),受限空间是 14 条
+ *   - 多出「作业负责人意见 / 审核部门意见 / 审批部门意见」三个意见类环节
+ *   - 作业实施环节只有「事中记录表」,没有气体分析记录表
+ *
+ * 与高处作业(HO_PERMIT_HEIGHT,共 10 节点)的环节结构完全一致,
+ * 差异只在现场安全措施默认清单的内容(20 条)。
+ *
+ * 本文件只服务吊装作业,与动火(fireFlowNodes.js)、
+ * 受限空间(confinedSpaceFlowNodes.js)、
+ * 高处作业(heightFlowNodes.js)的实现完全独立,既有实现不做任何改动。
+ *
+ * 字段类型 type:
+ *   text      普通文本
+ *   textarea  多行文本
+ *   select    下拉(options 静态选项)
+ *   radio     单选(options 静态选项)
+ *   checkbox  多选(options 静态选项)
+ *   date      日期时间
+ *   sign      手写签名
+ *   table     子表(columns 为列定义)
+ */
+
+/**
+ * 风险辨识结果选项(源系统 radio 固定 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: '已确认' }
+];
+
+/**
+ * 吊装作业现场安全措施清单(源系统「现场安全措施确认」节点默认带入的 20 条)
+ *
+ * 注意:第 13、16 条的「益板」「夜同警示灯」是源系统原文(应为「盖板」「夜间」),
+ * 这里按源系统逐字保留,避免与源系统对不上。
+ */
+export const DEFAULT_HOISTING_MEASURES = [
+  '一、二级吊装作业已编制吊装作业方案,已经审查批准;吊装物体形状复杂、刚度小,长径比大、精密贵重,作业条件特殊的三级吊装作业,已编制吊装作业方案,已经审查批准',
+  '吊装场所如有含危险物料的设备、管道时,应制定详细吊装方案,并对设备、管道采取有效防护措施,必要时停车,放空物料,置换后再进行吊装作业',
+  '作业人员已按规定佩戴个体防护装备',
+  '已对起重吊装设备、钢丝绳、揽风绳、链条、吊钩等各种机具进行检查,安全可靠',
+  '已明确各自分工',
+  '坚守岗位,并统一规定联络信号',
+  '将建筑物、构筑物作为锚点,应经所属单位工程管理部门审查校算并批准',
+  '吊装绳索、揽风绳、拖拉绳等不应与带电线路接触,并保持安全距离',
+  '不应利用管道、管架、电杆、机电设备等作吊装锚点',
+  '吊物捆扎坚固,未见绳打结、绳不齐现象,棱角吊物已采取衬垫措施',
+  '起重机安全装置灵活好用',
+  '吊装作业人员持有有效的法定资格证书',
+  '地下通信电(光)缆、局域网络电(光)缆,排水沟的益板,承重吊装机械的负重量已确认,保护措施已落实',
+  '起吊物的质量(t)经确认,在吊装机械的承重范围内',
+  '在吊装高度的管线、电缆桥架已做好防护措施',
+  '作业现场围栏、警戒线、警告牌、夜同警示灯已按要求设置',
+  '作业高度和转臂范围内无架空线路',
+  '在爆炸危险场所内的作业,机动车排气管已装阻火器',
+  '露天作业,环境风力满足作业安全要求',
+  '其他相关特殊作业已办理相应安全作业票'
+];
+
+/**
+ * 吊装作业票后续环节(源系统第二级 ~ 第十级节点)
+ * key:作业票内容(extensionJson / 流程变量)中的取值 key
+ * alias:取值时的兼容 key(中文标签等),按顺序匹配
+ */
+export const HOISTING_FLOW_NODES = [
+  {
+    key: 'risk',
+    code: 'HO_PERMIT_HOISTING_TWOTH',
+    name: '风险辨识',
+    aliases: ['风险辨识', '风险识别', '危险辨识', '风险辨识结果'],
+    fields: [
+      {
+        key: 'risk_result',
+        label: '风险辨识结果',
+        type: 'radio',
+        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_HOISTING_THREETH',
+    name: '现场安全措施确认',
+    aliases: [
+      '现场安全措施确认',
+      '安全措施确认',
+      '现场安全措施',
+      '安全措施清单'
+    ],
+    fields: [
+      {
+        key: 'measure_list',
+        label: '安全措施确认表',
+        type: 'table',
+        alias: ['安全措施确认表', 'hoisting_measures', 'measureList'],
+        // 未填写时带出源系统默认的吊装作业安全措施清单
+        defaultRows: DEFAULT_HOISTING_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: 'text' }
+        ]
+      },
+      {
+        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_HOISTING_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_HOISTING_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_HOISTING_SIXTH',
+    name: '所在单位意见',
+    aliases: ['所在单位意见', '单位意见', '属地单位意见'],
+    fields: [
+      {
+        key: 'unit_opinion',
+        label: '所在单位意见',
+        type: 'textarea',
+        alias: ['所在单位意见', 'dept_opinion']
+      },
+      {
+        key: 'unit_sign',
+        label: '手写签名',
+        type: 'sign',
+        // 源系统标签为「所在单位签字」
+        alias: ['所在单位签字', '所在单位签名', 'unit_sign']
+      }
+    ]
+  },
+  {
+    key: 'audit_opinion',
+    code: 'HO_PERMIT_HOISTING_SEVENTH',
+    name: '审核部门意见',
+    aliases: ['审核部门意见', '审核部门', '审核意见'],
+    fields: [
+      {
+        key: 'audit_opinion',
+        label: '审核部门意见',
+        type: 'textarea',
+        alias: ['审核部门意见', 'audit_dept_opinion']
+      },
+      {
+        key: 'audit_sign',
+        label: '手写签名',
+        type: 'sign',
+        alias: ['审核部门签名', 'audit_sign']
+      }
+    ]
+  },
+  {
+    key: 'approver_opinion',
+    code: 'HO_PERMIT_HOISTING_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_HOISTING_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_HOISTING_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_HOISTING_TWOTH,
+ * 本平台流程模型里的 key 由流程设计器决定,可能完全不同,
+ * 因此按「精确 -> 双向包含」多级匹配,并用任务名称一起参与匹配。
+ *
+ * 匹配顺序:
+ *   1) 节点 code / key 精确命中
+ *   2) 别名精确命中
+ *   3) 节点名称精确命中
+ *   4) 双向包含(节点名、别名、节点 key 与标识互相包含)
+ *
+ * 说明:第 4 步只把节点 key 纳入比较,不纳入节点 code。
+ *   节点 code 是源系统编码(如 HO_PERMIT_HOISTING_TWOTH),
+ *   流程定义 key(HO_PERMIT_HOISTING)是它的前缀,
+ *   纳入比较会让「一级作业申请」的任务 key 误命中第一个后续环节。
+ *
+ * @param  {...any} candidates 候选标识(任务定义 key、任务名称等)
+ * @returns {object|null} 匹配到的节点,未匹配返回 null
+ */
+export function matchHoistingFlowNode(...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 HOISTING_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 HOISTING_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/hoistingFlowTask.js

@@ -0,0 +1,113 @@
+/**
+ * 吊装作业作业票流程环节的运行时辅助
+ *
+ * 职责:
+ *   1) 取流程实例当前待办任务的信息(任务定义 key + 任务名称)
+ *   2) 按吊装作业节点 schema 从数据源取值(回显)
+ *   3) 生成环节内容(写入作业票内容 extensionJson 的 payload)
+ *   4) 生成随审批提交的流程变量(字符串化,兼容流程变量类型限制)
+ *
+ * 与动火 / 受限空间 / 高处作业实现的关系:
+ *   - fireFlowTask.js 未做任何改动,这里仅「只读」复用其中与作业类型无关的部分
+ *     (取待办任务、读流程变量、合并作业票内容、组装作业票修改参数);
+ *   - 取值口径(字段 key 候选)由吊装作业自己的 hoistingFlowNodes.js 提供,
+ *     不依赖动火 / 受限空间 / 高处作业的节点定义。
+ */
+import {
+  getCurrentTaskInfo,
+  loadProcessFormVariables,
+  mergeExtension,
+  buildTicketUpdateParams,
+  parseJsonObject
+} from './fireFlowTask';
+import { getFieldKeys } from './hoistingFlowNodes';
+
+// 通用能力直接复用(动火实现本身未改动)
+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;
+}