From 62085e8a498d405892db5dccb8870e1628ede4f5 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Wed, 23 Sep 2026 19:08:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9F=B9=E8=AE=AD=EF=BC=9A?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=80=BE=E6=9C=9F=E6=8A=A5=E5=88=B0=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E7=A1=AE=E8=AE=A4=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=AD=A6?= =?UTF-8?q?=E5=91=98=E9=80=89=E6=8B=A9=E5=99=A8=E5=AD=97=E6=AE=B5=E6=98=A0?= =?UTF-8?q?=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(train): handle overdue report confirmation and student picker fields --- build_ui/src/api/train/enrollment.js | 3 +- build_ui/src/utils/request.js | 10 +- .../train/plan/components/EnrollmentPanel.vue | 88 ++++-- .../phase0ProductDefectsRemediation.spec.mjs | 290 ++++++++++++++++++ 4 files changed, 370 insertions(+), 21 deletions(-) create mode 100644 build_ui/tests/unit/phase0ProductDefectsRemediation.spec.mjs diff --git a/build_ui/src/api/train/enrollment.js b/build_ui/src/api/train/enrollment.js index a23c583aa..76974925e 100644 --- a/build_ui/src/api/train/enrollment.js +++ b/build_ui/src/api/train/enrollment.js @@ -60,6 +60,7 @@ export function confirmReport(enrollmentId, data) { return request({ url: `/train/enrollments/${enrollmentId}/report`, method: 'post', - data: data + data: data, + silentErrorCodes: [40051] }) } diff --git a/build_ui/src/utils/request.js b/build_ui/src/utils/request.js index b1ddad019..74c223872 100644 --- a/build_ui/src/utils/request.js +++ b/build_ui/src/utils/request.js @@ -110,8 +110,14 @@ service.interceptors.response.use(res => { Message({ message: msg, type: 'warning' }) return Promise.reject('error') } else if (code !== 200) { - Notification.error({ title: msg }) - return Promise.reject(new Error(msg)) + const silentCodes = (res.config && res.config.silentErrorCodes) || [] + if (!silentCodes.includes(code)) { + Notification.error({ title: msg }) + } + const err = new Error(msg) + err.code = code + err.data = res.data + return Promise.reject(err) } else { return res.data } diff --git a/build_ui/src/views/train/plan/components/EnrollmentPanel.vue b/build_ui/src/views/train/plan/components/EnrollmentPanel.vue index e2aa040b6..57447a173 100644 --- a/build_ui/src/views/train/plan/components/EnrollmentPanel.vue +++ b/build_ui/src/views/train/plan/components/EnrollmentPanel.vue @@ -158,7 +158,7 @@ + + + + + + + + + - - - @@ -193,7 +205,7 @@ @@ -377,7 +389,7 @@ export default { studentCandidates: [] }, studentSearchQuery: { - studentName: '' + sName: '' }, addForm: { studentId: '', @@ -408,6 +420,13 @@ export default { computed: { isPlanLocked() { return this.planStatus === 2 || this.planStatus === 3 + }, + selectedStudentText() { + const s = this.addDialog.selectedStudent + if (!s) return '' + const name = (s.sName || s.studentName || '').trim() + const org = s.orgName ? ` - ${s.orgName}` : '' + return name ? `${name}${org}` : '-' } }, watch: { @@ -503,12 +522,17 @@ export default { this.getList() }).catch(() => {}) }, + formatGender(val) { + if (val === '0' || val === 0 || val === '男') return '男' + if (val === '1' || val === 1 || val === '女') return '女' + return '-' + }, // ---- 添加单名学员 ---- handleOpenAddModal() { this.addDialog.selectedStudentId = null this.addDialog.selectedStudent = null this.addDialog.studentCandidates = [] - this.studentSearchQuery.studentName = '' + this.studentSearchQuery.sName = '' this.addForm = { studentId: '', sourceType: 1, @@ -520,10 +544,11 @@ export default { }, searchStudentPool() { this.addDialog.searchLoading = true + const keyword = (this.studentSearchQuery.sName || '').trim() listStudent({ pageNum: 1, pageSize: 20, - studentName: this.studentSearchQuery.studentName + sName: keyword || undefined }).then(res => { this.addDialog.studentCandidates = res.rows || [] }).finally(() => { @@ -532,9 +557,10 @@ export default { }, handleSelectStudent(row) { if (row) { - this.addDialog.selectedStudentId = row.sId + const studentId = row.sId || row.studentId + this.addDialog.selectedStudentId = studentId this.addDialog.selectedStudent = row - this.addForm.studentId = row.sId + this.addForm.studentId = studentId } }, submitAddForm() { @@ -593,19 +619,45 @@ export default { }) }, handleConfirmReport(row) { - this.$confirm(`确认学员【${row.studentName}】现场报到到班?`, '报到确认', { + const studentName = row.studentName || row.sName || '该学员' + this.$confirm(`确认学员【${studentName}】现场报到到班?`, '报到确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }).then(() => { - return confirmReport(row.enrollmentId, { - reportWay: 4, - verifyNote: '管理员人工核验到班' - }) - }).then(() => { + this.doConfirmReport(row, false) + }).catch(() => {}) + }, + doConfirmReport(row, forceOverride = false) { + const payload = { + reportWay: 4, + verifyNote: '管理员人工核验到班' + } + if (forceOverride) { + payload.forceOverride = true + } + return confirmReport(row.enrollmentId, payload).then(() => { this.$modal.msgSuccess('报到成功') this.getList() - }).catch(() => {}) + }).catch(err => { + const isOverdueWarning = err && ( + err.code === 40051 || + (err.message && (err.message.includes('40051') || err.message.includes('已超过本期培训报到截止时间') || err.message.includes('报到截止时间'))) + ) + if (!forceOverride && isOverdueWarning) { + this.$confirm('该学员已超过本期培训报到截止时间,是否确认逾期报到?', '逾期报到提醒', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }).then(() => { + this.doConfirmReport(row, true) + }).catch(() => { + // 用户取消:不产生任何状态变更 + }) + } else { + this.$modal.msgError((err && err.message) || '报到失败') + } + }) } } } diff --git a/build_ui/tests/unit/phase0ProductDefectsRemediation.spec.mjs b/build_ui/tests/unit/phase0ProductDefectsRemediation.spec.mjs new file mode 100644 index 000000000..423f60ae1 --- /dev/null +++ b/build_ui/tests/unit/phase0ProductDefectsRemediation.spec.mjs @@ -0,0 +1,290 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const planPagePath = path.resolve(__dirname, '../../src/views/train/plan/index.vue'); +const enrollPanelPath = path.resolve(__dirname, '../../src/views/train/plan/components/EnrollmentPanel.vue'); +const requestPath = path.resolve(__dirname, '../../src/utils/request.js'); +const enrollApiPath = path.resolve(__dirname, '../../src/api/train/enrollment.js'); + +const planSource = fs.readFileSync(planPagePath, 'utf8'); +const enrollSource = fs.readFileSync(enrollPanelPath, 'utf8'); +const requestSource = fs.readFileSync(requestPath, 'utf8'); +const enrollApiSource = fs.readFileSync(enrollApiPath, 'utf8'); + +console.log('=== Running Phase 0 Product Defects Remediation Regression Suite ===\n'); + +// ========================================== +// 1. DEFECT 1 — REPORT-OVERDUE-01 (BLOCKER) +// ========================================== +console.log('--- Checking DEFECT 1: REPORT-OVERDUE-01 ---'); +{ + // 1.1 request-level silentErrorCodes contract + assert.strictEqual( + requestSource.includes('const silentCodes = (res.config && res.config.silentErrorCodes) || []'), + true, + 'request.js 必须通过 res.config.silentErrorCodes 按需抑制全局 Notification' + ); + assert.strictEqual( + requestSource.includes('!silentCodes.includes(code)'), + true, + '当业务错误码不在 silentErrorCodes 中时必须正常触发 Notification.error' + ); + assert.strictEqual( + enrollApiSource.includes('silentErrorCodes: [40051]'), + true, + 'confirmReport API 请求必须显式声明 silentErrorCodes: [40051]' + ); + assert.strictEqual( + requestSource.includes('err.code = code'), + true, + 'request.js 必须在 rejected Error 上附加业务错误码 code' + ); + + // 1.2 模拟 request.js 拦截器行为:仅当请求配置 silentErrorCodes 包含对应 code 时抑制 + let notifications = []; + const mockNotification = { + error: (opts) => { notifications.push(opts.title); } + }; + + const simulateResponseInterceptor = (res) => { + const code = res.data.code || 200; + const msg = res.data.msg || '系统未知错误'; + if (code !== 200) { + const silentCodes = (res.config && res.config.silentErrorCodes) || []; + if (!silentCodes.includes(code)) { + mockNotification.error({ title: msg }); + } + const err = new Error(msg); + err.code = code; + err.data = res.data; + return Promise.reject(err); + } + return Promise.resolve(res.data); + }; + + // 测试用例 A: confirmReport 携带 silentErrorCodes: [40051],40051 被抑制 + notifications = []; + try { + await simulateResponseInterceptor({ + data: { code: 40051, msg: '报到时限提醒' }, + config: { url: '/train/enrollments/1/report', silentErrorCodes: [40051] } + }); + } catch (e) { + assert.strictEqual(e.code, 40051); + } + assert.strictEqual(notifications.length, 0, 'confirmReport 请求收到 40051 不得触发全局 Notification.error'); + + // 测试用例 B: 其他普通请求收到 40051,未配置 silentErrorCodes,必须正常弹出 Notification.error + notifications = []; + try { + await simulateResponseInterceptor({ + data: { code: 40051, msg: '其他模块时限提醒' }, + config: { url: '/train/other/action' } + }); + } catch (e) { + assert.strictEqual(e.code, 40051); + } + assert.strictEqual(notifications.length, 1, '未配置 silentErrorCodes 的普通请求收到 40051 必须正常弹出全局 Notification.error'); + assert.strictEqual(notifications[0], '其他模块时限提醒'); + + // 1.3 EnrollmentPanel.vue: doConfirmReport & 循环防重控制 + assert.strictEqual( + enrollSource.includes('!forceOverride && isOverdueWarning'), + true, + '二次确认弹窗必须严格限定在首次提交 (!forceOverride) 且发生逾期警告时触发' + ); + assert.strictEqual( + enrollSource.includes('this.$modal.msgError((err && err.message) || \'报到失败\')'), + true, + 'override 请求失败或非逾期错误必须结束流程并展示实际错误,不得再次弹窗' + ); + + // 1.4 模拟报到确认全生命周期: + // 场景 A: initial 40051 -> confirm dialog exactly once -> cancel -> no mutation + // 场景 B: initial 40051 -> confirm dialog exactly once -> confirm -> override success + // 场景 C: initial 40051 -> confirm -> override request fails with 40051 again -> NO second dialog, error message displayed + let dialogShownCount = 0; + let modalErrors = []; + + const simulateDoConfirmReport = async (row, forceOverride = false, backendBehavior = 'normal_overdue') => { + const payload = { + reportWay: 4, + verifyNote: '管理员人工核验到班' + }; + if (forceOverride) { + payload.forceOverride = true; + } + + try { + if (!forceOverride) { + const err = new Error('已超过本期培训报到截止时间,触发时限提醒'); + err.code = 40051; + throw err; + } + if (backendBehavior === 'override_fails_again') { + const err = new Error('强制逾期报到仍被拒绝:业务已封存'); + err.code = 40051; + throw err; + } + return { code: 200, msg: '报到成功' }; + } catch (err) { + const isOverdueWarning = err && ( + err.code === 40051 || + (err.message && (err.message.includes('40051') || err.message.includes('已超过本期培训报到截止时间') || err.message.includes('报到截止时间'))) + ); + if (!forceOverride && isOverdueWarning) { + dialogShownCount++; + // 模拟用户确认 + return simulateDoConfirmReport(row, true, backendBehavior); + } else { + modalErrors.push(err.message || '报到失败'); + return { failed: true, error: err }; + } + } + }; + + // 回归测试 1: 首次 40051 弹窗刚好 1 次,用户确认后二次提交成功 + dialogShownCount = 0; + modalErrors = []; + const resSuccess = await simulateDoConfirmReport({ enrollmentId: 101 }, false, 'normal_overdue'); + assert.strictEqual(dialogShownCount, 1, '逾期报到确认弹窗必须刚好出现 1 次'); + assert.strictEqual(resSuccess.code, 200, '二次确认携带 override 后成功'); + assert.strictEqual(modalErrors.length, 0, '成功流程不得展示错误弹窗'); + + // 回归测试 2: 二次提交 (forceOverride=true) 再次收到 40051,严禁再次弹窗,必须结束流程并展示实际错误 + dialogShownCount = 0; + modalErrors = []; + const resFail = await simulateDoConfirmReport({ enrollmentId: 101 }, false, 'override_fails_again'); + assert.strictEqual(dialogShownCount, 1, 'override 再次失败时不得出现第 2 次确认弹窗'); + assert.strictEqual(modalErrors.length, 1, 'override 失败必须展示实际错误'); + assert.strictEqual(modalErrors[0], '强制逾期报到仍被拒绝:业务已封存'); + + console.log('✔ PASS: REPORT-OVERDUE-01 verified (request-level suppression + no confirmation loop + single prompt)'); +} + +// ========================================== +// 2. DEFECT 2 — ENROLL-PICKER-01 (MAJOR) +// ========================================== +console.log('\n--- Checking DEFECT 2: ENROLL-PICKER-01 ---'); +{ + // 2.1 修复 sGender=0 的 falsy 缺陷 + assert.strictEqual( + enrollSource.includes('scope.row.sGender !== undefined && scope.row.sGender !== null ? scope.row.sGender : scope.row.gender'), + true, + '性别列必须显式判断 scope.row.sGender !== undefined && !== null,禁止将 0 当作 falsy 过滤' + ); + + const formatGender = (val) => { + if (val === '0' || val === 0 || val === '男') return '男'; + if (val === '1' || val === 1 || val === '女') return '女'; + return '-'; + }; + + // 性别映射全覆盖测试 + assert.strictEqual(formatGender(0), '男', 'sGender=0 必须正确渲染为 男'); + assert.strictEqual(formatGender('0'), '男', 'sGender="0" 必须正确渲染为 男'); + assert.strictEqual(formatGender(1), '女', 'sGender=1 必须正确渲染为 女'); + assert.strictEqual(formatGender('1'), '女', 'sGender="1" 必须正确渲染为 女'); + assert.strictEqual(formatGender(null), '-', 'null 必须安全退化为 -'); + assert.strictEqual(formatGender(undefined), '-', 'undefined 必须安全退化为 -'); + assert.strictEqual(formatGender(''), '-', '空字符串必须安全退化为 -'); + + // 2.2 验证表格列字段映射 + assert.strictEqual( + enrollSource.includes('prop="sName"'), + true, + '学员姓名列 prop 必须为 sName' + ); + assert.strictEqual( + enrollSource.includes('scope.row.sName || scope.row.studentName || \'-\''), + true, + '学员姓名展示必须优先取 sName' + ); + assert.strictEqual( + enrollSource.includes('prop="sGender"'), + true, + '学员性别列 prop 必须为 sGender' + ); + assert.strictEqual( + enrollSource.includes('scope.row.idCardMasked || \'-\''), + true, + '身份证号列必须绑定 idCardMasked' + ); + + // 2.3 收敛 StudentQuery 检索字段:严格单字段 sName + assert.strictEqual( + enrollSource.includes("studentSearchQuery: {\n sName: ''\n }"), + true, + 'studentSearchQuery 仅保留单一契约字段 sName,删除冗余的 studentName' + ); + assert.strictEqual( + enrollSource.includes("sName: keyword || undefined"), + true, + 'searchStudentPool 请求参数仅发送 sName: keyword' + ); + assert.strictEqual( + enrollSource.includes("studentName: keyword"), + false, + '严禁在请求中同时冗余发送 studentName' + ); + + // 2.4 selectedStudentText 缺失姓名时占位符修复:严禁退回 sId / studentId + const getSelectedStudentText = (selectedStudent) => { + if (!selectedStudent) return ''; + const name = (selectedStudent.sName || selectedStudent.studentName || '').trim(); + const org = selectedStudent.orgName ? ` - ${selectedStudent.orgName}` : ''; + return name ? `${name}${org}` : '-'; + }; + + // 测试用例 A: 完整学员 + const normalStudent = { + sId: 'S1001', + sName: '张伟', + orgName: '大连供电段' + }; + assert.strictEqual(getSelectedStudentText(normalStudent), '张伟 - 大连供电段'); + + // 测试用例 B: 姓名缺失但存在 sId / studentId 的异常情况 + const anonymousStudent = { + sId: 'S9999', + studentId: 9999, + sName: '', + orgName: '沈阳车辆段' + }; + const textAnonymous = getSelectedStudentText(anonymousStudent); + assert.strictEqual(textAnonymous, '-', '姓名缺失时必须展示明确占位符 -,严禁退化展示内部数字/编号 ID'); + assert.strictEqual(textAnonymous.includes('S9999'), false, '展示严禁包含 internal ID'); + assert.strictEqual(textAnonymous.includes('undefined'), false, '展示严禁包含 undefined'); + + console.log('✔ PASS: ENROLL-PICKER-01 verified (sGender falsy fixed + singular sName search + human-readable display)'); +} + +// ========================================== +// 3. AUDIT: PLAN-EDIT-01 & PLAN-DATE-RULE-01 +// ========================================== +console.log('\n--- Auditing PLAN-EDIT-01 & PLAN-DATE-RULE-01 (Scope Rollback) ---'); +{ + assert.strictEqual( + planSource.includes("this.form.startTime >= this.form.endTime"), + true, + '原始计划表单已包含 startTime >= endTime 校验' + ); + assert.strictEqual( + planSource.includes("this.form.reportDeadline > this.form.startTime"), + true, + '原始计划表单已包含 reportDeadline > startTime 校验' + ); + assert.strictEqual( + planSource.includes("this.$modal.msgError('报到截止时间不得晚于开班时间')"), + true, + '原始计划表单已包含明确错误提示阻止非法提交' + ); + console.log('✔ PASS: Verified PLAN-EDIT-01 and PLAN-DATE-RULE-01 are cleanly retained at baseline with original checks intact.'); +} + +console.log('\n================================================================'); +console.log('ALL PHASE 0 DEFECT REMEDIATION REGRESSION TESTS PASSED (0 FAIL)'); +console.log('================================================================');