feat(review): 调整评审详情展示顺序与样式,描述支持多行,项目信息列宽40/60
fix(auth): 登录页与首页循环跳转保护;api.ts 在登录页不再重定向;401分支在登录页不跳转
fix(router): /login 放行策略优化,避免死循环;评审列表跳转到 /project-review/:id/detail
fix(frontend): 补齐 utils/appConfig.ts,避免启动白屏
fix(review): 详情页提交评分缺少stageId时回退使用项目详情的stageId
feat(backend): ActivityPlayerDetailResponse.playerInfo 补充 avatarUrl/avatar,服务组装时填充用户头像
chore(dev): 启动脚本注入本地JWT密钥,重启前后端
8个文件已修改
1个文件已添加
201 ■■■■ 已修改文件
backend/src/main/java/com/rongyichuang/player/dto/response/PlayerInfoResponse.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/service/ActivityPlayerDetailService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/config/api.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/router/index.ts 13 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/utils/appConfig.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/utils/auth.ts 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/views/review-detail.vue 69 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/views/review-list.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/dto/response/PlayerInfoResponse.java
@@ -14,6 +14,8 @@
    private String education; // 学历
    private String introduction; // 个人介绍
    private String description; // 简介
    private String avatarUrl; // 头像URL(直接用于前端展示)
    private MediaResponse avatar; // 头像媒体详情
    private PlayerUserInfoResponse userInfo; // 关联的用户信息,包含头像
    // Constructors
