check-break-ground-work-flow-nodes.js 22 KB

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