From 4e7dfe09c8dd2760bab66d66fa3e787c06350491 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Mon, 12 Oct 2026 17:20:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E8=B4=A6=E5=8F=B7=E8=A1=A8?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E7=BC=96=E8=BE=91=E3=80=8D=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=E9=80=89=E6=89=8B=E4=BF=A1=E6=81=AF=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=BC=B9=E7=AA=97=EF=BC=88R108=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · 账号表新增操作列(fixed=right)+ 编辑按钮 · 编辑弹窗:可改 name/phone/id_number;group 只读并说明原因(改组别会令 已作答答卷与新组别抽题/发布开关错配);口径说明 alert + warnings 区 · api/admin.js 新增 updateContestant(silent: true,调用方负责提示) · 修正一处死代码:初版按 e.response.status 判 409/404 —— 实测证伪: helper.error() 不改 HTTP 状态,业务错 = HTTP 200 + body.code != 0, 拦截器以 new Error(res.message) reject(该 Error 无 response 属性) => 改用 e.message --- src/api/admin.js | 28 +++++ src/views/Admin.vue | 242 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 265 insertions(+), 5 deletions(-) diff --git a/src/api/admin.js b/src/api/admin.js index c8b9392..c3128e7 100644 --- a/src/api/admin.js +++ b/src/api/admin.js @@ -512,4 +512,32 @@ export function getContestantScore(group, contestantId) { return request.get(`/admin/review/score/${group}/${contestantId}`) } +// ==================== 选手信息编辑(R108 新增) ==================== + +/** + * 编辑已入库选手的身份字段(姓名 / 手机号 / 身份证号) + * + * 背景:此前选手信息只有「Excel 导入」一个写入口,录错了既改不了也删不掉。 + * + * silent: true —— 抑制响应拦截器的通用错误提示,由调用方按业务码分支提示。 + * 本接口的失败形态是**业务性**的(400 入参非法 / 404 选手不存在 / 409 证号被占用), + * 后端 message 已含可读原因(如「身份证号 X 已被占用:张三 / id=123 / 组别=zhongzhi」), + * 拦截器再弹一次「请求失败」即为同族双弹(见 frontend-error-notify.md 口径)。 + * + * @param {number|string} id - contestants.id + * @param {{name?: string, phone?: string, id_number?: string}} payload + * 仅这三项可改;传其他字段后端会显式 400 拒绝(调用方切勿透传整行对象, + * 否则 group / score 等只读字段会把请求打成 400)。 + * @returns {Promise<{code:number,message:string,data:{ + * ok:boolean, changed:string[], warnings:string[], + * contestant:{id:number,name:string,phone:string,id_number:string,group:string, + * team_id:number|null,activated:boolean,test_flag:number} + * }}>} + * `changed` 为空数组 = 提交值与库内一致,未落库(后端 message 为「未检测到需要修改的内容」)。 + * `warnings` 非空须展示(典型:改号后口令不重置,需走「忘记密码」)。 + */ +export function updateContestant(id, payload) { + return request.put(`/admin/contestants/${id}`, payload, { silent: true }) +} + diff --git a/src/views/Admin.vue b/src/views/Admin.vue index bcdd447..34eb1b4 100644 --- a/src/views/Admin.vue +++ b/src/views/Admin.vue @@ -205,6 +205,13 @@ + + + +
保存 + + + + + + + + + + + +
@@ -1798,6 +1905,14 @@ const groupStats = ref([]) const statsOnlyProduction = ref(false) // Excel 导入时是否标记为测试数据(默认勾选,便于一键重置、隔离生产作用域) const importAsTest = ref(true) +// R107:导入结果明细(成功/跳过/忽略/需复核 四类),供「导入结果」对话框展示 +const importResultVisible = ref(false) +const importResult = ref(null) +const isImportResultClean = computed(() => { + const r = importResult.value + if (!r) return false + return !(r.errors && r.errors.length) && !(r.warnings && r.warnings.length) +}) // 默认口令提示(仅用于前端文案展示,真实值由后端 TEST_DEFAULT_PASSWORD 控制) const testDefaultPasswordHint = ref(import.meta.env.VITE_TEST_DEFAULT_PASSWORD || '123456') @@ -1844,6 +1959,88 @@ async function handleToggleTestFlag(row, val) { } } +// ==================== 选手信息编辑(R108 新增) ==================== +// 背景:此前 contestants 只有「Excel 导入」一个写入口,身份证号/姓名/手机号录错即无退路 +// (改不了也删不掉)。本组函数提供事后纠正通道。 +// 口径来源:backend/app/service/admin.js#updateContestant(可改字段白名单 / 口令同步规则)。 +const editVisible = ref(false) +const editSaving = ref(false) +const editWarnings = ref([]) +const editRow = ref(null) +// ⚠️ 只放「可改字段 + 只读展示字段」。切勿整行透传 —— 后端对白名单外字段一律 400 +// (group / score / test_flag 等为只读),整行透传会把请求直接打成失败。 +const editForm = ref(null) + +function openEditContestant(row) { + editRow.value = row + editForm.value = { + name: row.name || '', + phone: row.phone || '', + id_number: row.idNumber || row.id_number || '', + group: row.group || '', + } + editWarnings.value = [] + editVisible.value = true +} + +async function submitEditContestant() { + const form = editForm.value + const row = editRow.value + if (!form || !row) return + + // 客户端只做「后端必然也会拒」的空值预检,避免无谓往返。 + // 长度 / 校验位 / 唯一性等判据的**唯一真相源在后端**(lib/idCard.js + UNIQUE 约束),此处不复制口径。 + const name = String(form.name || '').trim() + const phone = String(form.phone || '').trim() + const idNumber = String(form.id_number || '').trim() + if (!name) { ElMessage.warning('姓名不能为空'); return } + if (!idNumber) { ElMessage.warning('身份证号不能为空'); return } + if (phone && !/^1\d{10}$/.test(phone)) { ElMessage.warning('手机号应为 1 开头的 11 位数字(可留空)'); return } + + // 只提交「与列表行不同」的字段:减少无谓写库,也让后端返回的 changed 语义更干净。 + const payload = {} + if (name !== String(row.name || '')) payload.name = name + if (phone !== String(row.phone || '')) payload.phone = phone + if (idNumber !== String(row.idNumber || row.id_number || '')) payload.id_number = idNumber + if (Object.keys(payload).length === 0) { + ElMessage.info('未做任何修改') + editVisible.value = false + return + } + + editSaving.value = true + try { + const data = await adminApi.updateContestant(row.uid, payload) + const warnings = (data && data.warnings) || [] + const changed = (data && data.changed) || [] + editWarnings.value = warnings + if (warnings.length) { + // 有需人工知悉的连带影响(典型:已激活者改号后口令不重置)⇒ 留在弹窗内展示, + // 不关窗、不吞掉 —— 这类提示一旦被 toast 带过就等于没提示。 + editVisible.value = true + ElMessage.warning('已保存,但有需注意的提示,请查看弹窗内说明') + } else if (changed.length === 0) { + ElMessage.info('未检测到需要修改的内容') + editVisible.value = false + } else { + ElMessage.success('选手信息已更新') + editVisible.value = false + } + fetchAccounts() + } catch (e) { + // ⚠️ 此处**不能**读 `e.response.status`:后端 `ctx.helper.error(code, msg)` **不改 HTTP 状态** + // (extend/helper.js:164-169)⇒ 业务错 = HTTP 200 + body.code≠0 ⇒ 响应拦截器 + // (api/request.js:178-183)以 `Promise.reject(new Error(res.message))` 抛出, + // 该 Error **没有 response 属性**。后端的 message 本身已自带上下文 + // (如「身份证号 X 已被占用:李雨桐(测试数据) / id=2 / 组别=zhongzhi」), + // 故直接透传即可,不再自行拼接状态码前缀。 + // silent: true 已抑制拦截器提示 ⇒ 这里只弹一次,无同族双弹。 + ElMessage.error((e && e.message) || '编辑失败') + } finally { + editSaving.value = false + } +} + async function handleResetTestData() { resetLoading.value = true try { @@ -1972,21 +2169,29 @@ async function onExcelImport(file) { // 导致「标记为测试数据」勾选失效、测试数据被当成生产数据导入(test_flag=0)。 formData.append('testFlag', importAsTest.value ? '1' : '0') formData.append('file', file.raw) - // 响应拦截器已解包,这里拿到的是后端返回的导入结果 {imported, skipped, errors} + // 响应拦截器已解包,这里拿到的是后端返回的导入结果 + // { imported, skipped, ignored, ignoredBy, errors:[{row,name,message}], warnings:[{row,name,message}] } const result = await adminApi.importExcel(formData) const imported = (result && result.imported) || 0 const skipped = (result && result.skipped) || 0 // 非参赛选手行(报名人/指导教师/领队)按业务规则主动忽略,与「跳过」分开计数并展示 const ignored = (result && result.ignored) || 0 + const errors = (result && result.errors) || [] + const warnings = (result && result.warnings) || [] if (ignored && result.ignoredBy) { console.info('按身份忽略明细:', result.ignoredBy) } + // R107:明细不再只打控制台 —— 统一填进「导入结果」对话框,便于定位到具体行与人 + importResult.value = { imported, skipped, ignored, errors, warnings } if (imported === 0 && skipped > 0) { - const detail = ((result && result.errors) || []).slice(0, 3) - .map(e => `第${e.row}行:${e.message}`).join(';') - ElMessage.warning(`导入完成但 0 条成功、跳过 ${skipped} 条。原因:${detail || '见控制台'}`) + ElMessage.warning(`导入完成但 0 条成功、跳过 ${skipped} 条,详见「导入结果」`) } else { - ElMessage.success(`Excel 导入成功:成功 ${imported} 条${importAsTest.value ? '(已标记为测试数据)' : ''}${skipped ? `,跳过 ${skipped} 条` : ''}${ignored ? `,忽略非参赛选手 ${ignored} 条` : ''}`) + const warnTip = warnings.length ? `,${warnings.length} 行需人工复核` : '' + ElMessage.success(`Excel 导入成功:成功 ${imported} 条${importAsTest.value ? '(已标记为测试数据)' : ''}${skipped ? `,跳过 ${skipped} 条` : ''}${ignored ? `,忽略非参赛选手 ${ignored} 条` : ''}${warnTip}`) + } + // 有跳过或有需复核项时自动弹出明细;全部正常则只留 toast,不打扰 + if (skipped > 0 || errors.length || warnings.length) { + importResultVisible.value = true } accountsPage.value = 1 fetchAccounts() @@ -3790,6 +3995,33 @@ onMounted(() => { .top-left { display: flex; align-items: center; gap: 10px; } .admin-logo { font-size: 22px; color: #c9a84c; } .admin-title { font-size: 16px; font-weight: 600; } + +/* R107(2026-10-12)导入结果明细对话框 */ +.import-result-summary { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 6px; font-size: 13px; } +.import-result-summary span { padding: 2px 10px; border-radius: 12px; line-height: 20px; } +.import-result-summary .ir-ok { background: #f0f9eb; color: #67c23a; } +.import-result-summary .ir-skip { background: #fdf6ec; color: #e6a23c; } +.import-result-summary .ir-ignore { background: #f4f4f5; color: #909399; } +.import-result-summary .ir-warn { background: #fef0f0; color: #f56c6c; } +.import-result-subtitle { margin: 14px 0 8px; font-size: 13px; color: #303133; } +.import-result-subtitle.ir-error-title { color: #f56c6c; } +.import-result-subtitle.ir-warn-title { color: #e6a23c; } +.import-result-table { margin-bottom: 4px; } +.import-result-clean { padding: 18px 0; text-align: center; color: #67c23a; font-size: 14px; } + +/* R108 选手信息编辑弹窗 */ +.edit-readonly-hint { margin-left: 10px; color: #909399; font-size: 12px; } +.edit-alert { margin-top: 4px; } +.edit-alert .edit-alert-title { margin-bottom: 2px; } +.edit-alert .edit-alert-title:not(:first-child) { margin-top: 8px; } +.edit-warnings { + margin-top: 10px; + padding: 10px 12px; + border: 1px solid #f3d19e; + border-radius: 4px; + background: #fdf6ec; +} +.edit-warning-item { color: #b88230; font-size: 12px; line-height: 20px; } .top-right { display: flex; gap: 8px; } /* ==================== 主体 ==================== */