feat(admin): R176-R180 公示表设置/新闻附件与撤回/管理员权限页 + 小组编号列 + 筛选清空修复
- R176 公示表设置区(赛项名称/编号两个配置项)+ 成绩管理下载半成品 - R177 新闻详情附件块 + 「发布 ↔ 撤回」按钮成对(v-if/v-else),撤回不删除 - R178 管理员账号页:新增/编辑权限/重置口令/禁用/删除;侧栏身份区显示角色 - R178 无权限空态;菜单按 can() 裁剪(体验层,最终以后端 403 为准) - R178 成绩总览新增「小组编号」列(三态:编号 / 队伍 #N / 未编组) - R178 筛选清空修复:Element Plus value-on-clear 默认 undefined ⇒ 改白名单判定 + value-on-clear=""
This commit is contained in:
156
src/api/admin.js
156
src/api/admin.js
@@ -277,6 +277,91 @@ export function getPublishStatus(group) {
|
|||||||
return request.get('/admin/publish-scores', group ? { params: { group } } : undefined)
|
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 })
|
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 })
|
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}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@@ -19,6 +19,7 @@ declare module 'vue' {
|
|||||||
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
||||||
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,22 @@
|
|||||||
|
|
||||||
<el-divider />
|
<el-divider />
|
||||||
|
|
||||||
|
<!-- R176:附件下载(成绩公示表等「文件类公示」)
|
||||||
|
放在正文之前:附件是这类新闻的**主内容**,正文只是说明。
|
||||||
|
⛔ 链接直接用后端返回的 /public/... 相对路径(dev 由 vite 代理、
|
||||||
|
生产由 nginx 直出),不要拼 /api 前缀。 -->
|
||||||
|
<div v-if="news.attachment_url" class="article-attachment">
|
||||||
|
<el-icon class="article-attachment__icon"><Paperclip /></el-icon>
|
||||||
|
<span class="article-attachment__label">附件下载:</span>
|
||||||
|
<a
|
||||||
|
:href="news.attachment_url"
|
||||||
|
class="article-attachment__link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
:download="news.attachment_name || ''"
|
||||||
|
>{{ news.attachment_name || '点击下载附件' }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 微信链接型新闻 -->
|
<!-- 微信链接型新闻 -->
|
||||||
<div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section">
|
<div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section">
|
||||||
<el-alert type="info" :closable="false" show-icon>
|
<el-alert type="info" :closable="false" show-icon>
|
||||||
@@ -251,6 +267,36 @@ onMounted(() => {
|
|||||||
padding: 20px 0;
|
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 {
|
.wx-link-btn {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user