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 @@
{{ fmtDate(row.createdAt) }}
+
+
+
+ 编辑
+
+
@@ -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; }
/* ==================== 主体 ==================== */