Codex Assistant
昨天 58d9f460b2f8c34430285115e2557d18333c5cab
web/src/views/review-detail.vue
@@ -14,12 +14,15 @@
          <div class="project-section">
            <!-- 项目基本信息 -->
            <h4>项目信息</h4>
            <el-descriptions :column="2" border>
              <el-descriptions-item label="项目名称">
                {{ projectDetail.projectName || '未填写' }}
            <el-descriptions :column="2" border class="project-info">
              <el-descriptions-item label="比赛名称" :span="2">
                {{ competitionName || '未填写' }}
              </el-descriptions-item>
              <el-descriptions-item label="比赛名称">
                {{ projectDetail.activityName }}
              <el-descriptions-item label="比赛阶段" :span="2">
                {{ stageName || projectDetail.activityName || '未填写' }}
              </el-descriptions-item>
              <el-descriptions-item label="参赛项目名称" :span="2">
                {{ projectDetail.projectName || '未填写' }}
              </el-descriptions-item>
              <el-descriptions-item label="项目描述" :span="2">
                <div class="description-content">
@@ -118,6 +121,7 @@
                  :step="0.5"
                  size="small"
                  style="width: 100%; margin-top: 8px;"
                  :disabled="!canModifyRating"
                />
              </div>
@@ -128,17 +132,29 @@
                  v-model="ratingComment"
                  type="textarea"
                  :rows="4"
                  placeholder="请输入评语(可选)"
                  :placeholder="canModifyRating ? '请输入评语(可选)' : '评语(只读)'"
                  maxlength="500"
                  show-word-limit
                  :disabled="!canModifyRating"
                />
              </div>
              <!-- 提交按钮 -->
              <div class="submit-section">
              <div class="submit-section" v-if="canModifyRating">
                <el-button type="primary" @click="handleSubmitRating" :loading="submitting" style="width: 100%;">
                  提交评分
                </el-button>
              </div>
              <!-- Employee用户提示 -->
              <div class="readonly-notice" v-if="isEmployee && !canModifyRating">
                <el-alert
                  title="只读模式"
                  description="您以员工身份查看此评审详情,只能查看不能修改评分"
                  type="info"
                  :closable="false"
                  show-icon
                />
              </div>
            </div>
            <div v-else class="no-template">
@@ -152,13 +168,17 @@
    <!-- 文件预览对话框 -->
    <el-dialog v-model="previewVisible" title="文件预览" width="80%" center>
      <div class="preview-content">
        <iframe
          v-if="previewUrl"
          :src="previewUrl"
          style="width: 100%; height: 500px; border: none;"
        ></iframe>
        <!-- 图片预览 -->
        <img v-if="previewType === 'image' && previewUrl" :src="previewUrl" style="max-width: 100%; max-height: 70vh; object-fit: contain;" />
        <!-- 视频预览 -->
        <video v-else-if="previewType === 'video' && previewUrl" :src="previewUrl" controls style="width: 100%; max-height: 70vh;"></video>
        <!-- PDF 预览 -->
        <iframe v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" style="width: 100%; height: 70vh; border: none;"></iframe>
        <!-- DOCX 预览 -->
        <div v-else-if="previewType === 'docx'" ref="docxContainer" class="docx-preview"></div>
        <!-- 其它不支持 -->
        <div v-else class="preview-error">
          <el-empty description="无法预览此文件类型" />
          <el-empty description="无法预览此文件类型,请下载查看" />
        </div>
      </div>
    </el-dialog>
