Compare commits
3 Commits
45e77d9f69
...
4fd04f106a
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fd04f106a | |||
| 96d16129f7 | |||
| ab1c67dbc5 |
@@ -68,9 +68,11 @@ export function getTimes() {
|
|||||||
/**
|
/**
|
||||||
* 设置比赛时间
|
* 设置比赛时间
|
||||||
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
|
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
|
||||||
|
* @param {Object} [expected] - B② 乐观锁:打开表单时读到的旧值
|
||||||
|
* { zhongzhi: { start, end, deadline }, ... };不传 ⇒ 后端不校验(旧行为)
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
export function setTimes(data) {
|
export function setTimes(data, expected) {
|
||||||
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
|
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
|
||||||
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
|
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
|
||||||
const times = Object.entries(data).map(([group, item]) => ({
|
const times = Object.entries(data).map(([group, item]) => ({
|
||||||
@@ -81,7 +83,10 @@ export function setTimes(data) {
|
|||||||
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
|
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
|
||||||
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20,
|
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20,
|
||||||
}))
|
}))
|
||||||
return request.put('/admin/times', { times })
|
const payload = { times }
|
||||||
|
// ⛔ 只在真的有基线时才带:空对象会让后端误以为「所有组都要求校验」而恒冲突
|
||||||
|
if (expected && Object.keys(expected).length > 0) payload.expectedTimes = expected
|
||||||
|
return request.put('/admin/times', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import request from './request'
|
|||||||
/**
|
/**
|
||||||
* 获取上传状态
|
* 获取上传状态
|
||||||
* @param {string} group - 组别标识
|
* @param {string} group - 组别标识
|
||||||
* @returns {Promise} { submitted, deadline, canSubmit }
|
* @param {Object} [config] - 透传给 axios 的配置(如 { silent: true } 抑制拦截器报错)
|
||||||
|
* @returns {Promise} { submitted, deadline, canSubmit, canUpload, teamLocked, teamLockMessage, teamFiles, ... }
|
||||||
*/
|
*/
|
||||||
export function getUploadStatus(group) {
|
export function getUploadStatus(group, config = {}) {
|
||||||
return request.get(`/upload/${group}/status`)
|
return request.get(`/upload/${group}/status`, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -740,6 +740,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-table :data="pagedSubmissions" stripe v-loading="submissionsLoading" class="admin-table">
|
<el-table :data="pagedSubmissions" stripe v-loading="submissionsLoading" class="admin-table">
|
||||||
<el-table-column prop="name" label="选手姓名" width="120" />
|
<el-table-column prop="name" label="选手姓名" width="120" />
|
||||||
|
<!-- R190:小组编号 / 作品目录。
|
||||||
|
展示值 = 后端落盘用的目录名(teamFolder),与「批量下载」压缩包内的子目录
|
||||||
|
以及「单组压缩包」文件名**完全同源**,管理人员据此对得上号。 -->
|
||||||
|
<el-table-column label="小组编号 / 作品目录" width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="team-cell">
|
||||||
|
<span class="team-no" :title="row.teamFolder">{{ row.teamFolder }}</span>
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="row._zipLoading"
|
||||||
|
:disabled="!row.files || row.files.length === 0"
|
||||||
|
v-guard="() => downloadTeamZip(row)"
|
||||||
|
>下载本组压缩包</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="上传文件 / 评分" min-width="420">
|
<el-table-column label="上传文件 / 评分" min-width="420">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-for="f in row.files" :key="f.id" class="work-file-row">
|
<div v-for="f in row.files" :key="f.id" class="work-file-row">
|
||||||
@@ -1950,9 +1968,15 @@
|
|||||||
<h2 class="section-heading">轮播管理</h2>
|
<h2 class="section-heading">轮播管理</h2>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCarouselDialog()">添加轮播</el-button>
|
<el-button type="primary" @click="openCarouselDialog()">添加轮播</el-button>
|
||||||
<el-button type="primary" plain v-guard="() => saveCarouselOrder()" :disabled="adminCarousels.length < 2">保存排序</el-button>
|
<el-button
|
||||||
|
:type="carouselOrderDirty ? 'warning' : 'primary'"
|
||||||
|
plain
|
||||||
|
v-guard="() => saveCarouselOrder()"
|
||||||
|
:disabled="adminCarousels.length < 2"
|
||||||
|
>{{ carouselOrderDirty ? '保存排序(有未保存改动)' : '保存排序' }}</el-button>
|
||||||
|
<el-tag v-if="carouselOrderDirty" type="warning" effect="light" size="small">排序未保存</el-tag>
|
||||||
<span class="drag-hint" v-if="adminCarousels.length > 1">
|
<span class="drag-hint" v-if="adminCarousels.length > 1">
|
||||||
<el-icon><Rank /></el-icon> 拖拽卡片可调整排序
|
<el-icon><Rank /></el-icon> 拖拽卡片可调整排序;拖完须点「保存排序」才写入服务器
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-loading="carouselLoading" class="carousel-card-list" ref="carouselCardListRef">
|
<div v-loading="carouselLoading" class="carousel-card-list" ref="carouselCardListRef">
|
||||||
@@ -2434,7 +2458,31 @@ function firstAllowedMenu() {
|
|||||||
return MENU_ORDER.find(m => can(MENU_PERM[m])) || null
|
return MENU_ORDER.find(m => can(MENU_PERM[m])) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMenuSelect(index) {
|
// ---- 「改权限需刷新」的收敛点(R189)----
|
||||||
|
// 原状:权限只在 onMounted 拉一次。最高管理员改了你的权限后,你必须**手动刷新页面**;
|
||||||
|
// 若停在旧页面上,菜单还是旧的,每个请求都撞 403 且没有任何提示。
|
||||||
|
// 现法:切菜单时静默重拉 /api/admin/me(8s 节流,避免连点菜单刷请求)。
|
||||||
|
// ⛔ 不用 location.reload() —— 那会丢掉未提交的表单输入。
|
||||||
|
let lastPermCalibAt = 0
|
||||||
|
const PERM_CALIB_MIN_INTERVAL = 8000
|
||||||
|
|
||||||
|
async function calibratePermissions() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastPermCalibAt < PERM_CALIB_MIN_INTERVAL) return
|
||||||
|
lastPermCalibAt = now
|
||||||
|
const prev = authStore.currentUser || {}
|
||||||
|
const prevKey = `${prev.isSuper === true}|${[...(prev.permissions || [])].sort().join(',')}`
|
||||||
|
await loadPermissionCatalog()
|
||||||
|
const cur = authStore.currentUser || {}
|
||||||
|
const curKey = `${cur.isSuper === true}|${[...(cur.permissions || [])].sort().join(',')}`
|
||||||
|
if (prevKey === curKey) return
|
||||||
|
ElMessage.warning('你的权限已被更新,菜单与可用操作已同步')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMenuSelect(index) {
|
||||||
|
// R189:先静默校准权限(节流 8s)。否则这里拦的仍是登录时的旧快照 ——
|
||||||
|
// 「改权限后需刷新」的根因就在这一步缺失,而不是拦少了。
|
||||||
|
await calibratePermissions()
|
||||||
// R178:菜单本身已按权限隐藏,这里再拦一层 ——
|
// R178:菜单本身已按权限隐藏,这里再拦一层 ——
|
||||||
// 权限被最高管理员收回后页面不会自动刷新,若仍停在旧菜单上,每个请求都会撞 403。
|
// 权限被最高管理员收回后页面不会自动刷新,若仍停在旧菜单上,每个请求都会撞 403。
|
||||||
const need = MENU_PERM[index]
|
const need = MENU_PERM[index]
|
||||||
@@ -4163,6 +4211,11 @@ async function fetchSubmissions() {
|
|||||||
idNumber: s.idNumber,
|
idNumber: s.idNumber,
|
||||||
phone: s.phone,
|
phone: s.phone,
|
||||||
group: s.group,
|
group: s.group,
|
||||||
|
// R190:小组维度字段(后端 getWorks 已按 (group, teamUid) 解析,前端只展示)
|
||||||
|
teamUid: s.teamUid === undefined ? null : s.teamUid,
|
||||||
|
teamNumber: s.teamNumber || null,
|
||||||
|
teamFolder: s.teamFolder || '未编组',
|
||||||
|
aggregationUid: s.aggregationUid === undefined ? null : s.aggregationUid,
|
||||||
files: (s.files || []).map(f => ({ ...f, _score: f.score != null ? Number(f.score) : null })),
|
files: (s.files || []).map(f => ({ ...f, _score: f.score != null ? Number(f.score) : null })),
|
||||||
}))
|
}))
|
||||||
} catch (e) { console.warn('获取作品列表失败', e) } finally { submissionsLoading.value = false }
|
} catch (e) { console.warn('获取作品列表失败', e) } finally { submissionsLoading.value = false }
|
||||||
@@ -4227,6 +4280,31 @@ async function batchDownloadWorks() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载「单个小组」作品压缩包(R190)
|
||||||
|
*
|
||||||
|
* 【业务来源】评分时两个文件分开下载容易混乱 ⇒ 一个小组一个压缩包,包内平铺该组文件。
|
||||||
|
* 定位键优先级:teamUid(组内全部成员)→ aggregationUid(无队伍号时退化为单人)。
|
||||||
|
* 🔴 与后端 `teamZip()` 的定位口径保持一致,⛔ 不在此处按姓名/编号自行匹配。
|
||||||
|
*/
|
||||||
|
async function downloadTeamZip(row) {
|
||||||
|
const group = submissionGroup.value
|
||||||
|
if (!group || row._zipLoading) return
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (row.teamUid !== null && row.teamUid !== undefined) q.set('teamUid', row.teamUid)
|
||||||
|
else if (row.aggregationUid !== null && row.aggregationUid !== undefined) q.set('aggregationUid', row.aggregationUid)
|
||||||
|
const suffix = q.toString() ? `?${q.toString()}` : ''
|
||||||
|
row._zipLoading = true
|
||||||
|
try {
|
||||||
|
await safeDownload(`/api/admin/team-zip/${group}${suffix}`, {
|
||||||
|
fallbackName: `${row.teamFolder || '作品'}_作品.zip`,
|
||||||
|
timeout: 300000,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
row._zipLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 批量保存评分(按文件聚合到选手的 wordScore / pptScore)
|
// 批量保存评分(按文件聚合到选手的 wordScore / pptScore)
|
||||||
async function batchSaveWorkScores() {
|
async function batchSaveWorkScores() {
|
||||||
try {
|
try {
|
||||||
@@ -4259,6 +4337,10 @@ const timesForm = reactive({
|
|||||||
})
|
})
|
||||||
const timesSaving = ref(false)
|
const timesSaving = ref(false)
|
||||||
const timesLoading = ref(false)
|
const timesLoading = ref(false)
|
||||||
|
// B② 乐观锁基线:`fetchTimes` 载入时的「服务端旧值」。
|
||||||
|
// 保存时原样回传,后端比对不一致即判并发冲突 ⇒ 后保存者不会静默覆盖先保存者。
|
||||||
|
// ⛔ 不要在这里放表单当前值:那样永远等于提交值,校验恒真(假保护)。
|
||||||
|
const timesBaseline = ref({})
|
||||||
|
|
||||||
const timeGroups = [
|
const timeGroups = [
|
||||||
{ key: 'zhongzhi', name: '中职组', isExam: true },
|
{ key: 'zhongzhi', name: '中职组', isExam: true },
|
||||||
@@ -4275,6 +4357,8 @@ async function fetchTimes() {
|
|||||||
const list = (res && res.times) || []
|
const list = (res && res.times) || []
|
||||||
const map = {}
|
const map = {}
|
||||||
for (const t of list) map[t.group] = t
|
for (const t of list) map[t.group] = t
|
||||||
|
// 重新载入即视为「新的服务端基线」:冲突后刷新表单走的就是这里
|
||||||
|
const baseline = {}
|
||||||
for (const g of timeGroups) {
|
for (const g of timeGroups) {
|
||||||
const t = map[g.key]
|
const t = map[g.key]
|
||||||
if (!t) continue
|
if (!t) continue
|
||||||
@@ -4285,7 +4369,14 @@ async function fetchTimes() {
|
|||||||
timesForm[g.key + 'JudgeCount'] = t.judgeCount != null ? t.judgeCount : 20
|
timesForm[g.key + 'JudgeCount'] = t.judgeCount != null ? t.judgeCount : 20
|
||||||
timesForm[g.key + 'AvailChoice'] = t.availableChoice || 0
|
timesForm[g.key + 'AvailChoice'] = t.availableChoice || 0
|
||||||
timesForm[g.key + 'AvailJudge'] = t.availableJudge || 0
|
timesForm[g.key + 'AvailJudge'] = t.availableJudge || 0
|
||||||
|
// 只记「会被乐观锁比对」的三项时间字段(choiceCount/judgeCount 不参与)
|
||||||
|
baseline[g.key] = {
|
||||||
|
start: t.startTime ? t.startTime : '',
|
||||||
|
end: t.endTime ? t.endTime : '',
|
||||||
|
deadline: t.deadline ? t.deadline : '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
timesBaseline.value = baseline
|
||||||
} catch (e) { console.warn('获取时间配置失败', e) } finally { timesLoading.value = false }
|
} catch (e) { console.warn('获取时间配置失败', e) } finally { timesLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4297,11 +4388,18 @@ async function saveTimes() {
|
|||||||
gaozhi: { start: timesForm.gaozhiStart, end: timesForm.gaozhiEnd, deadline: timesForm.gaozhiDeadline, choiceCount: timesForm.gaozhiChoiceCount, judgeCount: timesForm.gaozhiJudgeCount },
|
gaozhi: { start: timesForm.gaozhiStart, end: timesForm.gaozhiEnd, deadline: timesForm.gaozhiDeadline, choiceCount: timesForm.gaozhiChoiceCount, judgeCount: timesForm.gaozhiJudgeCount },
|
||||||
benke: { start: timesForm.benkeStart, end: timesForm.benkeEnd, deadline: timesForm.benkeDeadline, choiceCount: timesForm.benkeChoiceCount, judgeCount: timesForm.benkeJudgeCount },
|
benke: { start: timesForm.benkeStart, end: timesForm.benkeEnd, deadline: timesForm.benkeDeadline, choiceCount: timesForm.benkeChoiceCount, judgeCount: timesForm.benkeJudgeCount },
|
||||||
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
|
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
|
||||||
})
|
}, timesBaseline.value)
|
||||||
ElMessage.success('时间设置保存成功')
|
ElMessage.success('时间设置保存成功')
|
||||||
// 保存即重排三节点计划(生产环境)⇒ 立刻回读,让「新计划是否真的排上」当场可见
|
// 保存即重排三节点计划(生产环境)⇒ 立刻回读,让「新计划是否真的排上」当场可见
|
||||||
fetchNodeBackupStatus()
|
fetchNodeBackupStatus()
|
||||||
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { timesSaving.value = false }
|
} catch (e) {
|
||||||
|
// 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹)。
|
||||||
|
// 🔴 仅「并发冲突」时自动回读:此刻表单里的值已过期,拿它重试只会再冲突;
|
||||||
|
// 其它失败(网络 / 参数校验)保留用户输入,不擅自清空。
|
||||||
|
if (e && typeof e.message === 'string' && e.message.includes('已被他人修改')) {
|
||||||
|
await fetchTimes()
|
||||||
|
}
|
||||||
|
} finally { timesSaving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 节点备份状态(P1「生产备份静默失败防护」的可见性)----
|
// ---- 节点备份状态(P1「生产备份静默失败防护」的可见性)----
|
||||||
@@ -4955,6 +5053,10 @@ const carouselForm = reactive({
|
|||||||
})
|
})
|
||||||
const carouselCardListRef = ref(null)
|
const carouselCardListRef = ref(null)
|
||||||
let carouselSortable = null
|
let carouselSortable = null
|
||||||
|
// ㊳ 排序脏标记:拖拽只改前端顺序、**不落库**(需点「保存排序」)。
|
||||||
|
// 此前卡片上「排序: N」用的是渲染下标 idx+1 ⇒ 拖完立刻变成新序号,看起来已生效,
|
||||||
|
// 刷新却回退到旧顺序。加脏标记后,未保存状态在界面上是可见的。
|
||||||
|
const carouselOrderDirty = ref(false)
|
||||||
|
|
||||||
const uploadUrl = '/api/admin/upload/image'
|
const uploadUrl = '/api/admin/upload/image'
|
||||||
const uploadHeaders = computed(() => {
|
const uploadHeaders = computed(() => {
|
||||||
@@ -5005,6 +5107,8 @@ async function fetchCarousels() {
|
|||||||
sort_order: item.sort_order ?? 0,
|
sort_order: item.sort_order ?? 0,
|
||||||
enabled: item.enabled !== false,
|
enabled: item.enabled !== false,
|
||||||
}))
|
}))
|
||||||
|
// 重新载入 = 服务端顺序,脏状态归零
|
||||||
|
carouselOrderDirty.value = false
|
||||||
initCarouselSortable()
|
initCarouselSortable()
|
||||||
} catch (e) { console.warn('获取轮播失败', e) } finally { carouselLoading.value = false }
|
} catch (e) { console.warn('获取轮播失败', e) } finally { carouselLoading.value = false }
|
||||||
}
|
}
|
||||||
@@ -5084,6 +5188,7 @@ async function saveCarouselOrder() {
|
|||||||
}))
|
}))
|
||||||
try {
|
try {
|
||||||
await adminApi.updateCarouselOrder(orders)
|
await adminApi.updateCarouselOrder(orders)
|
||||||
|
carouselOrderDirty.value = false
|
||||||
ElMessage.success('排序保存成功')
|
ElMessage.success('排序保存成功')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('轮播排序保存失败:', e)
|
console.error('轮播排序保存失败:', e)
|
||||||
@@ -5121,6 +5226,8 @@ function initCarouselSortable() {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||||
adminCarousels.value = reordered
|
adminCarousels.value = reordered
|
||||||
|
// 拖拽只改前端顺序,**未落库** ⇒ 必须点亮脏标记,否则「排序: N」看着已生效
|
||||||
|
carouselOrderDirty.value = true
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -5274,11 +5381,10 @@ async function downloadReviewWork(row) {
|
|||||||
|
|
||||||
// ==================== 初始化 ====================
|
// ==================== 初始化 ====================
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// R178:先向服务端校准权限(多管理员下,权限可能在登录后被改动)。
|
// R178/R189:权限校准统一由 onMenuSelect → calibratePermissions 负责(带节流),
|
||||||
|
// 此处不再单独请求 /api/admin/me,避免首屏连发两次。
|
||||||
// 失败不阻断 —— can() 会退回登录时落盘的那份快照。
|
// 失败不阻断 —— can() 会退回登录时落盘的那份快照。
|
||||||
await loadPermissionCatalog()
|
await onMenuSelect(activeMenu.value)
|
||||||
// 首次加载时根据当前活动菜单获取对应数据
|
|
||||||
onMenuSelect(activeMenu.value)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -5445,6 +5551,17 @@ onMounted(async () => {
|
|||||||
.preview-file-name { color: #303133; font-weight: 600; margin-bottom: 8px; }
|
.preview-file-name { color: #303133; font-weight: 600; margin-bottom: 8px; }
|
||||||
.work-summary { color: #606266; font-size: 13px; margin-left: 8px; }
|
.work-summary { color: #606266; font-size: 13px; margin-left: 8px; }
|
||||||
|
|
||||||
|
/* R190:小组编号 / 作品目录列 */
|
||||||
|
.team-cell { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
|
||||||
|
.team-no {
|
||||||
|
max-width: 210px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #303133;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== 统计卡片 ==================== */
|
/* ==================== 统计卡片 ==================== */
|
||||||
.stats-row { margin-bottom: 20px; }
|
.stats-row { margin-bottom: 20px; }
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,46 @@
|
|||||||
<div class="exam-upload-mode" v-if="isUploadMode" v-loading="uploadPanelLoading">
|
<div class="exam-upload-mode" v-if="isUploadMode" v-loading="uploadPanelLoading">
|
||||||
<el-card class="upload-card" shadow="never">
|
<el-card class="upload-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span class="upload-title">答案文件上传({{ groupLabel }})</span>
|
<div class="upload-header">
|
||||||
|
<span class="upload-title">答案文件上传({{ groupLabel }})</span>
|
||||||
|
<!-- R192:页面级常驻出口。本页无全局导航(App.vue 只有 router-view),
|
||||||
|
组锁 / 倒计时归零 / 只是想放弃提交 的用户若无此按钮就只能在浏览器后退,
|
||||||
|
移动端甚至退不出去(体验反馈:成员进入后被锁在提交页)。 -->
|
||||||
|
<el-button text type="primary" :icon="Back" @click="leaveCompetition">返回工作台</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
|
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
|
||||||
<template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template>
|
<template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template>
|
||||||
支持 docx / pdf / ppt / zip。规则:① zip 须单独上传(与任何其他文件互斥,自动覆盖);② pdf 与 docx 相互覆盖,只能保留其一;③ 若需同时提交文档与演示稿,请选「pdf + ppt」或「docx + ppt」组合。zip 上限 200MB,其余格式上限 100MB。
|
支持 docx / pdf / ppt / zip。规则:① zip 须单独上传(与任何其他文件互斥,自动覆盖);② pdf 与 docx 相互覆盖,只能保留其一;③ 若需同时提交文档与演示稿,请选「pdf + ppt」或「docx + ppt」组合。zip 上限 200MB,其余格式上限 100MB。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
|
<!-- R190:小组作品状态(组内互斥)—— 组内已有人上传后,其他成员不能再传。
|
||||||
|
文案直接取后端 teamLockMessage,⛔ 前端不另拼一套口径(避免与后端判定漂移)。 -->
|
||||||
|
<div
|
||||||
|
v-if="teamPanelVisible"
|
||||||
|
class="team-status-panel"
|
||||||
|
:class="{ 'is-locked': teamLocked }"
|
||||||
|
>
|
||||||
|
<div class="team-status-head">
|
||||||
|
<span class="team-status-title">
|
||||||
|
<template v-if="teamLocked">本组作品已由其他成员上传,您不能再上传</template>
|
||||||
|
<template v-else-if="uploadStatus.teamSubmitted">本组作品已由您上传,截止前可随时替换</template>
|
||||||
|
<template v-else>本组尚无人上传作品</template>
|
||||||
|
</span>
|
||||||
|
<el-tag v-if="uploadStatus.teamNumber" size="small" type="info">小组编号 {{ uploadStatus.teamNumber }}</el-tag>
|
||||||
|
<el-tag v-if="uploadStatus.memberCount > 1" size="small" type="info">组内 {{ uploadStatus.memberCount }} 人</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="uploadStatus.teamLockMessage" class="team-status-msg">{{ uploadStatus.teamLockMessage }}</div>
|
||||||
|
<div v-if="teamSharedFiles.length" class="team-status-files">
|
||||||
|
<div class="pending-list-title">组内已上传文件:</div>
|
||||||
|
<div v-for="(f, i) in teamSharedFiles" :key="i" class="team-status-file">
|
||||||
|
<span class="uploaded-name">{{ f.fileName }}</span>
|
||||||
|
<el-tag size="small" :type="categoryTagType(categoryOf(f.fileName))">{{ categoryLabel(categoryOf(f.fileName)) }}</el-tag>
|
||||||
|
<span class="team-status-uploader">上传者:{{ f.uploaderName || '组内成员' }}{{ f.isMine ? '(我)' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-upload
|
<el-upload
|
||||||
ref="uploadRef"
|
ref="uploadRef"
|
||||||
:auto-upload="false"
|
:auto-upload="false"
|
||||||
@@ -34,6 +68,7 @@
|
|||||||
:accept="'.doc,.docx,.pdf,.ppt,.pptx,.zip'"
|
:accept="'.doc,.docx,.pdf,.ppt,.pptx,.zip'"
|
||||||
:file-list="uploadFileList"
|
:file-list="uploadFileList"
|
||||||
:on-change="onUploadFileChange"
|
:on-change="onUploadFileChange"
|
||||||
|
:disabled="teamLocked"
|
||||||
multiple
|
multiple
|
||||||
drag
|
drag
|
||||||
class="upload-drag"
|
class="upload-drag"
|
||||||
@@ -67,9 +102,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="upload-actions">
|
<div class="upload-actions">
|
||||||
<el-button type="primary" :loading="uploading" :disabled="pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button>
|
<el-button type="primary" :loading="uploading" :disabled="teamLocked || pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button>
|
||||||
<el-button @click="clearUploadFiles" :disabled="uploadFileList.length === 0">清空待上传</el-button>
|
<el-button @click="clearUploadFiles" :disabled="uploadFileList.length === 0">清空待上传</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 被组锁时,明确告知「为什么按钮点不动」,避免用户以为是页面故障 -->
|
||||||
|
<el-alert
|
||||||
|
v-if="teamLocked"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-top:12px"
|
||||||
|
title="组内作品唯一:本组已有成员上传,您无法再上传"
|
||||||
|
description="如需替换作品,请联系上传者本人操作(仅首个上传者可在截止前替换)。您本人无需提交,可直接返回工作台。"
|
||||||
|
/>
|
||||||
|
<!-- 组锁的出口按钮必须**紧贴提示**:视线焦点在这里,且此处的「返回」不弹
|
||||||
|
「丢失提交进度」确认(团队作品已由队友提交,本机没有可丢的内容)。 -->
|
||||||
|
<div v-if="teamLocked" class="team-locked-actions">
|
||||||
|
<el-button type="primary" @click="leaveCompetition">返回工作台</el-button>
|
||||||
|
<span class="team-locked-hint">本组作品已由队友提交,您无需任何操作。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-divider v-if="uploadedWorks.length" />
|
<el-divider v-if="uploadedWorks.length" />
|
||||||
<div v-if="uploadedWorks.length" class="uploaded-list">
|
<div v-if="uploadedWorks.length" class="uploaded-list">
|
||||||
@@ -366,7 +417,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||||
import { Timer, UploadFilled } from '@element-plus/icons-vue'
|
import { Timer, UploadFilled, Back } from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useExamStore } from '@/stores/exam'
|
import { useExamStore } from '@/stores/exam'
|
||||||
import * as examApi from '@/api/exam'
|
import * as examApi from '@/api/exam'
|
||||||
@@ -396,6 +447,31 @@ const uploadFileList = ref([]) // el-upload 绑定列表
|
|||||||
const uploadedWorks = ref([])
|
const uploadedWorks = ref([])
|
||||||
const uploadRef = ref(null)
|
const uploadRef = ref(null)
|
||||||
|
|
||||||
|
// ---------- R190:小组作品状态(组内互斥)----------
|
||||||
|
// 后端 `GET /api/upload/:group/status` 是组锁判定的**唯一权威**,前端只负责展示:
|
||||||
|
// teamLocked / teamLockMessage / teamFiles 等一律原样取用,⛔ 不在前端重算一套规则
|
||||||
|
//(重算必然与后端漂移,且学生端与下载端的「谁是归属人」会打架)。
|
||||||
|
const uploadStatus = ref({
|
||||||
|
submitted: false,
|
||||||
|
canUpload: true,
|
||||||
|
deadlineValid: true,
|
||||||
|
teamNumber: null,
|
||||||
|
folderName: '',
|
||||||
|
memberCount: 1,
|
||||||
|
teamSubmitted: false,
|
||||||
|
teamOwnerName: null,
|
||||||
|
isTeamOwner: false,
|
||||||
|
teamLocked: false,
|
||||||
|
teamLockMessage: null,
|
||||||
|
teamFiles: [],
|
||||||
|
})
|
||||||
|
const teamLocked = computed(() => !!uploadStatus.value.teamLocked)
|
||||||
|
// 有小组编号 or 组内多于一人时展示小组状态面板;独立参赛者保持原界面不变
|
||||||
|
const teamPanelVisible = computed(() => !!uploadStatus.value.teamNumber || uploadStatus.value.memberCount > 1)
|
||||||
|
// 被锁时才展示「组内已上传文件」——归属人(本人)在下方「已上传文件」里已有可删除列表,
|
||||||
|
// 两处同列会重复且按钮语义不同,容易误删。
|
||||||
|
const teamSharedFiles = computed(() => (teamLocked.value ? (uploadStatus.value.teamFiles || []) : []))
|
||||||
|
|
||||||
const ZIP_MAX = 200 * 1024 * 1024
|
const ZIP_MAX = 200 * 1024 * 1024
|
||||||
const OTHER_MAX = 100 * 1024 * 1024
|
const OTHER_MAX = 100 * 1024 * 1024
|
||||||
|
|
||||||
@@ -452,6 +528,36 @@ function clearUploadFiles() {
|
|||||||
uploadRef.value?.clearFiles?.()
|
uploadRef.value?.clearFiles?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回工作台(R192)—— 上传模式的统一出口。
|
||||||
|
*
|
||||||
|
* 🔴 必须先 resetExam() 再 push,理由有三条:
|
||||||
|
* ① 不清 `examStarted` ⇒ `onBeforeRouteLeave` 会再弹一次「离开页面将丢失当前提交进度」。
|
||||||
|
* 上传模式下用户常常只是放弃提交(甚至被组锁、本机根本没有可提交内容),
|
||||||
|
* 这句文案既失真又构成二次打扰;
|
||||||
|
* ② 不清 timer ⇒ store 是全局单例,回到工作台后倒计时仍在跑,下次进入会看到陈旧剩余时间;
|
||||||
|
* ③ 用 resetExam(而非只 stopExam)与 `onBeforeRouteLeave` 里确认离开后的清理**同一口径**,
|
||||||
|
* ⛔ 不留「按钮退出」与「路由守卫退出」两套清理逻辑(漏项会以「下次进入状态诡异」的形式出现)。
|
||||||
|
*
|
||||||
|
* 只有「待上传列表里有已选文件」才算真有内容会丢,此时才确认一次。
|
||||||
|
* 组锁用户的上传控件是 disabled 的,选不出文件 ⇒ 不弹确认,直接离开。
|
||||||
|
*/
|
||||||
|
async function leaveCompetition() {
|
||||||
|
if (!teamLocked.value && uploadFileList.value.length > 0) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'已选择的文件尚未上传,离开后需要重新选择。确定返回工作台吗?',
|
||||||
|
'提示',
|
||||||
|
{ confirmButtonText: '返回工作台', cancelButtonText: '继续上传', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
examStore.resetExam()
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
async function submitUploadFiles() {
|
async function submitUploadFiles() {
|
||||||
const files = pendingUploadFiles.value
|
const files = pendingUploadFiles.value
|
||||||
if (!files.length) return
|
if (!files.length) return
|
||||||
@@ -483,7 +589,8 @@ async function submitUploadFiles() {
|
|||||||
ElMessage.success('上传成功,作品已提交')
|
ElMessage.success('上传成功,作品已提交')
|
||||||
uploadFileList.value = []
|
uploadFileList.value = []
|
||||||
uploadRef.value?.clearFiles?.()
|
uploadRef.value?.clearFiles?.()
|
||||||
await fetchUploadedWorks()
|
// 上传成功后必须同步组锁状态:归属人变成本人、teamSubmitted 变 true(R190)
|
||||||
|
await Promise.all([fetchUploadedWorks(), fetchUploadStatus()])
|
||||||
examStore.stopExam()
|
examStore.stopExam()
|
||||||
} catch {
|
} catch {
|
||||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
@@ -510,7 +617,12 @@ async function deleteUploadedWork(w, silent = false) {
|
|||||||
try {
|
try {
|
||||||
await uploadApi.deleteWork(authStore.userGroup, w.id)
|
await uploadApi.deleteWork(authStore.userGroup, w.id)
|
||||||
uploadedWorks.value = uploadedWorks.value.filter(x => x.id !== w.id)
|
uploadedWorks.value = uploadedWorks.value.filter(x => x.id !== w.id)
|
||||||
if (!silent) ElMessage.success('已删除')
|
// 🔴 只在「用户手动删除」时刷新组锁状态:silent=true 的批量删除是 submitUploadFiles
|
||||||
|
// 的中间步骤,此时并发刷新可能在上传完成后才返回旧快照,把面板刷回陈旧结论。
|
||||||
|
if (!silent) {
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
fetchUploadStatus()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -526,6 +638,31 @@ async function fetchUploadedWorks() {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取小组提交状态(R190)。
|
||||||
|
* 🔴 失败**不阻断**上传面板:状态查询只是辅助展示,真正的上传校验在后端(6006)。
|
||||||
|
* 用 silent 抑制拦截器提示,避免辅助查询失败时给考生弹一条看不懂的报错。
|
||||||
|
*/
|
||||||
|
async function fetchUploadStatus() {
|
||||||
|
try {
|
||||||
|
const d = (await uploadApi.getUploadStatus(authStore.userGroup, { silent: true })) || {}
|
||||||
|
uploadStatus.value = {
|
||||||
|
submitted: !!d.submitted,
|
||||||
|
canUpload: d.canUpload !== false,
|
||||||
|
deadlineValid: d.deadlineValid !== false,
|
||||||
|
teamNumber: d.teamNumber || null,
|
||||||
|
folderName: d.folderName || '',
|
||||||
|
memberCount: Number(d.memberCount) || 1,
|
||||||
|
teamSubmitted: !!d.teamSubmitted,
|
||||||
|
teamOwnerName: d.teamOwnerName || null,
|
||||||
|
isTeamOwner: !!d.isTeamOwner,
|
||||||
|
teamLocked: !!d.teamLocked,
|
||||||
|
teamLockMessage: d.teamLockMessage || null,
|
||||||
|
teamFiles: Array.isArray(d.teamFiles) ? d.teamFiles : [],
|
||||||
|
}
|
||||||
|
} catch (e) { /* 辅助状态查询失败保持默认(不锁),真正拦截在后端 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 状态 ====================
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -936,7 +1073,13 @@ function preventKeyboard(e) {
|
|||||||
|
|
||||||
// 路由离开警告
|
// 路由离开警告
|
||||||
onBeforeRouteLeave((to, from, next) => {
|
onBeforeRouteLeave((to, from, next) => {
|
||||||
if (examStore.examStarted && !examStore.isTimeUp) {
|
// 🔴 组锁用户不弹:上传控件 disabled、无交卷动作 ⇒ 本机没有任何可丢的内容。
|
||||||
|
// 对这类用户弹「离开将丢失提交进度」是假警告(体验反馈:被锁成员被困在提交页)。
|
||||||
|
// 判定写成「上传模式 && teamLocked」而不是只看 teamLocked:后者当前虽等价
|
||||||
|
// (客观题模式不拉 uploadStatus),但语义上把考试组也纳入了豁免面,将来一旦
|
||||||
|
// 考试组也拉 status,就会静默取消「交卷前离开」的确认 —— 这是不可接受的放松。
|
||||||
|
const lockedNoContent = isUploadMode.value && teamLocked.value
|
||||||
|
if (examStore.examStarted && !examStore.isTimeUp && !lockedNoContent) {
|
||||||
const verb = isUploadMode.value ? '提交' : '答题'
|
const verb = isUploadMode.value ? '提交' : '答题'
|
||||||
ElMessageBox.confirm(
|
ElMessageBox.confirm(
|
||||||
`离开页面将丢失当前${verb}进度,确定要离开吗?`,
|
`离开页面将丢失当前${verb}进度,确定要离开吗?`,
|
||||||
@@ -1078,7 +1221,8 @@ onMounted(async () => {
|
|||||||
// 提交组(本科/教师):无客观题,进入「答案文件上传」模式并启动提交倒计时
|
// 提交组(本科/教师):无客观题,进入「答案文件上传」模式并启动提交倒计时
|
||||||
uploadPanelLoading.value = true
|
uploadPanelLoading.value = true
|
||||||
try {
|
try {
|
||||||
await fetchUploadedWorks()
|
// 两个查询互不依赖,并行发起:作品列表 + 组锁状态(R190)
|
||||||
|
await Promise.all([fetchUploadedWorks(), fetchUploadStatus()])
|
||||||
} finally {
|
} finally {
|
||||||
uploadPanelLoading.value = false
|
uploadPanelLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -1483,6 +1627,81 @@ onUnmounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== R192:上传页出口(组锁 / 主动放弃提交) ==================== */
|
||||||
|
/* 本页无全局导航(App.vue 只有 router-view)⇒ 出口按钮必须常驻,否则被组锁的
|
||||||
|
成员只能靠浏览器后退离开(移动端/内嵌浏览器常常退不出去)。 */
|
||||||
|
.upload-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.team-locked-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.team-locked-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== R190:小组作品状态面板 ==================== */
|
||||||
|
.team-status-panel {
|
||||||
|
border: 1px solid #c6e2ff;
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.team-status-panel.is-locked {
|
||||||
|
border-color: #f5dab1;
|
||||||
|
background: #fdf6ec;
|
||||||
|
}
|
||||||
|
.team-status-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.team-status-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.team-status-panel.is-locked .team-status-title {
|
||||||
|
color: #b88230;
|
||||||
|
}
|
||||||
|
.team-status-msg {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
.team-status-files {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.team-status-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.team-status-file + .team-status-file {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.team-status-uploader {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* 超出上传大小限制:明显的高亮警告样式(Issue ②) */
|
/* 超出上传大小限制:明显的高亮警告样式(Issue ②) */
|
||||||
.file-over-limit {
|
.file-over-limit {
|
||||||
background: #fef0f0;
|
background: #fef0f0;
|
||||||
|
|||||||
Reference in New Issue
Block a user