check-break-road-work-flow-nodes.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. /**
  2. * 断路作业票审批流校验
  3. *
  4. * 用法:node scripts/check-break-road-work-flow-nodes.js
  5. *
  6. * 校验内容:
  7. * 1) 动火、受限空间、高处作业、吊装作业、临时用电作业、动土作业的既有实现文件未被改动
  8. * (与 HEAD 完全一致)
  9. * 2) detail.vue 只做「新增」:既有作业类型的分发逻辑一行未删
  10. * 3) 断路作业 9 个后续环节的 code / name 与源系统一致
  11. * 4) 各环节的表单设计与源系统 formJson 一致(字段类型 / 列 / 必填项)
  12. * 5) 环节匹配:按 code / 别名 / 名称 / 双向包含都能命中
  13. * 6) 默认安全措施清单与源系统 formData 逐条一致
  14. * 7) 运行时辅助(回显 / payload / 流程变量)的取值口径正确
  15. * 8) 组件模板与脚本一致:模板里调用的方法必须都已在组件中定义
  16. * (漏定义会在渲染到该分支时抛 "is not a function",整个环节区域渲染不出来)
  17. *
  18. * 源系统依据:
  19. * https://www.acwlkj.com/IntellSecurity-web/flow/getFlowDefineListByEnterprise.do
  20. * -> 流程定义 HO_PERMIT_BREAK_ROAD(id 3768,名称「危险作业-断路作业」,编号前缀 DLZY)
  21. * https://www.acwlkj.com/IntellSecurity-web/flow/getFlowNodeDefineByFlowid.do?flow_id=3768
  22. * -> 节点定义(共 10 个节点,1 级为作业申请)
  23. */
  24. const fs = require('fs');
  25. const os = require('os');
  26. const path = require('path');
  27. const { execFileSync } = require('child_process');
  28. const babel = require('@babel/core');
  29. const Module = require('module');
  30. const ROOT = path.resolve(__dirname, '..');
  31. const SRC = path.join(
  32. ROOT,
  33. 'src/views/bpm/handleTask/components/dangerousWorkTicket'
  34. );
  35. const OUT = path.join(os.tmpdir(), 'kd-aiot-check-break-road-work');
  36. // 既有作业类型的实现文件:断路作业实现不得改动它们
  37. // (路径相对仓库根,便于 git diff 精确比对)
  38. const EXISTING_FILES = [
  39. 'src/views/bpm/handleTask/components/dangerousWorkTicket/fireFlowNodes.js',
  40. 'src/views/bpm/handleTask/components/dangerousWorkTicket/fireFlowTask.js',
  41. 'src/views/bpm/handleTask/components/dangerousWorkTicket/confinedSpaceFlowNodes.js',
  42. 'src/views/bpm/handleTask/components/dangerousWorkTicket/confinedSpaceFlowTask.js',
  43. 'src/views/bpm/handleTask/components/dangerousWorkTicket/heightFlowNodes.js',
  44. 'src/views/bpm/handleTask/components/dangerousWorkTicket/heightFlowTask.js',
  45. 'src/views/bpm/handleTask/components/dangerousWorkTicket/hoistingFlowNodes.js',
  46. 'src/views/bpm/handleTask/components/dangerousWorkTicket/hoistingFlowTask.js',
  47. 'src/views/bpm/handleTask/components/dangerousWorkTicket/electricityFlowNodes.js',
  48. 'src/views/bpm/handleTask/components/dangerousWorkTicket/electricityFlowTask.js',
  49. 'src/views/bpm/handleTask/components/dangerousWorkTicket/breakGroundFlowNodes.js',
  50. 'src/views/bpm/handleTask/components/dangerousWorkTicket/breakGroundFlowTask.js',
  51. 'src/views/bpm/handleTask/components/dangerousWorkTicket/components/fireTicketDetail.vue',
  52. 'src/views/bpm/handleTask/components/dangerousWorkTicket/components/flowNodeForm.vue',
  53. 'src/views/bpm/handleTask/components/dangerousWorkTicket/components/confinedSpaceFlow.vue',
  54. 'src/views/bpm/handleTask/components/dangerousWorkTicket/components/heightFlow.vue',
  55. 'src/views/bpm/handleTask/components/dangerousWorkTicket/components/hoistingFlow.vue',
  56. 'src/views/bpm/handleTask/components/dangerousWorkTicket/components/electricityFlow.vue',
  57. 'src/views/bpm/handleTask/components/dangerousWorkTicket/components/breakGroundFlow.vue',
  58. 'src/views/bpm/handleTask/components/dangerousWorkTicket/templates.js',
  59. 'src/views/bpm/handleTask/components/dangerousWorkTicket/workTypes.js'
  60. ];
  61. let failed = 0;
  62. function check(name, cond, extra) {
  63. if (cond) {
  64. console.log(' PASS ' + name);
  65. } else {
  66. failed++;
  67. console.log(
  68. ' FAIL ' + name + (extra ? ' -> ' + JSON.stringify(extra) : '')
  69. );
  70. }
  71. }
  72. /**
  73. * 取文件的 git diff 文本
  74. * @param {string} file 相对仓库根的文件路径
  75. * @returns {string}
  76. */
  77. function gitDiff(file) {
  78. try {
  79. return execFileSync('git', ['diff', 'HEAD', '--', file], {
  80. cwd: ROOT,
  81. encoding: 'utf8',
  82. maxBuffer: 1024 * 1024 * 20
  83. });
  84. } catch (e) {
  85. return '';
  86. }
  87. }
  88. /**
  89. * 取 diff 里被删除的行(去掉 diff 头部的 --- / ---)
  90. * @param {string} diff
  91. * @returns {string[]}
  92. */
  93. function removedLines(diff) {
  94. return (diff || '')
  95. .split(/\r?\n/)
  96. .filter((line) => line.startsWith('-') && !line.startsWith('---'))
  97. .map((line) => line.slice(1).trim());
  98. }
  99. /**
  100. * 读取 detail.vue 源码
  101. * @returns {string}
  102. */
  103. function detailSrc() {
  104. return fs.readFileSync(path.join(SRC, 'detail.vue'), 'utf8');
  105. }
  106. // 编译 ESM -> CJS
  107. // breakRoadFlowTask.js 引用了 fireFlowTask.js(只读复用),
  108. // fireFlowTask.js 又引用了 fireFlowNodes.js,一并编译进来
  109. fs.mkdirSync(OUT, { recursive: true });
  110. [
  111. 'breakRoadFlowNodes.js',
  112. 'breakRoadFlowTask.js',
  113. 'fireFlowNodes.js',
  114. 'fireFlowTask.js'
  115. ].forEach((file) => {
  116. const code = fs.readFileSync(path.join(SRC, file), 'utf8');
  117. const res = babel.transformSync(code, {
  118. filename: file,
  119. babelrc: false,
  120. configFile: false,
  121. plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
  122. });
  123. fs.writeFileSync(path.join(OUT, file), res.code);
  124. });
  125. // 断路作业运行时辅助会引用 @/api/*,这里用桩模块代替,避免校验脚本依赖后端接口
  126. const STUB = path.join(OUT, 'stub.js');
  127. fs.writeFileSync(
  128. STUB,
  129. 'module.exports = {\n' +
  130. ' getTaskListByProcessInstanceId: function () { return Promise.resolve([]); },\n' +
  131. ' getProcessInstance: function () { return Promise.resolve({}); }\n' +
  132. '};\n'
  133. );
  134. const originalResolve = Module._resolveFilename;
  135. Module._resolveFilename = function (request, ...args) {
  136. if (request.startsWith('@/')) {
  137. return STUB;
  138. }
  139. return originalResolve.call(this, request, ...args);
  140. };
  141. const br = require(path.join(OUT, 'breakRoadFlowNodes.js'));
  142. const bt = require(path.join(OUT, 'breakRoadFlowTask.js'));
  143. console.log(
  144. '\n[1] 动火 / 受限空间 / 高处作业 / 吊装作业 / 临时用电作业 / 动土作业既有实现未被改动'
  145. );
  146. EXISTING_FILES.forEach((file) => {
  147. let changed = true;
  148. try {
  149. execFileSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
  150. cwd: ROOT,
  151. stdio: 'ignore'
  152. });
  153. changed = false;
  154. } catch (e) {
  155. changed = true;
  156. }
  157. check(file.split('/').pop() + ' 与 HEAD 一致', !changed);
  158. });
  159. console.log('\n[2] detail.vue 只做新增(既有作业类型的分发逻辑一行未删)');
  160. const DETAIL =
  161. 'src/views/bpm/handleTask/components/dangerousWorkTicket/detail.vue';
  162. const detailDiff = gitDiff(DETAIL);
  163. const detailRemoved = removedLines(detailDiff);
  164. // 允许删除的行:仅文件头注释里的「六类作业」说明,
  165. // 以及组件注册里因补逗号而重排的 BreakGroundFlow
  166. const ALLOWED_REMOVED = [
  167. '动火、受限空间、高处作业、吊装作业、临时用电作业、动土作业六类作业共用本页面与 submit.vue,按作业类型分发到各自的组件:',
  168. 'BreakGroundFlow'
  169. ];
  170. const unexpectedRemoved = detailRemoved.filter(
  171. (line) => !ALLOWED_REMOVED.includes(line)
  172. );
  173. check(
  174. `detail.vue 的删除行仅为预期的 ${ALLOWED_REMOVED.length} 行`,
  175. unexpectedRemoved.length === 0,
  176. unexpectedRemoved
  177. );
  178. check(
  179. 'detail.vue 新增了断路作业的 tab 分发',
  180. /<break-road-flow[\s\S]{0,300}ref="breakRoadFlow"/.test(detailDiff)
  181. );
  182. check(
  183. 'detail.vue 新增了断路作业的当前环节计算属性',
  184. /currentBreakRoadNode\(\)\s*\{[\s\S]{0,200}matchBreakRoadFlowNode/.test(
  185. detailDiff
  186. )
  187. );
  188. check(
  189. 'detail.vue 新增了断路作业的取数 / 保存方法',
  190. /getBreakRoadTableValue\(\)/.test(detailDiff) &&
  191. /saveBreakRoad\(data\)/.test(detailDiff) &&
  192. /validateBreakRoadNodeForm\(\)/.test(detailDiff)
  193. );
  194. check(
  195. 'detail.vue 注册了断路作业组件',
  196. /import BreakRoadFlow from '\.\/components\/breakRoadFlow\.vue'/.test(
  197. detailSrc()
  198. ) && /BreakRoadFlow/.test(detailSrc())
  199. );
  200. check(
  201. 'detail.vue 的自动切 tab 在当前环节识别完成之后执行(未被破坏)',
  202. /loadCurrentNode\(\)\.then\(\(\) => \{[\s\S]{0,120}switchToFlowTab\(\)/.test(
  203. detailSrc()
  204. ) &&
  205. /await this\.loadCurrentNode\(\);[\s\S]{0,120}switchToFlowTab\(\)/.test(
  206. detailSrc()
  207. )
  208. );
  209. console.log(
  210. '\n[3] 断路作业环节(源系统 HO_PERMIT_BREAK_ROAD 共 10 节点,1 级为作业申请模板)'
  211. );
  212. const expected = [
  213. ['HO_PERMIT_BREAK_ROAD_TWOTH', '风险辨识'],
  214. ['HO_PERMIT_BREAK_ROAD_THREETH', '现场安全措施确认'],
  215. ['HO_PERMIT_BREAK_ROAD_FOURTH', '安全交底'],
  216. ['HO_PERMIT_BREAK_ROAD_FIVETH', '作业负责人意见'],
  217. ['HO_PERMIT_BREAK_ROAD_SIXTH', '所在单位意见'],
  218. ['HO_PERMIT_BREAK_ROAD_SEVENTH', '消防、安全部门意见'],
  219. ['HO_PERMIT_BREAK_ROAD_EIGHTTH', '审批部门意见'],
  220. ['HO_PERMIT_BREAK_ROAD_NINETH', '作业实施'],
  221. ['HO_PERMIT_BREAK_ROAD_TENTH', '完工验收']
  222. ];
  223. check('环节数量为 9', br.BREAK_ROAD_FLOW_NODES.length === 9, {
  224. len: br.BREAK_ROAD_FLOW_NODES.length
  225. });
  226. expected.forEach(([code, name], i) => {
  227. const node = br.BREAK_ROAD_FLOW_NODES[i];
  228. check(
  229. '第' + (i + 2) + '级 ' + code + ' / ' + name,
  230. node.code === code && node.name === name,
  231. { code: node.code, name: node.name }
  232. );
  233. });
  234. console.log('\n[4] 与源系统 formJson 一致性');
  235. const nodeOf = (k) => br.BREAK_ROAD_FLOW_NODES.find((n) => n.key === k);
  236. const colsOf = (k, fk) => {
  237. const node = nodeOf(k);
  238. const field = node.fields.find((f) => f.key === fk);
  239. return (field.columns || []).map((c) => c.label);
  240. };
  241. check(
  242. '风险辨识:结果为多选 20 项(源系统 checkbox)',
  243. nodeOf('risk').fields[0].type === 'checkbox' &&
  244. nodeOf('risk').fields[0].options.length === 20 &&
  245. nodeOf('risk').fields[0].options[0].label === '物体打击' &&
  246. nodeOf('risk').fields[0].options[19].label === '其它伤害'
  247. );
  248. check(
  249. '风险辨识:说明字段为多行文本(源系统 textarea)',
  250. nodeOf('risk').fields[1].type === 'textarea'
  251. );
  252. check(
  253. '风险辨识:说明字段别名不与多选框 key 冲突',
  254. !nodeOf('risk').fields[1].alias.includes('risk_result') &&
  255. !nodeOf('risk').fields[1].alias.includes('riskResult')
  256. );
  257. check(
  258. '现场安全措施确认:表名「安全措施确认表」,3 列(安全措施/是否确认/确认人)',
  259. nodeOf('measure').fields[0].label === '安全措施确认表' &&
  260. JSON.stringify(colsOf('measure', 'measure_list')) ===
  261. JSON.stringify(['安全措施', '是否确认', '确认人'])
  262. );
  263. check(
  264. '现场安全措施确认:编制人签字 + 确认人签字',
  265. JSON.stringify(nodeOf('measure').fields.map((f) => f.label)) ===
  266. JSON.stringify(['安全措施确认表', '编制人签字', '确认人签字'])
  267. );
  268. check(
  269. '现场安全措施确认:确认列为单选(不涉及 / 已确认)',
  270. nodeOf('measure')
  271. .fields[0].columns.find((c) => c.key === 'measure_confirm')
  272. .options.map((o) => o.label)
  273. .join(',') === '不涉及,已确认'
  274. );
  275. check(
  276. '现场安全措施确认:确认人列为单行文本(源系统 input)',
  277. nodeOf('measure').fields[0].columns.find((c) => c.key === 'measure_confirmer')
  278. .type === 'text'
  279. );
  280. check(
  281. '安全交底:两个签字(安全交底人签字 + 接受交底人签字)',
  282. JSON.stringify(nodeOf('brief').fields.map((f) => f.label)) ===
  283. JSON.stringify(['安全交底人签字', '接受交底人签字']) &&
  284. nodeOf('brief').fields.every((f) => f.type === 'sign')
  285. );
  286. [
  287. ['leader_opinion', '作业负责人意见'],
  288. ['unit_opinion', '所在单位意见']
  289. ].forEach(([key, label]) => {
  290. const node = nodeOf(key);
  291. check(
  292. label + ':意见 + 签名',
  293. node.fields.length === 2 &&
  294. node.fields[0].type === 'textarea' &&
  295. node.fields[1].type === 'sign',
  296. { labels: node.fields.map((f) => f.label) }
  297. );
  298. });
  299. check(
  300. '消防、安全部门意见:两个意见框 + 两个签字(消防部门 / 安全部门)',
  301. JSON.stringify(nodeOf('fire_safety_opinion').fields.map((f) => f.label)) ===
  302. JSON.stringify([
  303. '消防部门意见',
  304. '安全部门意见',
  305. '消防部门签字',
  306. '安全部门签字'
  307. ]) &&
  308. nodeOf('fire_safety_opinion').fields[0].type === 'textarea' &&
  309. nodeOf('fire_safety_opinion').fields[1].type === 'textarea' &&
  310. nodeOf('fire_safety_opinion').fields[2].type === 'sign' &&
  311. nodeOf('fire_safety_opinion').fields[3].type === 'sign'
  312. );
  313. // 签字项标题要能分辨出是谁签的:
  314. // 同一环节里不允许出现两个同名的「手写签名」,且标题沿用源系统的原始写法
  315. check(
  316. '签字项标题与源系统一致(不再统一写「手写签名」)',
  317. JSON.stringify(
  318. br.BREAK_ROAD_FLOW_NODES.flatMap((n) =>
  319. n.fields.filter((f) => f.type === 'sign').map((f) => f.label)
  320. )
  321. ) ===
  322. JSON.stringify([
  323. '编制人签字',
  324. '确认人签字',
  325. '安全交底人签字',
  326. '接受交底人签字',
  327. '作业负责人签字',
  328. '所在单位签字',
  329. '消防部门签字',
  330. '安全部门签字',
  331. '审批部门签字',
  332. '签字'
  333. ])
  334. );
  335. check(
  336. '同一环节内不存在重名的签字项(不会出现两个「手写签名」)',
  337. br.BREAK_ROAD_FLOW_NODES.every((n) => {
  338. const labels = n.fields
  339. .filter((f) => f.type === 'sign')
  340. .map((f) => f.label);
  341. return new Set(labels).size === labels.length;
  342. })
  343. );
  344. check(
  345. '「消防、安全部门意见」的两个签字标题互不相同',
  346. nodeOf('fire_safety_opinion').fields[2].label !==
  347. nodeOf('fire_safety_opinion').fields[3].label
  348. );
  349. check(
  350. '意见类环节的取值 key 互不相同(源系统多处复制同一 model,不能互相覆盖)',
  351. new Set(
  352. br.BREAK_ROAD_FLOW_NODES.filter((n) => n.fields[0].type === 'textarea').map(
  353. (n) => n.fields[0].key
  354. )
  355. ).size === 5
  356. );
  357. check(
  358. '审批部门意见:意见 + 签名',
  359. nodeOf('approver_opinion').fields.length === 2 &&
  360. nodeOf('approver_opinion').fields[0].type === 'textarea' &&
  361. nodeOf('approver_opinion').fields[1].type === 'sign'
  362. );
  363. check(
  364. '作业实施:实施开始时间 + 实施结束时间 + 事中记录表',
  365. JSON.stringify(nodeOf('execute').fields.map((f) => f.label)) ===
  366. JSON.stringify(['作业实施开始时间', '作业实施结束时间', '事中记录表'])
  367. );
  368. check(
  369. '事中记录表 3 列(时间/记录内容/记录人)',
  370. JSON.stringify(colsOf('execute', 'during_record_list')) ===
  371. JSON.stringify(['时间', '记录内容', '记录人'])
  372. );
  373. check(
  374. '完工验收:意见 + 签名',
  375. nodeOf('acceptance').fields.length === 2 &&
  376. nodeOf('acceptance').fields[0].label === '意见' &&
  377. nodeOf('acceptance').fields[1].type === 'sign'
  378. );
  379. check(
  380. '安全措施默认清单 3 条与源系统 formData 逐条一致',
  381. br.DEFAULT_BREAK_ROAD_MEASURES.length === 3 &&
  382. br.DEFAULT_BREAK_ROAD_MEASURES[0] ===
  383. '作业前,指定交通组织方案,并已通知相关部门或单位' &&
  384. br.DEFAULT_BREAK_ROAD_MEASURES[1] ===
  385. '作业前,在断路的路口和相关道路上设置交通警示标志,在作业区域附近设置路栏、道路作业警示灯、寻向标灯交通警示设施' &&
  386. br.DEFAULT_BREAK_ROAD_MEASURES[2] === '夜间作业设置警示灯'
  387. );
  388. check(
  389. '默认行挂在 measure_list 字段上(defaultRows)',
  390. Array.isArray(nodeOf('measure').fields[0].defaultRows) &&
  391. nodeOf('measure').fields[0].defaultRows.length === 3 &&
  392. nodeOf('measure').fields[0].defaultRows[0].measure_content ===
  393. br.DEFAULT_BREAK_ROAD_MEASURES[0]
  394. );
  395. console.log('\n[5] 环节匹配(多级匹配:code / 别名 / 名称 / 双向包含)');
  396. const m = br.matchBreakRoadFlowNode('HO_PERMIT_BREAK_ROAD_THREETH');
  397. check('按任务定义 key 精确命中', m && m.key === 'measure', {
  398. key: m && m.key
  399. });
  400. check(
  401. '按节点名称命中',
  402. (br.matchBreakRoadFlowNode('现场安全措施确认') || {}).key === 'measure'
  403. );
  404. check(
  405. '按别名命中',
  406. (br.matchBreakRoadFlowNode('危险辨识') || {}).key === 'risk'
  407. );
  408. check(
  409. '按平台自定义 key 反向包含命中',
  410. (br.matchBreakRoadFlowNode('break_road_risk') || {}).key === 'risk'
  411. );
  412. check(
  413. '第七级按 code 命中消防、安全部门意见',
  414. (br.matchBreakRoadFlowNode('HO_PERMIT_BREAK_ROAD_SEVENTH') || {}).key ===
  415. 'fire_safety_opinion'
  416. );
  417. check(
  418. '流程定义 key / 一级作业申请 key 不误命中后续环节',
  419. br.matchBreakRoadFlowNode('HO_PERMIT_BREAK_ROAD') === null &&
  420. br.matchBreakRoadFlowNode('HO_PERMIT_BREAK_ROAD_ONETH') === null
  421. );
  422. check('空标识返回 null', br.matchBreakRoadFlowNode('', null) === null);
  423. console.log('\n[6] 运行时辅助(回显 / payload / 流程变量)');
  424. const measureNode = nodeOf('measure');
  425. const source = {
  426. measure_list: [
  427. { measure_content: 'x', measure_confirm: '2', measure_confirmer: '张三' }
  428. ],
  429. measure_preparer_sign: '/tmp/a.png'
  430. };
  431. const loaded = bt.loadNodeValues(measureNode, [source]);
  432. check(
  433. '回显:按自身 key 取到已保存内容',
  434. loaded.measure_list.length === 1 &&
  435. loaded.measure_preparer_sign === '/tmp/a.png'
  436. );
  437. check(
  438. '回显:未保存的字段为 undefined',
  439. loaded.measure_confirmer_sign === undefined
  440. );
  441. const payload = bt.buildNodePayload(measureNode, {
  442. measure_list: [{ measure_content: 'x' }],
  443. measure_preparer_sign: '/tmp/a.png'
  444. });
  445. check(
  446. 'payload:保留原始类型(数组不转字符串)',
  447. Array.isArray(payload.measure_list) && payload.measure_list.length === 1
  448. );
  449. check(
  450. 'payload:空值不写入',
  451. !('measure_confirmer_sign' in payload) && !('measure_confirm' in payload)
  452. );
  453. const vars = bt.buildNodeVariables(measureNode, {
  454. measure_list: [{ measure_content: 'x' }],
  455. measure_preparer_sign: '/tmp/a.png'
  456. });
  457. check(
  458. '流程变量:子表转 JSON 字符串、其余转字符串',
  459. typeof vars.measure_list === 'string' &&
  460. vars.measure_preparer_sign === '/tmp/a.png'
  461. );
  462. check(
  463. '回显:多选框的值不会串到风险辨识说明',
  464. bt.loadNodeValues(nodeOf('risk'), [{ risk_result: '9' }]).risk_result_desc ===
  465. undefined
  466. );
  467. const opinionPayload = bt.buildNodePayload(nodeOf('leader_opinion'), {
  468. leader_opinion: '同意',
  469. leader_sign: '/tmp/leader.png'
  470. });
  471. check(
  472. '作业负责人意见:意见 + 签名写入 payload 与流程变量',
  473. opinionPayload.leader_opinion === '同意' &&
  474. opinionPayload.leader_sign === '/tmp/leader.png' &&
  475. bt.buildNodeVariables(nodeOf('leader_opinion'), {
  476. leader_opinion: '同意'
  477. }).leader_opinion === '同意'
  478. );
  479. check(
  480. '消防、安全部门意见:两处意见互不覆盖',
  481. bt.buildNodePayload(nodeOf('fire_safety_opinion'), {
  482. fire_dept_opinion: '消防意见',
  483. safety_dept_opinion: '安全意见'
  484. }).fire_dept_opinion === '消防意见' &&
  485. bt.buildNodePayload(nodeOf('fire_safety_opinion'), {
  486. fire_dept_opinion: '消防意见',
  487. safety_dept_opinion: '安全意见'
  488. }).safety_dept_opinion === '安全意见'
  489. );
  490. check(
  491. '所在单位意见 / 审批部门意见的 payload 互不覆盖',
  492. bt.buildNodePayload(nodeOf('unit_opinion'), { unit_opinion: '单位意见' })
  493. .unit_opinion === '单位意见' &&
  494. bt.buildNodePayload(nodeOf('approver_opinion'), {
  495. approver_opinion: '审批意见'
  496. }).approver_opinion === '审批意见'
  497. );
  498. console.log('\n[7] 组件模板与脚本一致性(模板调用的方法必须已在组件中定义)');
  499. const compiler = require('vue-template-compiler');
  500. const parser = require('@babel/parser');
  501. /**
  502. * 收集组件脚本里已定义的成员名(props / data / computed / methods)
  503. * @param {string} script script 源码
  504. * @returns {Set<string>} 成员名集合
  505. */
  506. function collectComponentMembers(script) {
  507. const names = new Set();
  508. const ast = parser.parse(script, {
  509. sourceType: 'module',
  510. plugins: [
  511. 'optionalChaining',
  512. 'objectRestSpread',
  513. 'nullishCoalescingOperator'
  514. ]
  515. });
  516. let component = null;
  517. for (const node of ast.program.body) {
  518. if (node.type === 'ExportDefaultDeclaration') {
  519. component = node.declaration;
  520. }
  521. }
  522. const collectObjectKeys = (obj) => {
  523. ((obj && obj.properties) || []).forEach((prop) => {
  524. if (!prop.key) {
  525. return;
  526. }
  527. names.add(prop.key.name || prop.key.value);
  528. });
  529. };
  530. ((component && component.properties) || []).forEach((prop) => {
  531. if (!prop.key) {
  532. return;
  533. }
  534. const key = prop.key.name;
  535. const value = prop.value;
  536. if (key === 'props' || key === 'methods' || key === 'computed') {
  537. collectObjectKeys(value);
  538. return;
  539. }
  540. if (key === 'data') {
  541. // data() { return { ... } }
  542. const body = value && value.body;
  543. if (!body) {
  544. return;
  545. }
  546. const ret = body.body.find(
  547. (stmt) => stmt.type === 'ReturnStatement' && stmt.argument
  548. );
  549. if (ret) {
  550. collectObjectKeys(ret.argument);
  551. }
  552. }
  553. });
  554. return names;
  555. }
  556. /**
  557. * 取模板里被调用的成员名
  558. *
  559. * 注意:Vue 2 编译后的 render 用 with(this) 解析标识符,
  560. * 方法调用编译成裸函数名(如 getRows(field)),不带 vm. 前缀,
  561. * 因此这里直接扫描模板表达式,而不是扫描编译产物。
  562. *
  563. * @param {string} template 模板源码
  564. * @returns {Set<string>} 被调用的成员名集合
  565. */
  566. function collectTemplateCalls(template) {
  567. const calls = new Set();
  568. // 1) 插值 {{ expr }}
  569. // 2) 指令 / 事件绑定属性值: :xxx="expr"、v-xxx="expr"、v-xxx:arg="expr"、@xxx="expr"
  570. const attrRe = /(?:[:@][\w.-]+|v-[\w-]+(?::[\w.-]+)?)\s*=\s*"([^"]*)"/g;
  571. const interpRe = /\{\{([\s\S]*?)\}\}/g;
  572. const exprs = [];
  573. let m;
  574. while ((m = attrRe.exec(template)) !== null) {
  575. exprs.push(m[1]);
  576. }
  577. while ((m = interpRe.exec(template)) !== null) {
  578. exprs.push(m[1]);
  579. }
  580. // 全局函数与 Vue 内部函数,不属于组件成员
  581. const globals = new Set([
  582. 'String',
  583. 'Number',
  584. 'Boolean',
  585. 'Array',
  586. 'Object',
  587. 'JSON',
  588. 'Math',
  589. 'Date',
  590. 'parseInt',
  591. 'parseFloat',
  592. 'isNaN',
  593. 'require'
  594. ]);
  595. // 标识符后紧跟 "(" 即为函数调用;排除属性访问(xxx.method())与内部变量
  596. const callRe = /(^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g;
  597. exprs.forEach((expr) => {
  598. let hit;
  599. callRe.lastIndex = 0;
  600. while ((hit = callRe.exec(expr)) !== null) {
  601. const name = hit[2];
  602. if (name.startsWith('$') || name.startsWith('_') || globals.has(name)) {
  603. continue;
  604. }
  605. calls.add(name);
  606. }
  607. });
  608. return { calls, errors: compiler.compile(template).errors || [] };
  609. }
  610. const sfcPath = path.join(SRC, 'components/breakRoadFlow.vue');
  611. const sfcSource = fs.readFileSync(sfcPath, 'utf8');
  612. const sfc = compiler.parseComponent(sfcSource);
  613. const templateCheck = collectTemplateCalls(sfc.template.content);
  614. const members = collectComponentMembers(sfc.script.content);
  615. check(
  616. 'breakRoadFlow.vue 模板编译无错误',
  617. templateCheck.errors.length === 0,
  618. templateCheck.errors
  619. );
  620. const missing = [...templateCheck.calls].filter((name) => !members.has(name));
  621. check(
  622. `模板调用的 ${templateCheck.calls.size} 个方法均已定义`,
  623. missing.length === 0,
  624. missing
  625. );
  626. check(
  627. '模板确实调用了 getConfirmColumn / confirmAll(子表「全部确认」按钮)',
  628. templateCheck.calls.has('getConfirmColumn') &&
  629. templateCheck.calls.has('confirmAll')
  630. );
  631. check(
  632. '未识别到环节时展示当前任务名 / key(便于排查流程模型配置)',
  633. /taskLabel/.test(sfcSource)
  634. );
  635. check(
  636. '子表日期列渲染为日期时间选择器(el-date-picker)',
  637. /<el-date-picker[^>]*column\.type === 'date'/.test(sfcSource)
  638. );
  639. check(
  640. '作业实施:事中记录表「时间」列类型为 date',
  641. nodeOf('execute')
  642. .fields.find((f) => f.key === 'during_record_list')
  643. .columns.some((c) => c.key === 'record_time' && c.type === 'date')
  644. );
  645. check(
  646. '组件对外提供 validate / getPayload / getVariables',
  647. /validate\(\)\s*\{/.test(sfc.script.content) &&
  648. /getPayload\(\)\s*\{/.test(sfc.script.content) &&
  649. /getVariables\(\)\s*\{/.test(sfc.script.content)
  650. );
  651. check(
  652. '组件引用的节点 schema 与运行时辅助均来自断路作业自己的文件',
  653. /from '\.\.\/breakRoadFlowNodes'/.test(sfc.script.content) &&
  654. /from '\.\.\/breakRoadFlowTask'/.test(sfc.script.content)
  655. );
  656. // 其余作业类型组件只做只读体检,不参与断言(既有实现不做任何改动)
  657. [
  658. 'confinedSpaceFlow.vue',
  659. 'heightFlow.vue',
  660. 'hoistingFlow.vue',
  661. 'electricityFlow.vue',
  662. 'breakGroundFlow.vue'
  663. ].forEach((name) => {
  664. const otherSfc = compiler.parseComponent(
  665. fs.readFileSync(path.join(SRC, 'components', name), 'utf8')
  666. );
  667. const otherCheck = collectTemplateCalls(otherSfc.template.content);
  668. const otherMembers = collectComponentMembers(otherSfc.script.content);
  669. const otherMissing = [...otherCheck.calls].filter(
  670. (n) => !otherMembers.has(n)
  671. );
  672. console.log(
  673. ' INFO ' +
  674. name +
  675. ' 模板调用体检:缺失 ' +
  676. otherMissing.length +
  677. ' 个' +
  678. (otherMissing.length ? ' -> ' + JSON.stringify(otherMissing) : '')
  679. );
  680. });
  681. console.log('\n' + (failed ? 'FAILED: ' + failed + ' 项' : 'ALL PASSED'));
  682. process.exit(failed ? 1 : 0);