chore(frontend): 移除孤儿路由 /upload 与 Upload.vue(BACKLOG B2)

定性:/upload 是全仓零 UI 入口的孤儿路由。
- Dashboard.enterContest() 对所有组别统一 router.push('/competition');
  全仓 push('/upload') / replace('/upload') / to="/upload" / name:'Upload' 0 命中,
  Upload.vue 只被 router 一处 import。
- 本科/教师的答案文件上传链路早已由 Competition.vue 的 exam-upload-mode 完整承担
  (zip 独占 / pdf-docx 互斥 / 组合校验 / 覆盖式重传 / 删除)。
- 该页对中职/高职会调用后端不支持的分组接口
  (GET /api/upload/{zhongzhi,gaozhi}/status|submissions → 「无效的组别,仅支持 benke/jiaoshi」),
  因守卫只弹 warning 且注释明写"不强制跳转",随后仍无条件发请求 ⇒ 3 条 console 报错。

处置(三方案中的 ①;②接线会使 Competition.vue 上传模式被孤立,属功能倒退;③被①涵盖):
- src/router/index.js: 删 import Upload 与 /upload 路由块(-9 行)
- src/views/Upload.vue: git rm(-735 行)
- 保留 src/api/upload.js:stores/exam.js 与 Competition.vue 仍在用
  getUploadStatus / uploadWork / deleteWork / getSubmissions

验证:
- 前置:全项目(docs/deploy/nginx/bat/ps1/后端)零外部引用;Upload.vue 无具名导出
- vite build exit 0;dist 异步分片列表已无 Upload-*.js(残留 upload-*.js 是 MD5 库分片,0 页面文案)
- TDZ 扫描 37 文件 PASS(原 38,正好少一)
- 真浏览器:/login 9/9 · /upload 现落 404 页且 0 pageerror / 0 console error
  (修复前:渲染旧上传页 + 3 条 console 报错)
This commit is contained in:
2026-09-29 12:17:48 +08:00
parent 0416ac3360
commit ecf8d39f03
2 changed files with 0 additions and 744 deletions

View File

