| | |
| | | <div class="card-header"> |
| | | <span>比赛详情</span> |
| | | <div> |
| | | <el-button v-if="canExport" type="primary" :loading="exportingActivity" @click="handleExportActivity">导出评审ZIP</el-button> |
| | | <el-button @click="handleEdit">编辑比赛</el-button> |
| | | <el-button @click="goBack">返回</el-button> |
| | | </div> |
| | |
| | | <el-descriptions-item label="比赛地址">{{ activity.address || '-' }}</el-descriptions-item> |
| | | <el-descriptions-item label="评分模板"> |
| | | {{ activity.ratingScheme ? activity.ratingScheme.name : '-' }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="最近评审导出" :span="2"> |
| | | <template v-if="activity.reviewExportUrl"> |
| | | <a :href="activity.reviewExportUrl" target="_blank">点击下载</a> |
| | | </template> |
| | | <template v-else> |
| | | 暂无 |
| | | </template> |
| | | <span v-if="exportJobActivity && exportingActivity" class="export-status"> |
| | | 导出任务进行中 |
| | | <span v-if="exportJobActivity.progress !== null && exportJobActivity.progress !== undefined">({{ exportJobActivity.progress }}%)</span> |
| | | </span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="创建时间" :span="2">{{ formatDateTime(activity.createTime) }}</el-descriptions-item> |
| | | </el-descriptions> |
| | |
| | | <el-tag :type="getStateType(row.state)">{{ row.stateName }}</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="220" fixed="right"> |
| | | <el-table-column label="最近导出" width="200"> |
| | | <template #default="{ row }"> |
| | | <template v-if="row.reviewExportUrl"> |
| | | <a :href="row.reviewExportUrl" target="_blank">下载ZIP</a> |
| | | </template> |
| | | <template v-else> |
| | | <span style="color: #909399;">暂无</span> |
| | | </template> |
| | | <div v-if="exportJobStageMap[row.id] && exportingStageId === row.id" class="export-status"> |
| | | 导出任务进行中 |
| | | <span v-if="exportJobStageMap[row.id].progress !== null && exportJobStageMap[row.id].progress !== undefined">({{ exportJobStageMap[row.id].progress }}%)</span> |
| | | </div> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="320" fixed="right"> |
| | | <template #default="{ row }"> |
| | | <el-button size="small" @click="viewStageDetail(row)">查看详情</el-button> |
| | | <el-button size="small" type="warning" @click="closeStage(row)" v-if="row.state === 1">关闭</el-button> |
| | | <el-button size="small" type="danger" @click="deleteStage(row)">删除</el-button> |
| | | <el-button |
| | | v-if="canExport" |
| | | size="small" |
| | | type="primary" |
| | | :loading="exportingStageId === row.id" |
| | | @click="handleExportStage(row)" |
| | | >导出阶段评审</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, onMounted, computed } from 'vue' |
| | | import { ref, onMounted, onUnmounted, computed } from 'vue' |
| | | import { useRouter, useRoute } from 'vue-router' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { getActivity } from '@/api/activity' |
| | | import { exportReviewZip, startReviewExportJob, getReviewExportJobStatus } from '@/api/reviewExport' |
| | | import { isEmployee } from '@/utils/auth' |
| | | |
| | | const router = useRouter() |
| | | const route = useRoute() |
| | |
| | | const activity = ref(null) |
| | | const stageDialogVisible = ref(false) |
| | | const selectedStage = ref(null) |
| | | const exportingActivity = ref(false) |
| | | const exportingStageId = ref(null) |
| | | // 异步导出任务状态 |
| | | const exportJobActivity = ref(null) // { jobId, status, progress, message } |
| | | const exportJobStageMap = ref({}) // { [stageId]: { jobId, status, progress, message } } |
| | | let activityPollTimer = null |
| | | const stagePollTimers = {} |
| | | |
| | | // 计算属性 |
| | | const sortedStages = computed(() => { |
| | |
| | | onMounted(() => { |
| | | loadActivity() |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | if (activityPollTimer) { |
| | | clearInterval(activityPollTimer) |
| | | activityPollTimer = null |
| | | } |
| | | Object.keys(stagePollTimers).forEach((sid) => { |
| | | if (stagePollTimers[sid]) { |
| | | clearInterval(stagePollTimers[sid]) |
| | | delete stagePollTimers[sid] |
| | | } |
| | | }) |
| | | }) |
| | | |
| | | // 是否可执行导出(仅员工) |
| | | const canExport = computed(() => isEmployee()) |
| | | |
| | | // 导出主活动评审ZIP |
| | | const handleExportActivity = async () => { |
| | | try { |
| | | await ElMessageBox.confirm('将导出该比赛下所有阶段的已评分数据为ZIP,确认继续?', '确认导出', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | }) |
| | | exportingActivity.value = true |
| | | // 优先使用异步导出任务 |
| | | const jobId = await startReviewExportJob(route.params.id) |
| | | if (jobId) { |
| | | exportJobActivity.value = { jobId, status: 'PENDING', progress: 0 } |
| | | // 轮询任务状态 |
| | | activityPollTimer = setInterval(async () => { |
| | | try { |
| | | const status = await getReviewExportJobStatus(jobId) |
| | | if (status) { |
| | | exportJobActivity.value = status |
| | | if (status.status === 'SUCCEEDED') { |
| | | clearInterval(activityPollTimer) |
| | | activityPollTimer = null |
| | | ElMessage.success('导出成功') |
| | | await loadActivity() |
| | | exportingActivity.value = false |
| | | } else if (status.status === 'FAILED') { |
| | | clearInterval(activityPollTimer) |
| | | activityPollTimer = null |
| | | ElMessage.error(status.message || '导出失败') |
| | | exportingActivity.value = false |
| | | } |
| | | } |
| | | } catch (e) { |
| | | console.warn('查询导出任务状态失败:', e?.message || e) |
| | | } |
| | | }, 2000) |
| | | } else { |
| | | // 回退:使用同步导出 |
| | | const res = await exportReviewZip(route.params.id) |
| | | if (res?.success) { |
| | | ElMessage.success('导出成功') |
| | | await loadActivity() |
| | | } else { |
| | | ElMessage.error(res?.message || '导出失败') |
| | | } |
| | | exportingActivity.value = false |
| | | } |
| | | } catch (e) { |
| | | // 用户取消或异常 |
| | | if (e && e.message) { |
| | | console.warn('导出操作取消或失败:', e.message) |
| | | } |
| | | } finally { |
| | | // 异步导出时由轮询回调负责关闭 loading;此处仅在未启动任务时重置 |
| | | if (!activityPollTimer) { |
| | | exportingActivity.value = false |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 导出指定阶段评审ZIP |
| | | const handleExportStage = async (stage) => { |
| | | try { |
| | | await ElMessageBox.confirm(`将导出【${stage.name}】阶段的已评分数据为ZIP,确认继续?`, '确认导出', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | }) |
| | | exportingStageId.value = stage.id |
| | | const jobId = await startReviewExportJob(route.params.id, [stage.id]) |
| | | if (jobId) { |
| | | exportJobStageMap.value[stage.id] = { jobId, status: 'PENDING', progress: 0 } |
| | | // 轮询阶段导出状态 |
| | | stagePollTimers[stage.id] = setInterval(async () => { |
| | | try { |
| | | const status = await getReviewExportJobStatus(jobId) |
| | | if (status) { |
| | | exportJobStageMap.value[stage.id] = status |
| | | if (status.status === 'SUCCEEDED') { |
| | | clearInterval(stagePollTimers[stage.id]) |
| | | delete stagePollTimers[stage.id] |
| | | ElMessage.success('导出成功') |
| | | await loadActivity() |
| | | exportingStageId.value = null |
| | | } else if (status.status === 'FAILED') { |
| | | clearInterval(stagePollTimers[stage.id]) |
| | | delete stagePollTimers[stage.id] |
| | | ElMessage.error(status.message || '导出失败') |
| | | exportingStageId.value = null |
| | | } |
| | | } |
| | | } catch (e) { |
| | | console.warn('查询阶段导出任务状态失败:', e?.message || e) |
| | | } |
| | | }, 2000) |
| | | } else { |
| | | // 回退:同步导出 |
| | | const res = await exportReviewZip(route.params.id, [stage.id]) |
| | | if (res?.success) { |
| | | ElMessage.success('导出成功') |
| | | await loadActivity() |
| | | } else { |
| | | ElMessage.error(res?.message || '导出失败') |
| | | } |
| | | exportingStageId.value = null |
| | | } |
| | | } catch (e) { |
| | | if (e && e.message) { |
| | | console.warn('导出阶段操作取消或失败:', e.message) |
| | | } |
| | | } finally { |
| | | if (!stagePollTimers[stage.id]) { |
| | | exportingStageId.value = null |
| | | } |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |