From c4a9cad1c50e89365e2a58b50e259af642ed3b8c Mon Sep 17 00:00:00 2001
From: Codex Assistant <codex@example.com>
Date: 星期二, 07 十月 2025 16:12:20 +0800
Subject: [PATCH] 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密钥,重启前后端

---
 backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java    |    4 
 web/src/utils/auth.ts                                                                  |   44 +++++++++++
 backend/src/main/java/com/rongyichuang/player/dto/response/PlayerInfoResponse.java     |    8 ++
 web/src/utils/appConfig.ts                                                             |   15 +++
 web/src/router/index.ts                                                                |   13 +-
 web/src/views/review-detail.vue                                                        |   69 ++++++++++++++--
 backend/src/main/java/com/rongyichuang/player/service/ActivityPlayerDetailService.java |    3 
 web/src/config/api.ts                                                                  |   43 ++++++++++
 web/src/views/review-list.vue                                                          |    2 
 9 files changed, 177 insertions(+), 24 deletions(-)

diff --git a/backend/src/main/java/com/rongyichuang/player/dto/response/PlayerInfoResponse.java b/backend/src/main/java/com/rongyichuang/player/dto/response/PlayerInfoResponse.java
index e09ee6f..b467571 100644
--- a/backend/src/main/java/com/rongyichuang/player/dto/response/PlayerInfoResponse.java
+++ b/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; }
 }
\ No newline at end of file
diff --git a/backend/src/main/java/com/rongyichuang/player/service/ActivityPlayerDetailService.java b/backend/src/main/java/com/rongyichuang/player/service/ActivityPlayerDetailService.java
index 1433984..ba7601e 100644
--- a/backend/src/main/java/com/rongyichuang/player/service/ActivityPlayerDetailService.java
+++ b/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("璋冭瘯锛氭湭鎵惧埌鐢ㄦ埛澶村儚锛寀serId: {}, targetType: {}", userId, MediaTargetType.USER_AVATAR.getValue());
diff --git a/backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java b/backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java
index 442d5ec..27bf71a 100644
--- a/backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java
+++ b/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);
         }
diff --git a/web/src/config/api.ts b/web/src/config/api.ts
index c1430be..ecbdb5e 100644
--- a/web/src/config/api.ts
+++ b/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();
+
+  // 鑻oken杩囨湡锛岀洿鎺ユ竻鐞嗗苟璺崇櫥褰�
+  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;
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
index 617baaf..3aa9c0a 100644
--- a/web/src/router/index.ts
+++ b/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()) {
-    // 鏈櫥褰曪紝閲嶅畾鍚戝埌鐧诲綍椤�
+  // 妫�鏌ユ槸鍚﹀凡鐧诲綍涓攖oken鏈繃鏈�
+  const token = getToken()
+  if (!token || isTokenExpired(token) || !isLoggedIn()) {
     next('/login')
     return
   }
diff --git a/web/src/utils/appConfig.ts b/web/src/utils/appConfig.ts
new file mode 100644
index 0000000..9f974ab
--- /dev/null
+++ b/web/src/utils/appConfig.ts
@@ -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 ''
+}
\ No newline at end of file
diff --git a/web/src/utils/auth.ts b/web/src/utils/auth.ts
index a55e90c..3cb965e 100644
--- a/web/src/utils/auth.ts
+++ b/web/src/utils/auth.ts
@@ -75,6 +75,50 @@
   return !!(token && userInfo)
 }
 
+/**
+ * 瑙f瀽骞跺垽鏂璊WT鏄惁杩囨湡
+ * 瑙勫垯锛氳嫢token涓虹┖鎴栨棤娉曡В鏋恊xp锛岃涓鸿繃鏈燂紱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('瑙f瀽JWT澶辫触:', e)
+    return true
+  }
+}
+
+/**
+ * Base64Url 瑙g爜
+ */
+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()
diff --git a/web/src/views/review-detail.vue b/web/src/views/review-detail.vue
index e015e26..848948b 100644
--- a/web/src/views/review-detail.vue
+++ b/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>
\ No newline at end of file
diff --git a/web/src/views/review-list.vue b/web/src/views/review-list.vue
index f2e344d..932877b 100644
--- a/web/src/views/review-list.vue
+++ b/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`)
 }
 
 // 缁勪欢鎸傝浇鏃跺姞杞芥暟鎹�

--
Gitblit v1.8.0