backend/src/main/java/com/rongyichuang/player/dto/response/PlayerInfoResponse.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
backend/src/main/java/com/rongyichuang/player/service/ActivityPlayerDetailService.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
web/src/config/api.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
web/src/router/index.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
web/src/utils/appConfig.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
web/src/utils/auth.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
web/src/views/review-detail.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
web/src/views/review-list.vue | ●●●●● 补丁 | 查看 | 原始文档 | 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`) } // 组件挂载时加载数据