@@ -16,7 +16,6 @@ const Competition = () => import('@/views/Competition.vue')
const MockExam = () => import('@/views/MockExam.vue') const MockExam = () => import('@/views/MockExam.vue')
const NmeaParser = () => import('@/views/NmeaParser.vue') const NmeaParser = () => import('@/views/NmeaParser.vue')
const AtSimulator = () => import('@/views/AtSimulator.vue') const AtSimulator = () => import('@/views/AtSimulator.vue')
const Upload = () => import('@/views/Upload.vue')
const Materials = () => import('@/views/Materials.vue') const Materials = () => import('@/views/Materials.vue')
const Score = () => import('@/views/Score.vue') const Score = () => import('@/views/Score.vue')
@@ -106,14 +105,6 @@ const routes = [
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
// 作品上传页
{
path: '/upload',
name: 'Upload',
component: Upload,
meta: { requiresAuth: true },
},
// 资料下载页 // 资料下载页
{ {
path: '/materials', path: '/materials',

View File

@@ -1,735 +0,0 @@
<template>
<div class="upload-page">
<!-- 顶部导航 -->
<div class="upload-header">
<div class="upload-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="upload-title">作品提交</h2>
<span class="group-tag">{{ groupLabel }}</span>
</div>
<div class="upload-content">
<!-- 截止时间 -->
<div class="deadline-card" v-if="deadline">
<div class="deadline-info">
<el-icon :size="20"><Clock /></el-icon>
<span>提交截止时间:</span>
<strong :class="{ 'deadline-near': isDeadlineNear }">{{ formatDeadline(deadline) }}</strong>
</div>
<div class="deadline-countdown" v-if="deadlineCountdown > 0">
<span>剩余时间:</span>
<strong>{{ formatCountdown(deadlineCountdown) }}</strong>
</div>
</div>
<!-- 教师组双上传区 -->
<template v-if="isTeacher">
<el-row :gutter="24">
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">教学方案</h3>
<p class="upload-card-desc">请上传教学方案文件(PDF、Word 格式)</p>
<file-upload-area
group="jiaoshi"
file-type="word"
:max-size="100 * 1024 * 1024"
accept=".pdf,.doc,.docx"
@upload-success="onUploadSuccess('word', $event)"
/>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">说课 PPT</h3>
<p class="upload-card-desc">请上传说课 PPT 文件</p>
<file-upload-area
group="jiaoshi"
file-type="ppt"
:max-size="100 * 1024 * 1024"
accept=".ppt,.pptx"
@upload-success="onUploadSuccess('ppt', $event)"
/>
</div>
</el-col>
</el-row>
</template>
<!-- 本科组双上传区(Word + PPT) -->
<template v-else>
<el-row :gutter="24">
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">参赛作品(Word)</h3>
<p class="upload-card-desc">请上传参赛作品文档(PDF、Word 格式)</p>
<file-upload-area
:group="userGroup"
file-type="word"
:max-size="50 * 1024 * 1024"
accept=".pdf,.doc,.docx"
@upload-success="onUploadSuccess('word', $event)"
/>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">参赛作品(PPT)</h3>
<p class="upload-card-desc">请上传参赛作品演示文稿(PPT 格式)</p>
<file-upload-area
:group="userGroup"
file-type="ppt"
:max-size="50 * 1024 * 1024"
accept=".ppt,.pptx"
@upload-success="onUploadSuccess('ppt', $event)"
/>
</div>
</el-col>
</el-row>
</template>
<!-- 上传历史 -->
<div class="history-card">
<h3 class="history-title">提交记录</h3>
<div v-loading="historyLoading">
<el-table :data="submissionHistory" stripe empty-text="暂无提交记录">
<el-table-column prop="fileName" label="文件名" min-width="180" show-overflow-tooltip />
<el-table-column prop="fileSize" label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column prop="uploadTime" label="上传时间" width="170">
<template #default="{ row }">{{ formatDate(row.uploadTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button
text
type="danger"
size="small"
:loading="deletingId === row.id"
v-guard="() => deleteSubmission(row)"
>删除</el-button>
<el-button text type="primary" size="small" @click="reupload">重新上传</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
</template>
<script setup>
/**
* Upload.vue - 作品上传页面(需要登录,benke/jiaoshi 组)
*
* 功能:
* 1. 截止时间倒计时
* 2. 拖拽上传区域
* 3. 文件类型/大小校验
* 4. 分片上传 + 进度条
* 5. MD5 哈希显示
* 6. 上传历史列表
* 7. 教师组双上传区(教学方案 + 说课PPT)
*/
import { ref, computed, onMounted, onUnmounted, h } from 'vue'
import { ElMessage, ElMessageBox, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
import { ArrowLeft, HomeFilled, Clock, UploadFilled, Delete } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as uploadApi from '@/api/upload'
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
const authStore = useAuthStore()
// ==================== 计算属性 ====================
const userGroup = computed(() => authStore.userGroup)
const groupLabel = computed(() => {
const map = { benke: '本科组', jiaoshi: '教师组' }
return map[userGroup.value] || userGroup.value
})
const isTeacher = computed(() => userGroup.value === 'jiaoshi')
const isDeadlineNear = computed(() => {
if (!deadline.value) return false
const diff = new Date(deadline.value) - new Date()
return diff > 0 && diff < 3 * 24 * 60 * 60 * 1000
})
// ==================== 状态 ====================
const deadline = ref('')
const deadlineCountdown = ref(0)
const submissionHistory = ref([])
const historyLoading = ref(false)
const deletingId = ref(null)
let deadlineTimer = null
// ==================== 文件上传子组件(内联实现) ====================
// 使用简单的内联上传逻辑替代子组件
const uploadStates = reactive({})
// ==================== 方法 ====================
function formatDate(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatDeadline(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatSize(bytes) {
return formatFileSize(bytes || 0)
}
function formatCountdown(seconds) {
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (d > 0) return `${d}天 ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
/** 启动截止倒计时 */
function startDeadlineCountdown() {
clearInterval(deadlineTimer)
if (!deadline.value) return
deadlineTimer = setInterval(() => {
const diff = Math.floor((new Date(deadline.value) - new Date()) / 1000)
deadlineCountdown.value = Math.max(0, diff)
}, 1000)
}
/** 获取提交历史 */
async function fetchHistory() {
historyLoading.value = true
try {
const data = await uploadApi.getSubmissions(userGroup.value)
if (Array.isArray(data)) {
submissionHistory.value = data
} else if (data?.list) {
submissionHistory.value = data.list
}
} catch (err) {
console.warn('获取提交历史失败', err)
} finally {
historyLoading.value = false
}
}
/** 获取上传状态 */
async function fetchUploadStatus() {
try {
const data = await uploadApi.getUploadStatus(userGroup.value)
if (data?.deadline) {
deadline.value = data.deadline
startDeadlineCountdown()
}
} catch (err) {
console.warn('获取上传状态失败', err)
}
}
function onUploadSuccess(type, result) {
ElMessage.success('文件上传成功')
fetchHistory()
}
/** 删除已上传的作品文件(本科/教师组) */
async function deleteSubmission(row) {
try {
await ElMessageBox.confirm(
`确定要删除「${row.fileName}」吗?删除后可重新上传进行替换。`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
deletingId.value = row.id
try {
await uploadApi.deleteWork(userGroup.value, row.id)
ElMessage.success('文件已删除')
fetchHistory()
} catch (err) {
ElMessage.error('删除失败,请重试')
} finally {
deletingId.value = null
}
}
/** 滚动到上传区以便重新上传 */
function reupload() {
const el = document.querySelector('.upload-card')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
// ==================== 生命周期 ====================
onMounted(() => {
// 组别检查
if (!['benke', 'jiaoshi'].includes(authStore.userGroup)) {
ElMessage.warning('您所在的组别不需要提交作品')
// 不强制跳转,避免闪烁
}
fetchUploadStatus()
fetchHistory()
})
onUnmounted(() => {
if (deadlineTimer) clearInterval(deadlineTimer)
})
</script>
<!-- ==================== 文件上传子区域组件模板 ==================== -->
<script>
/**
* file-upload-area 是一个内联组件
* 为了保持 Vue SFC 的简洁性,我们用 defineComponent
*/
import { defineComponent, ref, reactive, h, computed } from 'vue'
import { ElMessage, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
import { UploadFilled, Delete } from '@element-plus/icons-vue'
import * as uploadApi from '@/api/upload'
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
const FileUploadArea = defineComponent({
name: 'FileUploadArea',
props: {
group: { type: String, required: true },
fileType: { type: String, default: 'work' },
maxSize: { type: Number, default: 50 * 1024 * 1024 },
accept: { type: String, default: '.pdf,.doc,.docx,.ppt,.pptx,.zip' },
},
emits: ['upload-success'],
setup(props, { emit }) {
const fileList = ref([])
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadedInfo = ref(null)
function validateFile(file) {
const raw = file.raw || file
if (raw.size > props.maxSize) {
const maxMB = props.maxSize / (1024 * 1024)
ElMessage.error(`文件大小不能超过 ${maxMB}MB`)
return false
}
return true
}
function handleFileChange(file) {
if (!validateFile(file)) {
fileList.value = []
return
}
fileList.value = [file]
uploadedInfo.value = null
// 选择文件后自动开始上传,提供即时反馈
startUpload()
}
function removeFile() {
if (uploading.value) {
ElMessage.warning('文件正在上传中,请等待完成后再操作')
return
}
fileList.value = []
uploadedInfo.value = null
uploadPercent.value = 0
}
async function startUpload() {
// 防重入:选择文件会自动触发一次上传,用户若再点「开始上传」会造成同一文件
// 双份分片流并发,既浪费连接池也可能让后端合并出错。
if (uploading.value) return
if (fileList.value.length === 0) {
ElMessage.warning('请先选择文件')
return
}
const rawFile = fileList.value[0]?.raw || fileList.value[0]
if (!rawFile) return
uploading.value = true
uploadPercent.value = 0
try {
// 计算 MD5 作为 fileId
let fileId = ''
try {
fileId = await calculateMd5(rawFile)
} catch (e) {
console.warn('MD5 计算失败', e)
fileId = rawFile.name + '_' + Date.now()
}
// 分片上传
const chunks = chunkFile(rawFile)
for (let i = 0; i < chunks.length; i++) {
const formData = new FormData()
// ⚠️ Egg stream 模式下 stream.fields 仅解析「出现在文件字段之前」的普通字段。
// 必须把文本字段放在 chunk(文件)之前,否则后端收不到 chunkIndex/fileId/fileName -> 400 缺参。
formData.append('chunkIndex', chunks[i].index)
formData.append('totalChunks', chunks[i].total)
formData.append('fileId', fileId)
formData.append('fileName', rawFile.name)
formData.append('chunk', chunks[i].chunk, rawFile.name)
await uploadWithRetry(
() => uploadApi.uploadChunk(props.group, formData),
3, 1000
)
uploadPercent.value = Math.round(((i + 1) / chunks.length) * 100)
}
// 所有分片上传完成,调用合并接口(透传 file_type:word/ppt)
await uploadApi.mergeChunks(props.group, {
fileId,
fileName: rawFile.name,
totalChunks: chunks.length,
work_name: rawFile.name,
file_type: props.fileType,
})
uploadedInfo.value = {
fileName: rawFile.name,
fileSize: rawFile.size,
fileId,
}
ElMessage.success('上传成功')
emit('upload-success', { type: props.fileType, info: uploadedInfo.value })
} catch (err) {
ElMessage.error('上传失败,请重试')
} finally {
uploading.value = false
}
}
return () => {
return h('div', { class: 'file-upload-wrapper' }, [
// 拖拽上传区
!uploadedInfo.value ? [
h('div', {
class: 'drag-area',
onClick: () => {
// 触发文件选择
},
}, [
h('input', {
type: 'file',
accept: props.accept,
style: { display: 'none' },
ref: 'fileInput',
onChange: (e) => {
const file = e.target.files?.[0]
if (file) {
handleFileChange({ raw: file, name: file.name, size: file.size })
}
e.target.value = ''
},
}),
h('div', {
class: 'drag-content',
onClick: (e) => {
// 点击时触发隐藏的 file input
e.currentTarget.previousElementSibling?.click()
},
}, [
h(ElIcon, { size: 48 }, () => h(UploadFilled)),
h('div', { class: 'drag-text' }, '拖拽文件到此处或 '),
h('em', null, '点击上传'),
h('div', { class: 'drag-hint' },
`支持 ${props.accept.replace(/\./g, '')} 格式,最大 ${props.maxSize / (1024 * 1024)}MB`
),
]),
]),
// 已选文件
fileList.value.length > 0 ? h('div', { class: 'selected-file' }, [
h('span', { class: 'selected-name' }, fileList.value[0].name || fileList.value[0].fileName),
h(ElButton, {
type: 'danger',
size: 'small',
icon: h(Delete),
circle: true,
onClick: removeFile,
}),
]) : null,
// 上传按钮
fileList.value.length > 0 ? h('div', { class: 'upload-actions' }, [
h(ElButton, {
type: 'primary',
loading: uploading.value,
onClick: startUpload,
}, { default: () => '开始上传' }),
]) : null,
] : null,
// 上传进度
uploading.value ? h('div', { class: 'upload-progress' }, [
h(ElProgress, {
percentage: uploadPercent.value,
status: uploadPercent.value >= 100 ? 'success' : undefined,
}),
]) : null,
// 已上传文件信息
uploadedInfo.value ? h('div', { class: 'uploaded-card' }, [
h(ElTag, { type: 'success', size: 'small' }, () => '上传成功'),
h('div', { class: 'uploaded-name' }, uploadedInfo.value.fileName),
h('div', { class: 'uploaded-meta' }, [
h('span', null, formatFileSize(uploadedInfo.value.fileSize)),
uploadedInfo.value.md5 ? h('span', null, `MD5: ${uploadedInfo.value.md5.slice(0, 16)}...`) : null,
].filter(Boolean)),
h(ElButton, {
text: true,
type: 'danger',
size: 'small',
onClick: removeFile,
}, { default: () => '重新上传' }),
]) : null,
])
}
},
})
export default {
components: { FileUploadArea },
}
</script>
<style scoped>
.upload-page {
min-height: 100vh;
background: #f5f6f8;
padding-bottom: 40px;
}
/* ==================== 顶部导航 ==================== */
.upload-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn { color: rgba(255,255,255,0.85); white-space: nowrap; }
.back-btn:hover { color: #c9a84c; }
.upload-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.upload-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.group-tag {
background: rgba(201,168,76,0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
/* ==================== 内容 ==================== */
.upload-content {
max-width: 1000px;
margin: 24px auto 0;
padding: 0 20px;
}
/* ==================== 截止时间 ==================== */
.deadline-card {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.deadline-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
color: #6b7280;
}
.deadline-near { color: #f56c6c; }
.deadline-countdown {
font-size: 18px;
color: #1a3a5c;
}
/* ==================== 上传卡片 ==================== */
.upload-card {
background: #fff;
border-radius: 12px;
padding: 28px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.upload-card-title {
font-size: 18px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 8px;
}
.upload-card-desc {
font-size: 14px;
color: #6b7280;
margin-bottom: 24px;
}
/* ==================== 拖拽上传区 ==================== */
.drag-area {
border: 2px dashed #d1d5db;
border-radius: 12px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #fafafa;
}
.drag-area:hover {
border-color: #1a3a5c;
background: #f0f4f8;
}
.drag-content {
color: #6b7280;
}
.drag-text {
font-size: 14px;
margin-top: 12px;
}
.drag-hint {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
/* ==================== 已选文件 ==================== */
.selected-file {
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4f8;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
margin-top: 12px;
}
.selected-name {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
.upload-actions {
margin-top: 12px;
text-align: center;
}
.upload-progress {
margin-top: 16px;
}
/* ==================== 上传成功信息 ==================== */
.uploaded-card {
border: 1px solid #bbf7d0;
background: #f0fdf4;
border-radius: 10px;
padding: 20px;
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.uploaded-name {
font-size: 14px;
font-weight: 600;
color: #1f2937;
flex: 1;
min-width: 200px;
word-break: break-all;
}
.uploaded-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #6b7280;
width: 100%;
}
/* ==================== 历史记录 ==================== */
.history-card {
background: #fff;
border-radius: 12px;
padding: 28px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.history-title {
font-size: 18px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 16px;
}
/* ==================== Responsive ==================== */
@media (max-width: 480px) {
.upload-header { padding: 0 12px; }
.deadline-card { flex-direction: column; }
}
</style>