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 @@
- + + 数据总览 - + 选手管理 - + 试卷管理 - + 备赛资料 - + 作品管理 - + 时间设置 - + 成绩管理 - + 成绩复核 - + 新闻管理 - + 轮播管理 + + + 管理员 + + + +
+ + + + + +
@@ -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 @@ + +
+ + 附件下载: + {{ news.attachment_name || '点击下载附件' }} +
+