feat(admin): 账号表新增「编辑」入口与选手信息编辑弹窗(R108)

· 账号表新增操作列(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
This commit is contained in:
2026-10-12 17:20:47 +08:00
parent 1b569a38e6
commit 4e7dfe09c8
2 changed files with 265 additions and 5 deletions

View File

@@ -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 })
}

View File

@@ -205,6 +205,13 @@
<el-table-column prop="createdAt" label="注册时间" min-width="170" align="center">
<template #default="{ row }">{{ fmtDate(row.createdAt) }}</template>
</el-table-column>
<!-- R108 新增:编辑入口。此前 contestants 只有「Excel 导入」一个写入口,
身份证号/姓名/手机号录错即无退路(改不了也删不掉)⇒ 只能改库。 -->
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openEditContestant(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="accountsTotal > 0">
<el-pagination
@@ -1656,6 +1663,106 @@
<el-button type="primary" :loading="carouselSaving" v-guard="() => saveCarousel()">保存</el-button>
</template>
</el-dialog>
<!-- R107(2026-10-12)导入结果明细:
旧实现仅在「0 条成功」时提示前 3 条原因,其余一律只进控制台 ⇒
成功但有跳过的场景下,管理员根本看不到是哪几行、是谁
(生产事故:3 名选手身份证号位数异常被静默跳过,只能人工逐行比对才发现)。 -->
<el-dialog v-model="importResultVisible" title="导入结果" width="780px" top="8vh">
<template v-if="importResult">
<div class="import-result-summary">
<span class="ir-ok">成功 {{ importResult.imported || 0 }} 条</span>
<span v-if="importResult.skipped" class="ir-skip">跳过 {{ importResult.skipped }} 条</span>
<span v-if="importResult.ignored" class="ir-ignore">
忽略非参赛选手 {{ importResult.ignored }} 条
</span>
<span v-if="importResult.warnings && importResult.warnings.length" class="ir-warn">
需人工复核 {{ importResult.warnings.length }} 条
</span>
</div>
<template v-if="importResult.errors && importResult.errors.length">
<div class="import-result-subtitle ir-error-title">
以下 {{ importResult.errors.length }} 行<strong>未导入</strong>,请修正后重新导入:
</div>
<el-table
:data="importResult.errors"
size="small"
max-height="240"
border
class="import-result-table"
>
<el-table-column prop="row" label="Excel 行" width="88" align="center" />
<el-table-column prop="name" label="姓名" width="110" />
<el-table-column prop="message" label="原因" min-width="400" show-overflow-tooltip />
</el-table>
</template>
<template v-if="importResult.warnings && importResult.warnings.length">
<div class="import-result-subtitle ir-warn-title">
以下 {{ importResult.warnings.length }} 行<strong>已导入</strong>,但需人工核对:
</div>
<el-table
:data="importResult.warnings"
size="small"
max-height="200"
border
class="import-result-table"
>
<el-table-column prop="row" label="Excel 行" width="88" align="center" />
<el-table-column prop="name" label="姓名" width="110" />
<el-table-column prop="message" label="提示" min-width="400" show-overflow-tooltip />
</el-table>
</template>
<div v-if="isImportResultClean" class="import-result-clean">
全部行均已成功导入,无异常。
</div>
</template>
<template #footer>
<el-button type="primary" @click="importResultVisible = false">知道了</el-button>
</template>
</el-dialog>
<!-- R108 新增:编辑选手信息(姓名 / 手机号 / 身份证号)
可改字段的完整口径与理由见 backend/app/service/admin.js#updateContestant:
组别只读(改组别会使「已作答的答卷」与「新组别的抽题 + 成绩发布开关」错配);
成绩字段禁止(唯一写入口是成绩发布,本处若可写即为越权改分通道)。 -->
<el-dialog v-model="editVisible" title="编辑选手信息" width="580px" :close-on-click-modal="false">
<template v-if="editForm">
<el-form label-width="92px">
<el-form-item label="当前组别">
<el-tag size="small">{{ groupMap[editForm.group] || editForm.group }}</el-tag>
<span class="edit-readonly-hint">组别不可修改</span>
</el-form-item>
<el-form-item label="姓名" required>
<el-input v-model="editForm.name" maxlength="50" show-word-limit placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="editForm.phone" maxlength="11" placeholder="11 位手机号,可留空" />
</el-form-item>
<el-form-item label="身份证号" required>
<el-input v-model="editForm.id_number" maxlength="18" placeholder="18 位身份证号(同时是登录账号)" />
</el-form-item>
</el-form>
<el-alert type="warning" :closable="false" show-icon class="edit-alert">
<div class="edit-alert-title">身份证号同时是<strong>登录账号</strong>,修改后的连带影响:</div>
<div>· 选手<strong>未激活</strong>且口令仍为「证号后 6 位」派生值 → 口令<strong>自动同步重算</strong>;</div>
<div>· 选手<strong>已激活</strong>(口令由本人设置)→ 口令<strong>保持不变</strong>,需让选手走「忘记密码」;</div>
<div>· 若新证号与库内他人重复 → 保存会被拒绝(身份证号全局唯一)。</div>
<div class="edit-alert-title">本弹窗<strong>不可</strong>修改:组别、成绩、测试数据标记、激活状态、小组归属。</div>
</el-alert>
<div v-if="editWarnings.length" class="edit-warnings">
<div v-for="(w, i) in editWarnings" :key="i" class="edit-warning-item">⚠️ {{ w }}</div>
</div>
</template>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="submitEditContestant">保存</el-button>
</template>
</el-dialog>
</template>
</div>
</div>
@@ -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; }
/* ==================== 主体 ==================== */