From c8097fab006afaf61462d27b1f0fbf70241c3d15 Mon Sep 17 00:00:00 2001
From: qubiaobiao <3294694717@qq.com>
Date: Tue, 20 Oct 2026 11:07:31 +0800
Subject: [PATCH] =?UTF-8?q?feat(admin):=20R176-R180=20=E5=85=AC=E7=A4=BA?=
=?UTF-8?q?=E8=A1=A8=E8=AE=BE=E7=BD=AE/=E6=96=B0=E9=97=BB=E9=99=84?=
=?UTF-8?q?=E4=BB=B6=E4=B8=8E=E6=92=A4=E5=9B=9E/=E7=AE=A1=E7=90=86?=
=?UTF-8?q?=E5=91=98=E6=9D=83=E9=99=90=E9=A1=B5=20+=20=E5=B0=8F=E7=BB=84?=
=?UTF-8?q?=E7=BC=96=E5=8F=B7=E5=88=97=20+=20=E7=AD=9B=E9=80=89=E6=B8=85?=
=?UTF-8?q?=E7=A9=BA=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- R176 公示表设置区(赛项名称/编号两个配置项)+ 成绩管理下载半成品
- R177 新闻详情附件块 + 「发布 ↔ 撤回」按钮成对(v-if/v-else),撤回不删除
- R178 管理员账号页:新增/编辑权限/重置口令/禁用/删除;侧栏身份区显示角色
- R178 无权限空态;菜单按 can() 裁剪(体验层,最终以后端 403 为准)
- R178 成绩总览新增「小组编号」列(三态:编号 / 队伍 #N / 未编组)
- R178 筛选清空修复:Element Plus value-on-clear 默认 undefined ⇒ 改白名单判定 + value-on-clear=""
---
src/api/admin.js | 156 +++++++
src/components.d.ts | 1 +
src/views/Admin.vue | 976 +++++++++++++++++++++++++++++++++++++--
src/views/NewsDetail.vue | 46 ++
4 files changed, 1153 insertions(+), 26 deletions(-)
diff --git a/src/api/admin.js b/src/api/admin.js
index 2ec14e3..eb96d95 100644
--- a/src/api/admin.js
+++ b/src/api/admin.js
@@ -277,6 +277,91 @@ export function getPublishStatus(group) {
return request.get('/admin/publish-scores', group ? { params: { group } } : undefined)
}
+// ==================== 成绩公示表(R176) ====================
+
+/**
+ * 公示表导出前的统计预检(⛔ 不下载文件,只回统计)
+ *
+ * 为什么要先预检:成绩公示表是**对外的正式文书**,缺数据不能静默带出。
+ * 本接口回传「无小组归属人数 / 测试数据人数 / 赛项名称是否已配置」,
+ * 前端据此在执行下载前让管理员知情并确认,而不是下载完才发现表格有空白列。
+ *
+ * ⚠️ 为什么不用响应头传这些字段:生产经 nginx `X-Accel-Redirect` 时
+ * 上游自定义响应头会被丢弃(本项目已实测),故改用 `?meta=1` 走 JSON。
+ *
+ * silent: true —— 属「预检」而非用户动作,失败由调用方按业务语境提示。
+ * @param {string} group - 组别标识
+ * @returns {Promise<{fileName:string,rowCount:number,testCount:number,
+ * noTeamCount:number,totalContestants:number,itemName:string,itemCode:string}>}
+ */
+export function getPublicityMeta(group) {
+ return request.get(`/admin/export-publicity/${group}`, {
+ params: { meta: 1 },
+ silent: true,
+ })
+}
+
+/**
+ * 下载成绩公示表半成品(按模板填充,获奖等级列留空由人工填写)
+ *
+ * 下载动作本身由 `utils/download.js#safeDownload` 发起(含 HTTP 状态 +
+ * content-type + 文件头三重校验),故这里不提供 axios 封装,
+ * 调用方直接用 `/api/admin/export-publicity/${group}` 走 safeDownload。
+ *
+ * 留此常量而非散落字符串:URL 前缀被前端(safeDownload)与后端路由共同约束,
+ * 改成单点引用可避免两处漂移。
+ * @param {string} group
+ * @returns {string} 下载 URL
+ */
+export function publicityExportUrl(group) {
+ return `/api/admin/export-publicity/${group}`
+}
+
+/**
+ * 读取赛项信息(赛项名称 / 赛项编号)
+ *
+ * 这两项用于公示表标题(模板第 1~3 行)。用户裁定:不做硬编码,
+ * 改由管理人员在后台填写 —— 因为赛项名称/编号每届都会变。
+ * @returns {Promise<{itemName:string,itemCode:string}>}
+ */
+export function getCompetitionItem() {
+ return request.get('/admin/competition-item', { silent: true })
+}
+
+/**
+ * 保存赛项信息
+ * @param {{itemName?: string, itemCode?: string}} data
+ * @returns {Promise<{itemName:string,itemCode:string}>}
+ */
+export function updateCompetitionItem(data) {
+ // silent: true —— 越界(名称 >120 字 / 编号 >60 字)是业务性 400,
+ // 后端 message 已可读,由调用方在弹窗内就地提示,避免拦截器再弹一次(同族双弹)
+ return request.put('/admin/competition-item', data, { silent: true })
+}
+
+// ==================== 指导教师回填(R176) ====================
+
+/**
+ * 从报名表回填「指导教师」
+ *
+ * 背景:报名表导入时按用户裁定只录入参赛选手,指导教师的姓名没有落库。
+ * 公示表需要「指导教师」列,故提供一次性回填:重新上传同一份报名表,
+ * 服务端只读其中的「指导教师」行,按「组别 + 小组编号」定位到该组全体组员,
+ * 写入 `contestants.teacher_name`(组级属性,冗余到每位组员)。
+ *
+ * ⛔ 不新建账号、不改组别/成绩,只更新这一个列。
+ * silent: true —— 失败提示由调用方统一给出,避免同族双弹。
+ * @param {FormData} formData - 含报名表(字段名 file)
+ * @returns {Promise<{ok:boolean,code:string,message:string,stats:object,
+ * teams:object,updated:number,unchanged:number,unmatched:Array,warnings:string[]}>}
+ */
+export function backfillTeachers(formData) {
+ return request.post('/admin/contestants/backfill-teacher', formData, {
+ timeout: 120000,
+ silent: true,
+ })
+}
+
// ==================== 新闻管理 ====================
/**
@@ -324,6 +409,28 @@ export function updateNewsOrder(orders) {
return request.put('/admin/news/order', { orders })
}
+/**
+ * 上传新闻附件(R176「缺口2:新闻管理新增文件附件能力」)
+ *
+ * 场景:成绩公示表这类**文件类公示**要在新闻里对外提供下载。
+ * 附件与正文分开存:`attachment_url`(服务端返回的访问路径)+ `attachment_name`
+ * (展示用的原始文件名,中文原样保留)。
+ *
+ * ⚠️ 不要手动设置 Content-Type:axios 会在 FormData 上自动补 boundary,
+ * 写死 'multipart/form-data' 会让后端 getFileStream 报 Missing boundary → 500。
+ *
+ * silent: true —— 后端对「格式不支持 / 超 20MB」已给出可读文案,
+ * 由调用方就地提示,避免拦截器再弹一次(同族双弹)。
+ * @param {FormData} formData - 含文件(字段名 file)
+ * @returns {Promise<{url:string,name:string,size:number}>}
+ */
+export function uploadNewsFile(formData) {
+ return request.post('/admin/upload/news-file', formData, {
+ timeout: 120000,
+ silent: true,
+ })
+}
+
// ==================== 轮播图管理 ====================
/**
@@ -606,4 +713,53 @@ export function updateContestant(id, payload) {
return request.put(`/admin/contestants/${id}`, payload, { silent: true })
}
+// ==================== R178 多管理员 + 权限 ====================
+
+/**
+ * 当前登录管理员的自助信息(权限点 + 权限目录)
+ *
+ * 为什么需要它:权限被最高管理员改动后,前端手里那份(登录时落盘的)会过期。
+ * 进页面时拉一次,能让「权限已被收回」被立刻感知,而不是点每个按钮都撞 403。
+ *
+ * silent: true —— 该接口在 401(会话失效)时由拦截器统一跳登录,业务上不产生
+ * 需要额外提示的失败形态,避免与拦截器双弹。
+ */
+export function getAdminMe() {
+ return request.get('/admin/me', { silent: true })
+}
+
+/** 管理员列表(不含口令) */
+export function listAdminUsers() {
+ return request.get('/admin/admin-users')
+}
+
+/**
+ * 新增管理员
+ * @param {{username:string,password:string,permissions:string[],remark?:string}} data
+ */
+export function createAdminUser(data) {
+ return request.post('/admin/admin-users', data)
+}
+
+/** 编辑权限与备注(对目标账号立即生效,无需其重新登录) */
+export function updateAdminUserPermissions(id, data) {
+ return request.put(`/admin/admin-users/${id}/permissions`, data)
+}
+
+/** 重置口令(不要求旧口令;成功后目标账号所有在线会话立即失效) */
+export function resetAdminUserPassword(id, data) {
+ return request.put(`/admin/admin-users/${id}/password`, data)
+}
+
+/** 启用 / 禁用(status: 1 启用 / 0 禁用;禁用会立即踢下线) */
+export function setAdminUserStatus(id, data) {
+ return request.put(`/admin/admin-users/${id}/status`, data)
+}
+
+/** 删除管理员(最高管理员与本人账号不可删,后端会拒绝) */
+export function deleteAdminUser(id) {
+ return request.delete(`/admin/admin-users/${id}`)
+}
+
+
diff --git a/src/components.d.ts b/src/components.d.ts
index fea2558..80eabac 100644
--- a/src/components.d.ts
+++ b/src/components.d.ts
@@ -19,6 +19,7 @@ declare module 'vue' {
ElCarousel: typeof import('element-plus/es')['ElCarousel']
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+ ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
diff --git a/src/views/Admin.vue b/src/views/Admin.vue
index c70d9ed..6048cb9 100644
--- a/src/views/Admin.vue
+++ b/src/views/Admin.vue
@@ -16,55 +16,77 @@
+
+
数据总览
@@ -102,7 +124,8 @@
-
+
+
导入Excel
+
+
+ 回填指导教师
+
@@ -683,7 +719,10 @@
-
+
+
@@ -696,7 +735,7 @@
>批量下载作品
共 {{ submissionStatusCount.total }} 人 · 已提交 {{ submissionStatusCount.submitted }} · 未提交 {{ submissionStatusCount.unsubmitted }}
- |当前筛选出 {{ filteredSubmissions.length }} 人
+ |当前筛选出 {{ filteredSubmissions.length }} 人
@@ -924,11 +963,33 @@
-
+
+
{{ scoresPublishedMap[scoreExportGroup] ? '撤回成绩' : '发布成绩' }}
导出Excel
+
+ 下载成绩公示表
+ 公示表设置
批量下载作品
+
+
+
+
+
+ 公示表标题会拼成「<赛项名称>(选拔赛/国内决赛)比赛结果公示表」
+
+
+
+ 写入公示表第 3 行「赛项编号:___」
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+ {{ accountTeamLabel(row) }}
+ 未编组
+
+
-
+
+
- {{ row.submitted ? '已提交' : '未提交' }}
+ {{ row.submitted ? '已交卷' : '未交卷' }}
+
+ 已答 {{ row.answeredCount ?? 0 }}/{{ row.totalQuestions }}
+
@@ -1021,7 +1131,7 @@
- {{ row.score }}{{ row.submitted ? '分' : '' }}
+ {{ row.score }}
@@ -1080,10 +1190,11 @@
-
+
+
- {{ row.submitted ? '已提交' : '未提交' }}
+ {{ row.submitted ? '已交卷' : '未交卷' }}
@@ -1711,13 +1822,35 @@
{{ item.title }}
+
+ 草稿 · 前台不可见
{{ groupMap[item.group] || '全部' }}
{{ tag.trim() }}
+
+ 含附件
{{ fmtDate(item.publish_time) }}
togglePinned(item, val)" />
+
+
+ 发布
+ 撤回
编辑
删除
@@ -1752,6 +1885,37 @@
+
+
+
+ 开启时该新闻在前台列表与详情均不可见(服务端三处过滤);核对无误后关闭即发布
+
+
+
+
+
@@ -2036,7 +2350,7 @@ import { ref, reactive, onMounted, watch, computed, nextTick, defineAsyncCompone
import { ElMessage, ElMessageBox } from 'element-plus'
import {
DataAnalysis, User, EditPen, FolderOpened,
- Clock, Trophy, Notebook, PictureFilled, Rank, Files, Plus, Delete, Search
+ Clock, Trophy, Notebook, PictureFilled, Rank, Files, Plus, Delete, Search, Key
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as adminApi from '@/api/admin'
@@ -2063,6 +2377,28 @@ const RichEditor = defineAsyncComponent({
const authStore = useAuthStore()
+// ==================== R178 权限(前端体验层) ====================
+//
+// 🔴 边界:这里只负责「隐藏不该出现的菜单与按钮」,**不是安全边界**。
+// 真正的拦截在后端 middleware/auth.js + lib/adminPermissions.js:
+// 即使有人改前端或直接调接口,未授权的请求仍会 403。
+//
+// 为什么不在前端再存一份「权限目录」:目录来自 /api/admin/me(后端单源),
+// 新端硬编码会让新增权限点时忘记同步前端。
+const currentAdmin = computed(() => authStore.currentUser || {})
+const isSuper = computed(() => currentAdmin.value.isSuper === true)
+const currentUsername = computed(() => currentAdmin.value.username || currentAdmin.value.name || '')
+const ownedPermissions = computed(() => new Set(currentAdmin.value.permissions || []))
+
+/**
+ * 是否具备某权限点(最高管理员恒真)
+ * @param {string} key - 后端 lib/adminPermissions.js 的权限点 key
+ */
+function can(key) {
+ if (isSuper.value) return true
+ return ownedPermissions.value.has(key)
+}
+
// 轮播按钮"站内页面"可选项(公开首页按钮的常用跳转目标)
const internalLinkOptions = [
{ label: '激活账号', value: '/activate' },
@@ -2076,7 +2412,43 @@ const internalLinkOptions = [
// ==================== 导航 ====================
const activeMenu = ref(sessionStorage.getItem('admin_activeMenu') || 'dashboard')
+// R178:菜单 index → 权限点。⛔ 只有在这里映射一次,
+// 模板里的 v-if="can('xxx')" 与本表的 key 必须一致(键名写错 = 菜单永远不显示)。
+const MENU_PERM = {
+ dashboard: 'dashboard',
+ accounts: 'accounts',
+ questions: 'questions',
+ materials: 'materials',
+ submissions: 'submissions',
+ times: 'times',
+ scores: 'scores',
+ review: 'review',
+ news: 'news',
+ carousel: 'carousel',
+ admins: 'admins.manage',
+}
+const MENU_ORDER = ['dashboard', 'accounts', 'questions', 'materials', 'submissions', 'times', 'scores', 'review', 'news', 'carousel', 'admins']
+
+/** 第一个有权限的菜单(无任何权限时返回 null ⇒ 页面显示空态提示) */
+function firstAllowedMenu() {
+ return MENU_ORDER.find(m => can(MENU_PERM[m])) || null
+}
+
function onMenuSelect(index) {
+ // R178:菜单本身已按权限隐藏,这里再拦一层 ——
+ // 权限被最高管理员收回后页面不会自动刷新,若仍停在旧菜单上,每个请求都会撞 403。
+ const need = MENU_PERM[index]
+ if (need && !can(need)) {
+ ElMessage.warning('该功能需要相应权限,请联系最高管理员分配')
+ const fallback = firstAllowedMenu()
+ if (!fallback) {
+ // 一个模块都没授权:不加载任何数据,模板中的空态提示会接管
+ activeMenu.value = null
+ return
+ }
+ // 改指向后**继续往下走**,让 fallback 菜单的数据也被加载
+ index = fallback
+ }
activeMenu.value = index
sessionStorage.setItem('admin_activeMenu', index)
// 切换时加载对应数据
@@ -2106,6 +2478,185 @@ function onMenuSelect(index) {
}
if (index === 'news') fetchAdminNews()
if (index === 'carousel') fetchCarousels()
+ // R178:管理员页 —— 进页面同时刷新权限目录(新权限点上线后无需发版)
+ if (index === 'admins') {
+ fetchAdminUsers()
+ loadPermissionCatalog()
+ }
+}
+
+// ==================== R178 管理员账号管理 ====================
+//
+// 数据来源:GET /api/admin/admin-users(不含口令),权限目录来自 GET /api/admin/me。
+// ⛔ 权限点清单不在前端硬编码 —— 后端新增权限点时,这里自动跟随。
+const adminUsers = ref([])
+const adminUsersLoading = ref(false)
+const adminUserDialogVisible = ref(false)
+const adminUserSaving = ref(false)
+const adminUserForm = reactive({ id: null, username: '', password: '', remark: '', permissions: [] })
+const resetPwdVisible = ref(false)
+const resetPwdSaving = ref(false)
+const resetPwdTarget = ref({})
+const resetPwdValue = ref('')
+const permissionCatalog = ref([])
+
+const modulePermissions = computed(() => permissionCatalog.value.filter(p => p.group === 'module'))
+const highRiskPermissions = computed(() => permissionCatalog.value.filter(p => p.group === 'highRisk'))
+
+function permLabel(key) {
+ const hit = permissionCatalog.value.find(p => p.key === key)
+ return hit ? hit.label : key
+}
+function isHighRiskPerm(key) {
+ const hit = permissionCatalog.value.find(p => p.key === key)
+ return !!hit && hit.group === 'highRisk'
+}
+
+/**
+ * 拉取权限目录,并顺带把本地缓存的权限校准为服务端值。
+ *
+ * 为什么要在进管理端时校准:权限是每请求查库判定的,但前端菜单是用登录时落盘的
+ * 那份快照渲染的。若别人刚收回我的权限,不校准就会出现「菜单还在、点开全是 403」。
+ * 不落盘也能用,但刷新页面后会退回旧快照 ⇒ 一并写回 localStorage。
+ */
+async function loadPermissionCatalog() {
+ try {
+ const data = await adminApi.getAdminMe()
+ if (!data) return
+ permissionCatalog.value = data.catalog || []
+ if (authStore.currentUser) {
+ authStore.currentUser.isSuper = data.isSuper === true
+ authStore.currentUser.permissions = data.permissions || []
+ localStorage.setItem('user', JSON.stringify(authStore.currentUser))
+ }
+ } catch {
+ // 401(会话失效)由响应拦截器统一跳登录;其余情况保持既有目录,不打断页面
+ }
+}
+
+async function fetchAdminUsers() {
+ adminUsersLoading.value = true
+ try {
+ const data = await adminApi.listAdminUsers()
+ adminUsers.value = data?.list || []
+ } catch {
+ adminUsers.value = []
+ } finally {
+ adminUsersLoading.value = false
+ }
+}
+
+function openAdminUserDialog(row) {
+ if (row && !row.manageable) return
+ if (row) {
+ adminUserForm.id = row.id
+ adminUserForm.username = row.username
+ adminUserForm.password = ''
+ adminUserForm.remark = row.remark || ''
+ // 复制一份:直接引用会让弹窗里的勾选即时改动表格(未保存却看起来已生效)
+ adminUserForm.permissions = [...(row.permissions || [])]
+ } else {
+ adminUserForm.id = null
+ adminUserForm.username = ''
+ adminUserForm.password = ''
+ adminUserForm.remark = ''
+ adminUserForm.permissions = []
+ }
+ adminUserDialogVisible.value = true
+}
+
+async function submitAdminUser() {
+ if (!adminUserForm.id) {
+ if (!adminUserForm.username.trim()) { ElMessage.warning('请填写用户名'); return }
+ if (!adminUserForm.password) { ElMessage.warning('请填写初始口令'); return }
+ }
+ adminUserSaving.value = true
+ try {
+ if (adminUserForm.id) {
+ await adminApi.updateAdminUserPermissions(adminUserForm.id, {
+ permissions: adminUserForm.permissions,
+ remark: adminUserForm.remark,
+ })
+ ElMessage.success('权限已更新,对该账号立即生效')
+ } else {
+ await adminApi.createAdminUser({
+ username: adminUserForm.username.trim(),
+ password: adminUserForm.password,
+ permissions: adminUserForm.permissions,
+ remark: adminUserForm.remark,
+ })
+ ElMessage.success('管理员账号已创建')
+ }
+ adminUserDialogVisible.value = false
+ fetchAdminUsers()
+ } catch {
+ // 业务错误(用户名重复 / 口令强度不足 / 非法用户名)由拦截器统一提示,此处不再重复弹窗
+ } finally {
+ adminUserSaving.value = false
+ }
+}
+
+function openResetPwdDialog(row) {
+ resetPwdTarget.value = row
+ resetPwdValue.value = ''
+ resetPwdVisible.value = true
+}
+
+async function submitResetPwd() {
+ if (!resetPwdValue.value) { ElMessage.warning('请填写新口令'); return }
+ resetPwdSaving.value = true
+ try {
+ await adminApi.resetAdminUserPassword(resetPwdTarget.value.id, { password: resetPwdValue.value })
+ ElMessage.success('口令已重置,该账号需用新口令重新登录')
+ resetPwdVisible.value = false
+ resetPwdValue.value = ''
+ fetchAdminUsers()
+ } catch {
+ // 同上:错误提示由拦截器统一给出
+ } finally {
+ resetPwdSaving.value = false
+ }
+}
+
+async function toggleAdminUserStatus(row) {
+ const next = row.status === 1 ? 0 : 1
+ try {
+ await ElMessageBox.confirm(
+ next === 0
+ ? `确定禁用「${row.username}」吗?该账号所有在线会话会立即失效,且无法再登录。`
+ : `确定启用「${row.username}」吗?启用后对方可用原口令重新登录。`,
+ next === 0 ? '禁用管理员' : '启用管理员',
+ { type: next === 0 ? 'warning' : 'info', confirmButtonText: next === 0 ? '禁用' : '启用' }
+ )
+ } catch {
+ return
+ }
+ try {
+ await adminApi.setAdminUserStatus(row.id, { status: next })
+ ElMessage.success(next === 1 ? '账号已启用' : '账号已禁用')
+ fetchAdminUsers()
+ } catch {
+ // 拦截器已提示
+ }
+}
+
+async function removeAdminUser(row) {
+ try {
+ await ElMessageBox.confirm(
+ `确定删除管理员「${row.username}」吗?此操作不可恢复。该账号的历史操作记录仍保留在审计日志中。`,
+ '删除管理员',
+ { type: 'error', confirmButtonText: '删除', cancelButtonText: '取消' }
+ )
+ } catch {
+ return
+ }
+ try {
+ await adminApi.deleteAdminUser(row.id)
+ ElMessage.success('管理员账号已删除')
+ fetchAdminUsers()
+ } catch {
+ // 拦截器已提示
+ }
}
function handleLogout() {
@@ -2443,6 +2994,60 @@ async function onExcelImport(file) {
}
}
+// ==================== 指导教师回填(R176) ====================
+const teacherBackfillLoading = ref(false)
+
+/**
+ * 用同一份报名表回填「指导教师」。
+ *
+ * 背景:报名表导入按业务规则只录「参赛选手」(其余身份进 ignored),
+ * 指导教师的姓名没有落库;而成绩公示表需要「指导教师」列。
+ * 服务端只读报名表里的「指导教师」行,按「组别 + 小组编号」定位到该组,
+ * 写入组内每位选手的 `contestants.teacher_name`(组级属性,冗余到每位组员)。
+ *
+ * ⛔ 不新建账号、不改组别/成绩;值相同的不会重复写 ⇒ 可安全重跑。
+ */
+async function onTeacherBackfill(file) {
+ if (!file || !file.raw) return
+ teacherBackfillLoading.value = true
+ try {
+ const formData = new FormData()
+ formData.append('file', file.raw)
+ const r = await adminApi.backfillTeachers(formData)
+ if (!r || r.ok === false) {
+ ElMessage.error('回填失败:' + ((r && r.message) || '未知原因'))
+ return
+ }
+ const teams = r.teams || {}
+ const unmatched = r.unmatched || []
+ const parts = [
+ `更新 ${r.updated || 0} 人`,
+ `无变化 ${r.unchanged || 0} 人`,
+ `命中 ${teams.matched || 0} 个小组`,
+ ]
+ if (unmatched.length) parts.push(`${unmatched.length} 个小组未匹配`)
+ ElMessage.success('指导教师回填完成:' + parts.join(','))
+ // 未匹配的小组必须让人看到具体是哪个 —— 否则「回填完成」会掩盖部分失败
+ if (unmatched.length) {
+ const lines = unmatched.slice(0, 10).map(u =>
+ `${groupMap[u.group] || u.group} / 小组编号 ${u.teamId} / 教师 ${(u.names || []).join('、')}`)
+ if (unmatched.length > 10) lines.push(`…… 另有 ${unmatched.length - 10} 个`)
+ ElMessageBox.alert(
+ h('div', null, [
+ h('p', { style: 'margin:0 0 8px' }, '以下小组的「小组编号」在本系统 teams 表里找不到,其指导教师未能回填:'),
+ h('ul', { style: 'margin:0;padding-left:20px' }, lines.map(t => h('li', null, t))),
+ ]),
+ '部分小组未匹配',
+ { type: 'warning', confirmButtonText: '知道了' },
+ ).catch(() => {})
+ }
+ fetchAccounts()
+ } catch (e) {
+ // silent 接口 ⇒ 提示由调用方给(后端文案已含可读原因,如「这不是报名表」)
+ ElMessage.error('回填失败:' + ((e && e.message) || '未知错误'))
+ } finally { teacherBackfillLoading.value = false }
+}
+
function onAccountFilterChange() {
accountsPage.value = 1
fetchAccounts()
@@ -3532,7 +4137,9 @@ const filteredSubmissions = computed(() => {
const s = submissionSearch.value.trim().toLowerCase()
list = list.filter(item => (item.name || '').toLowerCase().includes(s))
}
- if (submissionStatusFilter.value !== '') {
+ // 🔴 R178 同「成绩总览」:⛔ 不用 `!== ''` 判断是否启用筛选(清空值可能是 undefined),
+ // 只用白名单;否则点「清除」后会静默变成「只看未提交」。
+ if (submissionStatusFilter.value === 'true' || submissionStatusFilter.value === 'false') {
const wantSubmitted = submissionStatusFilter.value === 'true'
list = list.filter(item => isWorkSubmitted(item) === wantSubmitted)
}
@@ -3847,7 +4454,15 @@ const filteredScoreList = computed(() => {
(item.idNumber || '').toLowerCase().includes(s)
)
}
- if (scoreStatusFilter.value !== '') {
+ // 🔴 R178 修复「清空筛选后数据不刷新,必须刷新页面」:
+ // 原写法 `if (scoreStatusFilter.value !== '')` 依赖「清空后值必为空字符串」这一假设,
+ // 但 el-select 点「清除」时 Element Plus 传的是 **undefined**(value-on-clear 默认为
+ // undefined,且不同小版本不一致)⇒ `undefined !== ''` 恒真,进入筛选分支;
+ // 紧接着 `undefined === 'true'` 为 false ⇒ 静默变成「只看未提交」,
+ // 用户看到的就是「条件已清空但结果仍是过滤后的」。
+ // ⇒ 改为**白名单判定**:只有明确是 'true' / 'false' 才筛选,其余一律视为「全部」。
+ // 这样即使组件库清空值再变(null / '' / undefined),行为都不会漂移。
+ if (scoreStatusFilter.value === 'true' || scoreStatusFilter.value === 'false') {
const isSubmitted = scoreStatusFilter.value === 'true'
list = list.filter(item => item.submitted === isSubmitted)
}
@@ -3930,6 +4545,113 @@ async function batchDownload() {
})
}
+// ==================== 6.1 成绩公示表(R176) ====================
+
+/**
+ * 公示表与「导出Excel」的分工(⛔ 勿合并成一个按钮):
+ * · 导出Excel —— **内部核查用**,全字段明细(含身份证号 / 手机号),不排序不裁剪;
+ * · 下载公示表 —— **对外公示用**,主办方模板 7 列、按分数由高到低、
+ * 「获奖等级」列刻意留空(系统不给奖项结论,由人工评审后填)。
+ * 正因为要对外张贴/盖章,才必须做下面的「先预检再下载」。
+ */
+const publicityExporting = ref(false)
+
+/** 赛项名称 / 赛项编号:填进公示表标题,由管理员在后台配置(每届都会变) */
+const publicityConfigVisible = ref(false)
+const publicityConfigSaving = ref(false)
+const publicityConfig = reactive({ itemName: '', itemCode: '' })
+
+async function loadPublicityConfig() {
+ try {
+ const data = await adminApi.getCompetitionItem()
+ publicityConfig.itemName = (data && data.itemName) || ''
+ publicityConfig.itemCode = (data && data.itemCode) || ''
+ } catch (e) { console.warn('读取赛项信息失败', e) }
+}
+
+async function openPublicityConfig() {
+ await loadPublicityConfig()
+ publicityConfigVisible.value = true
+}
+
+async function savePublicityConfig() {
+ publicityConfigSaving.value = true
+ try {
+ const saved = await adminApi.updateCompetitionItem({
+ itemName: (publicityConfig.itemName || '').trim(),
+ itemCode: (publicityConfig.itemCode || '').trim(),
+ })
+ publicityConfig.itemName = (saved && saved.itemName) || ''
+ publicityConfig.itemCode = (saved && saved.itemCode) || ''
+ publicityConfigVisible.value = false
+ ElMessage.success('赛项信息已保存')
+ } catch (e) {
+ // silent 接口 ⇒ 提示必须由调用方给,否则「点了保存什么都没发生」
+ ElMessage.error('保存失败:' + ((e && e.message) || '未知错误'))
+ } finally { publicityConfigSaving.value = false }
+}
+
+/**
+ * 下载成绩公示表(半成品):先预检(?meta=1)再下载。
+ *
+ * 为什么不直接下载:缺「指导教师」「赛项名称」时表格会出现整列空白,
+ * 而下载它的人是拿去盖章公示的 —— 事后发现空白要重走流程。
+ * 故把预检结果摆到管理员面前,确认后才真正下载;
+ * 点「去补设置」直接跳到赛项信息配置弹窗,免去自己找入口。
+ */
+async function exportPublicity() {
+ const group = scoreExportGroup.value
+ if (!group) return
+ const label = groupMap[group] || group
+
+ let meta = null
+ try {
+ meta = await adminApi.getPublicityMeta(group)
+ } catch (e) {
+ // 预检只是辅助信息,失败不阻断下载;但不再弹提示,避免与下载错误叠加
+ console.warn('公示表预检失败', e)
+ }
+
+ const warns = []
+ if (meta) {
+ if (!meta.itemName) warns.push('「赛项名称」尚未配置 —— 标题会保留模板占位文字')
+ if (!meta.itemCode) warns.push('「赛项编号」尚未配置')
+ if (meta.noTeamCount > 0) warns.push(`有 ${meta.noTeamCount} 名选手未归属任何小组,将各自单独占一行`)
+ if (meta.testCount > 0) warns.push(`本组含 ${meta.testCount} 名标记为「测试数据」的选手,其所在行也会出现在公示表里`)
+ if (!meta.rowCount) warns.push('本组没有任何可导出的选手')
+ }
+
+ if (warns.length) {
+ try {
+ await ElMessageBox.confirm(
+ h('div', null, [
+ h('p', { style: 'margin:0 0 8px' }, '导出前发现以下情况,请确认:'),
+ h('ul', { style: 'margin:0;padding-left:20px' }, warns.map(w => h('li', null, w))),
+ ]),
+ `导出「${label}」成绩公示表`,
+ {
+ type: 'warning',
+ confirmButtonText: '仍然下载',
+ cancelButtonText: '去补设置',
+ // 区分「点取消(去补设置)」与「点右上角 X」:前者跳配置,后者什么都不做
+ distinguishCancelAndClose: true,
+ },
+ )
+ } catch (action) {
+ if (action === 'cancel') openPublicityConfig()
+ return
+ }
+ }
+
+ publicityExporting.value = true
+ try {
+ await safeDownload(adminApi.publicityExportUrl(group), {
+ fallbackName: `成绩公示表-${label}.xlsx`,
+ timeout: 120000,
+ })
+ } finally { publicityExporting.value = false }
+}
+
// ==================== 7. 新闻管理 ====================
const adminNews = ref([])
const adminNewsLoading = ref(false)
@@ -3970,7 +4692,12 @@ const pagedAdminNews = computed(() => {
const newsDialogVisible = ref(false)
const newsSaving = ref(false)
const editingNews = ref(null)
-const newsForm = reactive({ title: '', group: 'all', pinned: false, tags: [''], news_type: 'content', content: '', wx_link: '' })
+// R176:新增 is_draft(草稿态)/ attachment_url / attachment_name(文件附件)
+const newsForm = reactive({
+ title: '', group: 'all', pinned: false, tags: [''], news_type: 'content',
+ content: '', wx_link: '', is_draft: false, attachment_url: '', attachment_name: '',
+})
+const newsFileUploading = ref(false)
async function fetchAdminNews() {
adminNewsLoading.value = true
@@ -4004,6 +4731,11 @@ function openNewsDialog(row = null) {
newsForm.news_type = row.news_type || 'content'
newsForm.content = row.content || ''
newsForm.wx_link = row.wx_link || ''
+ // R176:草稿态与附件。is_draft 后端存 0/1,这里统一成布尔供 el-switch 用;
+ // ⛔ 不要用 `row.is_draft || false` —— 0 会走 falsy 分支没问题,但 1 也需要转布尔
+ newsForm.is_draft = Number(row.is_draft) === 1
+ newsForm.attachment_url = row.attachment_url || ''
+ newsForm.attachment_name = row.attachment_name || ''
} else {
newsForm.title = ''
newsForm.group = 'all'
@@ -4012,6 +4744,9 @@ function openNewsDialog(row = null) {
newsForm.news_type = 'content'
newsForm.content = ''
newsForm.wx_link = ''
+ newsForm.is_draft = false
+ newsForm.attachment_url = ''
+ newsForm.attachment_name = ''
}
newsDialogVisible.value = true
}
@@ -4044,6 +4779,12 @@ async function saveNews() {
news_type: newsForm.news_type,
content: newsForm.content,
wx_link: newsForm.wx_link,
+ // R176:草稿态与附件。⚠️ is_draft 必须显式传 0/1 ——
+ // 后端 updateNews 用 `!== undefined` 判断,省略该字段等于「保持原值」,
+ // 于是「关闭草稿开关」会静默失效(看起来保存成功,前台依旧看不到)。
+ is_draft: newsForm.is_draft ? 1 : 0,
+ attachment_url: newsForm.attachment_url || '',
+ attachment_name: newsForm.attachment_name || '',
}
if (editingNews.value?.id) {
await adminApi.updateNews(editingNews.value.id, payload)
@@ -4056,6 +4797,72 @@ async function saveNews() {
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { newsSaving.value = false }
}
+/**
+ * 上传新闻附件(R176)
+ *
+ * 先传文件拿到 url+展示名,再随「保存」一起提交到新闻记录 ——
+ * 这样用户取消编辑时不会留下一条引用了附件的半成品新闻。
+ * 代价是被取消的那次上传会留下孤儿文件,由 `upload.js` 的清理逻辑兜底。
+ */
+async function onNewsFileChange(file) {
+ if (!file || !file.raw) return
+ newsFileUploading.value = true
+ try {
+ const formData = new FormData()
+ formData.append('file', file.raw)
+ const r = await adminApi.uploadNewsFile(formData)
+ if (!r || !r.url) {
+ ElMessage.error('上传失败:服务端未返回文件地址')
+ return
+ }
+ newsForm.attachment_url = r.url
+ newsForm.attachment_name = r.name || file.name || ''
+ ElMessage.success('附件已上传,记得点「保存」')
+ } catch (e) {
+ // silent 接口 ⇒ 提示由调用方给(后端对格式/体积已有可读文案)
+ ElMessage.error('附件上传失败:' + ((e && e.message) || '未知错误'))
+ } finally { newsFileUploading.value = false }
+}
+
+/** 移除附件(只清表单字段,保存后生效;不立即删服务器文件) */
+function clearNewsAttachment() {
+ newsForm.attachment_url = ''
+ newsForm.attachment_name = ''
+}
+
+/**
+ * 草稿 → 发布(R176)
+ *
+ * 等价于把 is_draft 置 0,前台立即可见。
+ * 单独给按钮的原因:成绩发布时每次都会自动生成一条公示草稿,
+ * 这是**高频入口**,藏在编辑弹窗的开关里容易被漏掉。
+ */
+async function publishAdminNews(row) {
+ try {
+ await adminApi.updateNews(row.id, { is_draft: 0 })
+ row.is_draft = 0
+ ElMessage.success('已发布,前台可见')
+ fetchAdminNews()
+ } catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
+}
+
+/**
+ * 已发布 → 撤回(R177)
+ *
+ * 与 publishAdminNews 互为反向:把 is_draft 置回 1,前台三处过滤立即生效
+ * (service/news.js 的 list / findById / getGroupCounts 都判 is_draft=0)。
+ * ⚠️ 只改状态、不删内容:正文、附件、发布时间全部保留,
+ * 再次点「发布」即可原样恢复 ⇒ 不同于「删除」,不需要二次确认。
+ */
+async function unpublishAdminNews(row) {
+ try {
+ await adminApi.updateNews(row.id, { is_draft: 1 })
+ row.is_draft = 1
+ ElMessage.success('已撤回,前台不可见(内容保留)')
+ fetchAdminNews()
+ } catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
+}
+
async function deleteAdminNews(row) {
try {
await ElMessageBox.confirm('确定删除该新闻?', '确认', { type: 'warning' })
@@ -4466,7 +5273,10 @@ async function downloadReviewWork(row) {
}
// ==================== 初始化 ====================
-onMounted(() => {
+onMounted(async () => {
+ // R178:先向服务端校准权限(多管理员下,权限可能在登录后被改动)。
+ // 失败不阻断 —— can() 会退回登录时落盘的那份快照。
+ await loadPermissionCatalog()
// 首次加载时根据当前活动菜单获取对应数据
onMenuSelect(activeMenu.value)
})
@@ -4539,10 +5349,15 @@ onMounted(() => {
border-right: 1px solid #e5e7eb;
overflow-y: auto;
flex-shrink: 0;
+ /* R178:改为纵向 flex —— 底部身份区要贴底(margin-top:auto),
+ 菜单溢出时仍可滚动(sidebar-menu 自己撑高)。 */
+ display: flex;
+ flex-direction: column;
}
.sidebar-menu {
border-right: none;
+ flex: 1 1 auto;
}
/* 增强选中项对比度 */
@@ -4663,6 +5478,86 @@ onMounted(() => {
margin-bottom: 4px;
}
+/* R178(B④)提交状态列内的作答进度(与「已交卷/未交卷」标签区分层次) */
+.submit-progress {
+ margin-top: 2px;
+ font-size: 12px;
+ color: #909399;
+ line-height: 1.2;
+}
+
+/* ==================== R178 多管理员 + 权限 ==================== */
+
+/* 账号无任何权限时的空态:占满内容区并垂直居中,避免看起来像加载失败 */
+.no-permission {
+ padding: 80px 0;
+}
+
+/* 表格里的「无值」占位(未编组 / 从未登录 / 未分配权限)
+ 统一用灰字而不是空白:空白会与「值恰好是空字符串」混淆 */
+.cell-muted {
+ color: #b1b3b8;
+ font-size: 13px;
+}
+
+.perm-tag {
+ margin: 0 4px 4px 0;
+}
+
+.admin-super-tag {
+ margin-left: 6px;
+}
+
+/* 权限勾选分区:模块与高危操作分组展示,避免 13 个复选框平铺成一团 */
+.perm-groups {
+ width: 100%;
+}
+
+.perm-group {
+ margin-bottom: 12px;
+}
+
+.perm-group__title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #1a3a5c;
+ margin-bottom: 6px;
+}
+
+.perm-group__hint {
+ margin-left: 8px;
+ font-weight: 400;
+ font-size: 12px;
+ color: #e6a23c;
+}
+
+/* 两列排布:13 个权限点单列会撑高弹窗,双列在一屏内可见 */
+.perm-check {
+ width: 46%;
+ margin-right: 2%;
+}
+
+/* 侧边栏底部身份区:多管理员下必须随时能确认「我是谁、什么角色」。
+ 配色跟随侧边栏白底(#fff),⛔ 不要用白色文字 —— 那是深色侧栏的写法。 */
+.sidebar-identity {
+ margin-top: auto;
+ padding: 12px 16px;
+ border-top: 1px solid #e5e7eb;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.sidebar-identity__name {
+ font-size: 13px;
+ color: #1a3a5c;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
/* ==================== 统计口径切换 ==================== */
.stats-scope-bar {
margin-bottom: 16px;
@@ -4692,6 +5587,35 @@ onMounted(() => {
color: #92400e;
}
+/* R176:表单内的一行说明文字(赛项信息 / 草稿开关) */
+.form-hint {
+ margin-top: 4px;
+ font-size: 12px;
+ line-height: 1.6;
+ color: #6b7280;
+}
+
+/* R176:新闻附件行(上传按钮 + 已传文件名 + 移除) */
+.news-attach {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.news-attach__name {
+ max-width: 320px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 13px;
+ color: #1a3a5c;
+ text-decoration: underline;
+}
+.news-attach__empty {
+ font-size: 12px;
+ color: #9ca3af;
+}
+
/* ==================== 表格 ==================== */
.admin-table {
background: #fff;
diff --git a/src/views/NewsDetail.vue b/src/views/NewsDetail.vue
index 2fe3dbd..2523e4e 100644
--- a/src/views/NewsDetail.vue
+++ b/src/views/NewsDetail.vue
@@ -49,6 +49,22 @@
+
+
+
@@ -251,6 +267,36 @@ onMounted(() => {
padding: 20px 0;
}
+/* R176:附件下载块(成绩公示表等文件类公示) */
+.article-attachment {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ padding: 12px 16px;
+ margin: 8px 0 16px;
+ background: #f0f6ff;
+ border: 1px solid #d6e4f5;
+ border-radius: 6px;
+}
+.article-attachment__icon {
+ color: #1a3a5c;
+}
+.article-attachment__label {
+ font-size: 14px;
+ color: #4b5563;
+}
+.article-attachment__link {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1a3a5c;
+ text-decoration: underline;
+ word-break: break-all;
+}
+.article-attachment__link:hover {
+ color: #c9a84c;
+}
+
.wx-link-btn {
text-decoration: none;
}