peng
2025-11-06 c4938f6f4e839890b032c75c7a57333a6a9157a9
web/src/views/ActivityDetail.vue
@@ -5,6 +5,7 @@
        <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>
@@ -23,6 +24,18 @@
          <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>
@@ -54,11 +67,32 @@
                <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>
@@ -125,10 +159,12 @@
</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()
@@ -138,6 +174,13 @@
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(() => {
@@ -248,6 +291,139 @@
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>