@@ -170,7 +190,9 @@
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Document, UserFilled } from '@element-plus/icons-vue'
import { getProjectDetail, getRatingStats, submitRating } from '@/api/projectReview'
import { getProjectDetail, getRatingStats, submitRating, getCurrentJudgeRating, getActiveActivities } from '@/api/projectReview'
import { userApi } from '@/api/user'
import { getUserInfo } from '@/utils/auth'
const route = useRoute()
const router = useRouter()
@@ -180,13 +202,157 @@
const submitting = ref(false)
const projectDetail = ref(null)
const ratingStats = ref({ ratingCount: 0, averageScore: 0 })
const competitionName = ref('')
const stageName = ref('')
const ratingItems = ref([])
const ratingComment = ref('')
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref('') // image | video | pdf | docx | unknown
const docxContainer = ref(null)
// 权限验证相关
const currentJudge = ref(null)
const hasJudgePermission = ref(false)
const isJudgeInActivity = ref(false)
const permissionChecked = ref(false)
const existingRating = ref(null)
const isEmployee = ref(false)
const canModifyRating = ref(false)
// 计算属性
const projectId = computed(() => route.params.id)
const stageId = computed(() => route.query.stageId || (projectDetail.value ? projectDetail.value.stageId : null))
const loadStageMeta = async () => {
  try {
    if (!projectDetail.value || !projectDetail.value.stageId) return
    const stages = await getActiveActivities()
    const stage = (stages || []).find(s => String(s.id) === String(projectDetail.value.stageId))
    if (stage) {
      stageName.value = stage.name || ''
      competitionName.value = stage.parent?.name || ''
    } else {
      stageName.value = projectDetail.value.activityName || ''
      competitionName.value = ''
    }
  } catch (e) {
    stageName.value = projectDetail.value?.activityName || ''
    competitionName.value = ''
  }
}
// 权限验证方法
const checkPermissions = async () => {
  try {
    // 获取当前用户信息
    const userInfo = getUserInfo()
    if (!userInfo) {
      ElMessage.error('用户信息获取失败,请重新登录')
      router.push('/project-review')
      return false
    }
    // 设置员工身份标识
    isEmployee.value = !!userInfo.employee
    // 优先检查评委身份和权限(即使用户同时有员工身份)
    const judgeInfo = await userApi.getCurrentJudgeInfo()
    if (judgeInfo) {
      currentJudge.value = judgeInfo
      hasJudgePermission.value = true
      // 检查是否在当前比赛阶段的评委列表中
      if (projectDetail.value && projectDetail.value.stageId) {
        const isInActivity = await userApi.checkJudgeInActivity(
          projectDetail.value.stageId,
          judgeInfo.judgeId
        )
        if (isInActivity) {
          isJudgeInActivity.value = true
          canModifyRating.value = true // 有评委权限,可以修改评分
          permissionChecked.value = true
          if (isEmployee.value) {
            ElMessage.success('您同时拥有员工和评委身份,当前以评委身份进行评审')
          }
          return true
        } else {
          isJudgeInActivity.value = false
          // 如果没有当前比赛的评委权限,但有员工身份,则以员工身份查看
          if (isEmployee.value) {
            canModifyRating.value = false
            permissionChecked.value = true
            ElMessage.info('您没有当前比赛的评委权限,以员工身份查看评审详情')
            return true
          } else {
            ElMessage.error('您不是当前比赛的评委,无法进行评审')
            router.push('/project-review')
            return false
          }
        }
      }
    }
    // 如果没有评委身份,但有员工身份,则以员工身份查看
    if (isEmployee.value) {
      hasJudgePermission.value = false
      canModifyRating.value = false // employee只能查看,不能修改
      permissionChecked.value = true
      ElMessage.info('您以员工身份查看评审详情,只能查看不能修改评分')
      return true
    }
    // 既没有评委身份也没有员工身份
    hasJudgePermission.value = false
    ElMessage.error('您没有评委权限,无法进行评审')
    router.push('/project-review')
    return false
  } catch (error) {
    console.error('权限验证失败:', error)
    hasJudgePermission.value = false
    ElMessage.error('权限验证失败,请重新登录')
    router.push('/project-review')
    return false
  }
}
// 加载当前评委已有的评审数据
const loadExistingRating = async () => {
  // 只有具有评委权限且可以修改评分的用户才需要加载评委的评分数据
  if (!hasJudgePermission.value || !canModifyRating.value) return
  try {
    const rating = await getCurrentJudgeRating(parseInt(projectId.value))
    if (rating) {
      existingRating.value = rating
      // 如果有已有评分,填充到表单中
      if (rating.items && rating.items.length > 0) {
        ratingItems.value = rating.items.map(item => ({
          id: item.ratingItemId,
          name: item.ratingItemName,
          score: item.score,
          maxScore: item.maxScore || 100
        }))
      }
      // 填充评语
      if (rating.remark) {
        ratingComment.value = rating.remark
      }
      ElMessage.success('已加载您之前的评分数据,可以继续编辑')
    }
  } catch (error) {
    console.error('加载已有评分失败:', error)
    // 不显示错误消息,因为可能是第一次评分
  }
}
// 加载项目详情
const loadProjectDetail = async () => {
@@ -194,6 +360,7 @@
  try {
    const data = await getProjectDetail(projectId.value)
    projectDetail.value = data
    await loadStageMeta()
    
    // 初始化评分项
    if (data.ratingForm && data.ratingForm.items) {
@@ -202,6 +369,14 @@
        score: 0
      }))
    }
    // 项目详情加载完成后,进行权限验证
    const hasPermission = await checkPermissions()
    if (hasPermission) {
      // 权限验证通过后,加载已有评分数据
      await loadExistingRating()
    }
  } catch (error) {
    ElMessage.error('加载项目详情失败')
    console.error(error)
@@ -222,6 +397,19 @@
// 提交评分
const handleSubmitRating = async () => {
  // 权限检查:employee用户不能提交评分
  if (!canModifyRating.value) {
    ElMessage.error('您没有权限提交评分')
    return
  }
  // 统一获取stageId(优先路由参数,其次详情里的stageId)
  const sid = stageId.value ? parseInt(stageId.value) : (projectDetail.value?.stageId ? parseInt(projectDetail.value.stageId) : null)
  if (!sid) {
    ElMessage.error('缺少比赛阶段信息,请重新进入页面')
    return
  }
  // 验证评分
  const hasEmptyScore = ratingItems.value.some(item => item.score === 0 || item.score === null)
  if (hasEmptyScore) {
@@ -239,7 +427,8 @@
    submitting.value = true
    
    const ratingData = {
      activityPlayerId: projectId.value,
      activityPlayerId: parseInt(projectId.value),
      stageId: sid,
      ratings: ratingItems.value.map(item => ({
        itemId: item.id,
        score: item.score
@@ -263,17 +452,84 @@
  }
}
// 文件预览
const previewFile = (file) => {
  // 根据文件类型决定预览方式
  const fileExtension = file.name.split('.').pop().toLowerCase()
  const previewableTypes = ['pdf', 'txt', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm']
  if (previewableTypes.includes(fileExtension)) {
    // 在新窗口中打开预览
    window.open(file.url, '_blank')
  } else {
    ElMessage.warning('此文件类型不支持预览,请下载查看')
/**
 * 文件预览:按扩展名分发到图片/视频/PDF/DOCX
 */
const previewFile = async (file) => {
  const ext = (file.name?.split('.').pop() || '').toLowerCase()
  previewVisible.value = true
  previewUrl.value = ''
  previewType.value = ''
  // 统一取可用的完整 URL
  const url = file.url || file.fullUrl || file.full_path || file.path
  const imageExts = ['jpg','jpeg','png','gif','bmp','webp']
  const videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','mkv']
  if (imageExts.includes(ext)) {
    previewType.value = 'image'
    previewUrl.value = url
    return
  }
  if (videoExts.includes(ext)) {
    previewType.value = 'video'
    previewUrl.value = url
    return
  }
  if (ext === 'pdf') {
    previewType.value = 'pdf'
    previewUrl.value = url
    return
  }
  if (ext === 'docx') {
    previewType.value = 'docx'
    try {
      await renderDocx(url)
    } catch (e) {
      console.error('DOCX 预览失败:', e)
      ElMessage.warning('DOCX 预览失败,建议下载查看')
      previewType.value = 'unknown'
    }
    return
  }
  if (ext === 'doc') {
    ElMessage.info('暂不支持 .doc 预览,请下载查看')
    previewType.value = 'unknown'
    return
  }
  ElMessage.warning('此文件类型不支持预览,请下载查看')
  previewType.value = 'unknown'
}
/**
 * 动态加载 docx-preview 并渲染 DOCX
 */
const renderDocx = async (url) => {
  const ensureScript = () => new Promise((resolve, reject) => {
    if (window.docx && window.docx.renderAsync) return resolve(true)
    const existed = document.querySelector('script[data-docx-preview]')
    if (existed) {
      existed.addEventListener('load', () => resolve(true))
      existed.addEventListener('error', reject)
      return
    }
    const s = document.createElement('script')
    s.src = 'https://unpkg.com/docx-preview/dist/docx-preview.min.js'
    s.async = true
    s.setAttribute('data-docx-preview', '1')
    s.onload = () => resolve(true)
    s.onerror = reject
    document.head.appendChild(s)
  })
  await ensureScript()
  const res = await fetch(url, { credentials: 'include' })
  if (!res.ok) throw new Error('获取 DOCX 失败: ' + res.status)
  const blob = await res.blob()
  if (docxContainer.value) {
    docxContainer.value.innerHTML = ''
    await window.docx.renderAsync(blob, docxContainer.value, null, { inWrapper: true })
  }
}
@@ -360,6 +616,7 @@
.description-content {
  line-height: 1.6;
  color: #606266;
  white-space: pre-wrap;
}
.attachments {
@@ -515,6 +772,13 @@
.preview-content {
  text-align: center;
}
.docx-preview {
  text-align: left;
  max-height: 70vh;
  overflow: auto;
  background: #fff;
  padding: 12px;
}
.preview-error {
  padding: 40px 0;
@@ -527,4 +791,25 @@
:deep(.el-card__body) {
  padding: 16px;
}
/* 仅针对项目信息这组描述设置标签/内容宽度比例 */
.project-info :deep(.el-descriptions__label) {
  width: 40% !important;
  min-width: 40%;
  box-sizing: border-box;
}
.project-info :deep(.el-descriptions__content) {
  width: 60% !important;
  min-width: 60%;
  box-sizing: border-box;
}
/* 窄屏自适应:小屏时回退为上下结构 */
@media (max-width: 768px) {
  .project-info :deep(.el-descriptions__label),
  .project-info :deep(.el-descriptions__content) {
    width: 100% !important;
    min-width: 100%;
  }
}
</style>