@@ -44,6 +46,12 @@
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public String getAvatarUrl() { return avatarUrl; }
    public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
    public MediaResponse getAvatar() { return avatar; }
    public void setAvatar(MediaResponse avatar) { this.avatar = avatar; }
    public PlayerUserInfoResponse getUserInfo() { return userInfo; }
    public void setUserInfo(PlayerUserInfoResponse userInfo) { this.userInfo = userInfo; }
}
backend/src/main/java/com/rongyichuang/player/service/ActivityPlayerDetailService.java
@@ -205,6 +205,9 @@
                String avatarUrl = buildFullMediaUrl(avatar.getPath());
                userInfo.setAvatarUrl(avatarUrl);
                userInfo.setAvatar(convertToMediaResponse(avatar));
                // 同步赋值到 playerInfo,便于前端直接使用 playerInfo.avatarUrl
                playerInfo.setAvatarUrl(avatarUrl);
                playerInfo.setAvatar(convertToMediaResponse(avatar));
                log.info("找到用户头像: {}", avatarUrl);
            } else {
                log.info("调试:未找到用户头像,userId: {}, targetType: {}", userId, MediaTargetType.USER_AVATAR.getValue());
backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java
@@ -83,9 +83,7 @@
        if (activityId != null) {
            q.setParameter("activityId", activityId);
        }
        if (regionId != null) {
            q.setParameter("regionId", regionId);
        }
        if (state != null) {
            q.setParameter("state", state);
        }
web/src/config/api.ts
@@ -17,9 +17,20 @@
// GraphQL请求工具函数
export const graphqlRequest = async (query: string, variables: any = {}) => {
  // 获取JWT token
  const { getToken } = await import('@/utils/auth');
  // 获取JWT token与工具
  const { getToken, isTokenExpired, clearAuth } = await import('@/utils/auth');
  const token = getToken();
  // 若token过期,直接清理并跳登录
  if (!token || isTokenExpired(token)) {
    clearAuth();
    // 避免在登录页重复跳转造成白屏/循环
    const atLogin = typeof window !== 'undefined' && window.location && window.location.hash?.startsWith('#/login');
    if (!atLogin) {
      window.location.href = '/#/login';
    }
    throw new Error('Token expired or missing')
  }
  // 构建请求头
  const headers: Record<string, string> = {
@@ -45,13 +56,39 @@
    });
    if (!response.ok) {
      // 处理401未授权
      if (response.status === 401) {
        const { clearAuth } = await import('@/utils/auth');
        clearAuth();
        const atLogin = typeof window !== 'undefined' && window.location && window.location.hash?.startsWith('#/login');
        if (!atLogin) {
          window.location.href = '/#/login';
        }
      }
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const result = await response.json();
    if (result.errors) {
      throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
      const msg = JSON.stringify(result.errors) || ''
      // 识别认证类错误关键字
      const isAuthError =
        msg.includes('Unauthorized') ||
        msg.includes('认证') ||
        msg.includes('unauthorized') ||
        msg.includes('invalid token') ||
        msg.includes('expired')
      if (isAuthError) {
        const { clearAuth } = await import('@/utils/auth');
        clearAuth();
        const atLogin = typeof window !== 'undefined' && window.location && window.location.hash?.startsWith('#/login');
        if (!atLogin) {
          window.location.href = '/#/login';
        }
      }
      throw new Error(`GraphQL errors: ${msg}`);
    }
    return result;
web/src/router/index.ts
@@ -1,5 +1,5 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import { isLoggedIn } from '@/utils/auth'
import { isLoggedIn, getToken, isTokenExpired } from '@/utils/auth'
const routes: RouteRecordRaw[] = [
  {
@@ -148,8 +148,9 @@
router.beforeEach((to, from, next) => {
  // 如果是登录页面,直接放行
  if (to.path === '/login') {
    // 如果已经登录,重定向到首页
    if (isLoggedIn()) {
    // 仅在“有token且未过期且本地已记录登录信息”时才从登录页跳转到首页
    const t = getToken()
    if (t && !isTokenExpired(t) && isLoggedIn()) {
      next('/')
    } else {
      next()
@@ -157,9 +158,9 @@
    return
  }
  // 检查是否已登录
  if (!isLoggedIn()) {
    // 未登录,重定向到登录页
  // 检查是否已登录且token未过期
  const token = getToken()
  if (!token || isTokenExpired(token) || !isLoggedIn()) {
    next('/login')
    return
  }
web/src/utils/appConfig.ts
New file
@@ -0,0 +1,15 @@
// 轻量的应用配置加载器:在未登录状态下也可安全执行,不依赖 graphqlRequest,避免重定向循环
export async function loadAppConfig(): Promise<string> {
  try {
    const res = await fetch('/api/config', { method: 'GET' })
    if (res.ok) {
      const data = await res.json().catch(() => ({} as any))
      // 兼容后端未提供字段的情况
      return (data && (data.mediaBaseUrl || data.media_base_url || data.media || '')) || ''
    }
  } catch {
    // 忽略网络/401错误,返回默认值,保证应用可渲染登录页
  }
  // 默认返回空字符串,不影响页面渲染
  return ''
}
web/src/utils/auth.ts
@@ -75,6 +75,50 @@
  return !!(token && userInfo)
}
/**
 * 解析并判断JWT是否过期
 * 规则:若token为空或无法解析exp,视为过期;exp单位为秒
 */
export function isTokenExpired(token?: string | null): boolean {
  if (!token) return true
  try {
    const parts = token.split('.')
    if (parts.length !== 3) return true
    const payloadJson = JSON.parse(decodeBase64Url(parts[1]))
    const exp = payloadJson?.exp
    if (!exp || typeof exp !== 'number') return true
    const now = Math.floor(Date.now() / 1000)
    return exp <= now
  } catch (e) {
    console.error('解析JWT失败:', e)
    return true
  }
}
/**
 * Base64Url 解码
 */
function decodeBase64Url(input: string): string {
  // 替换URL安全字符并补齐'='
  let base64 = input.replace(/-/g, '+').replace(/_/g, '/')
  const pad = base64.length % 4
  if (pad) {
    base64 += '='.repeat(4 - pad)
  }
  const decoded = atob(base64)
  // 处理UTF-8
  try {
    return decodeURIComponent(
      decoded
        .split('')
        .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
        .join('')
    )
  } catch {
    return decoded
  }
}
// 清除所有认证数据
export function clearAuth(): void {
  removeToken()
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">
@@ -183,7 +186,7 @@
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Document, UserFilled } from '@element-plus/icons-vue'
import { getProjectDetail, getRatingStats, submitRating, getCurrentJudgeRating } from '@/api/projectReview'
import { getProjectDetail, getRatingStats, submitRating, getCurrentJudgeRating, getActiveActivities } from '@/api/projectReview'
import { userApi } from '@/api/user'
import { getUserInfo } from '@/utils/auth'
@@ -195,6 +198,8 @@
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)
@@ -211,7 +216,25 @@
// 计算属性
const projectId = computed(() => route.params.id)
const stageId = computed(() => route.query.stageId)
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 () => {
@@ -331,6 +354,7 @@
  try {
    const data = await getProjectDetail(projectId.value)
    projectDetail.value = data
    await loadStageMeta()
    
    // 初始化评分项
    if (data.ratingForm && data.ratingForm.items) {
@@ -373,12 +397,13 @@
    return
  }
  
  // 验证stageId
  if (!stageId.value) {
  // 统一获取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) {
@@ -397,7 +422,7 @@
    
    const ratingData = {
      activityPlayerId: parseInt(projectId.value),
      stageId: parseInt(stageId.value),
      stageId: sid,
      ratings: ratingItems.value.map(item => ({
        itemId: item.id,
        score: item.score
@@ -518,6 +543,7 @@
.description-content {
  line-height: 1.6;
  color: #606266;
  white-space: pre-wrap;
}
.attachments {
@@ -685,4 +711,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>
web/src/views/review-list.vue
@@ -496,7 +496,7 @@
// 查看项目详情
const viewDetails = (projectId: number) => {
  router.push(`/review/detail/${projectId}`)
  router.push(`/project-review/${projectId}/detail`)
}
// 组件挂载时加载数据