Codex Assistant
2025-10-05 0a48616045ddce1562584543a0e89e5144051fde
报名审核
15个文件已修改
1个文件已添加
1723 ■■■■ 已修改文件
backend/src/main/java/com/rongyichuang/auth/filter/JwtAuthenticationFilter.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/dto/response/CurrentJudgeRatingResponse.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/service/ActivityPlayerRatingService.java 47 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/review/resolver/ReviewResolver.java 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/graphql/player.graphqls 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
clear-invalid-tokens.js 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/app.js 202 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/judge/review.js 522 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/judge/review.wxml 210 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/judge/review.wxss 444 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/message/message.js 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/project/detail.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/review/index.js 92 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/review/index.wxml 21 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/review/index.wxss 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/auth/filter/JwtAuthenticationFilter.java
@@ -169,7 +169,7 @@
     * 返回权限错误响应
     */
    private void sendUnauthorizedResponse(HttpServletResponse response) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"errors\":[{\"message\":\"没有权限访问,请先登录\",\"extensions\":{\"code\":\"UNAUTHORIZED\"}}]}");
    }
backend/src/main/java/com/rongyichuang/player/dto/response/CurrentJudgeRatingResponse.java
@@ -12,6 +12,7 @@
    private BigDecimal totalScore;
    private Integer status;
    private String remark;
    private String ratedAt;
    private List<CurrentJudgeRatingItemResponse> items;
    public CurrentJudgeRatingResponse() {}
@@ -48,6 +49,14 @@
        this.remark = remark;
    }
    public String getRatedAt() {
        return ratedAt;
    }
    public void setRatedAt(String ratedAt) {
        this.ratedAt = ratedAt;
    }
    public List<CurrentJudgeRatingItemResponse> getItems() {
        return items;
    }
@@ -64,15 +73,18 @@
        private String ratingItemName;
        private BigDecimal score;
        private BigDecimal weightedScore;
        private BigDecimal maxScore;
        public CurrentJudgeRatingItemResponse() {}
        public CurrentJudgeRatingItemResponse(Long ratingItemId, String ratingItemName, 
                                            BigDecimal score, BigDecimal weightedScore) {
                                            BigDecimal score, BigDecimal weightedScore,
                                            BigDecimal maxScore) {
            this.ratingItemId = ratingItemId;
            this.ratingItemName = ratingItemName;
            this.score = score;
            this.weightedScore = weightedScore;
            this.maxScore = maxScore;
        }
        public Long getRatingItemId() {
@@ -106,5 +118,13 @@
        public void setWeightedScore(BigDecimal weightedScore) {
            this.weightedScore = weightedScore;
        }
        public BigDecimal getMaxScore() {
            return maxScore;
        }
        public void setMaxScore(BigDecimal maxScore) {
            this.maxScore = maxScore;
        }
    }
}
}
backend/src/main/java/com/rongyichuang/player/service/ActivityPlayerRatingService.java
@@ -233,16 +233,35 @@
        // 获取评分项
        List<ActivityPlayerRatingItem> items = activityPlayerRatingItemRepository
                .findByActivityPlayerRatingId(rating.getId());
        List<CurrentJudgeRatingResponse.CurrentJudgeRatingItemResponse> itemResponses = items.stream()
                .map(item -> new CurrentJudgeRatingResponse.CurrentJudgeRatingItemResponse(
                        item.getRatingItemId(),
                        "", // 评分项名称暂时为空
                        item.getScore(),
                        item.getScore() // 使用得分作为加权得分
                ))
        List<Long> ratingItemIds = items.stream()
                .map(ActivityPlayerRatingItem::getRatingItemId)
                .filter(java.util.Objects::nonNull)
                .distinct()
                .collect(java.util.stream.Collectors.toList());
        java.util.Map<Long, RatingItem> ratingItemMap = ratingItemIds.isEmpty()
                ? java.util.Collections.emptyMap()
                : ratingItemRepository.findAllById(ratingItemIds).stream()
                        .collect(java.util.stream.Collectors.toMap(RatingItem::getId, java.util.function.Function.identity()));
        List<CurrentJudgeRatingResponse.CurrentJudgeRatingItemResponse> itemResponses = items.stream()
                .map(item -> {
                    RatingItem ratingItem = ratingItemMap.get(item.getRatingItemId());
                    String name = ratingItem != null ? ratingItem.getName() : "";
                    BigDecimal maxScore = ratingItem != null && ratingItem.getMaxScore() != null
                            ? BigDecimal.valueOf(ratingItem.getMaxScore()) : null;
                    return new CurrentJudgeRatingResponse.CurrentJudgeRatingItemResponse(
                            item.getRatingItemId(),
                            name,
                            item.getScore(),
                            item.getScore(),
                            maxScore
                    );
                })
                .collect(java.util.stream.Collectors.toList());
        response.setRatedAt(rating.getUpdateTime() != null ? rating.getUpdateTime().toString() : null);
        response.setItems(itemResponses);
        return response;
    }
@@ -403,7 +422,12 @@
                            ((Number) row.get("rating_item_id")).longValue(),
                            (String) row.get("rating_item_name"),
                            (BigDecimal) row.get("score"),
                            (BigDecimal) row.get("score") // weightedScore 暂时使用相同值
                            (BigDecimal) row.get("score"),
                            row.get("max_score") instanceof BigDecimal
                                    ? (BigDecimal) row.get("max_score")
                                    : row.get("max_score") instanceof Number
                                        ? BigDecimal.valueOf(((Number) row.get("max_score")).doubleValue())
                                        : null
                    ))
                    .collect(java.util.stream.Collectors.toList());
            
@@ -412,6 +436,7 @@
            response.setTotalScore(rating.getTotalScore());
            response.setStatus(rating.getState());
            response.setRemark(rating.getFeedback());
            response.setRatedAt(rating.getUpdateTime() != null ? rating.getUpdateTime().toString() : null);
            response.setItems(items);
            
            return response;
@@ -421,4 +446,4 @@
            return null;
        }
    }
}
}
backend/src/main/java/com/rongyichuang/review/resolver/ReviewResolver.java
@@ -31,10 +31,10 @@
     */
    @QueryMapping
    public ReviewProjectPageResponse unReviewedProjects(
            @Argument String searchKeyword,
            @Argument int page,
            @Argument int pageSize) {
        log.info("查询我未评审的项目列表,searchKeyword: {}, page: {}, pageSize: {}", searchKeyword, page, pageSize);
            @Argument int pageSize,
            @Argument String searchKeyword) {
        log.info("查询我未评审的项目列表,page: {}, pageSize: {}, searchKeyword: {}", page, pageSize, searchKeyword);
        
        Long currentJudgeId = userContextUtil.getCurrentJudgeId();
        if (currentJudgeId == null) {
@@ -49,10 +49,10 @@
     */
    @QueryMapping
    public ReviewProjectPageResponse reviewedProjects(
            @Argument String searchKeyword,
            @Argument int page,
            @Argument int pageSize) {
        log.info("查询我已评审的项目列表,searchKeyword: {}, page: {}, pageSize: {}", searchKeyword, page, pageSize);
            @Argument int pageSize,
            @Argument String searchKeyword) {
        log.info("查询我已评审的项目列表,page: {}, pageSize: {}, searchKeyword: {}", page, pageSize, searchKeyword);
        
        Long currentJudgeId = userContextUtil.getCurrentJudgeId();
        if (currentJudgeId == null) {
@@ -67,10 +67,10 @@
     */
    @QueryMapping
    public ReviewProjectPageResponse studentUnReviewedProjects(
            @Argument String searchKeyword,
            @Argument int page,
            @Argument int pageSize) {
        log.info("查询学员未评审的项目列表,searchKeyword: {}, page: {}, pageSize: {}", searchKeyword, page, pageSize);
            @Argument int pageSize,
            @Argument String searchKeyword) {
        log.info("查询学员未评审的项目列表,page: {}, pageSize: {}, searchKeyword: {}", page, pageSize, searchKeyword);
        
        Long currentJudgeId = userContextUtil.getCurrentJudgeId();
        if (currentJudgeId == null) {
backend/src/main/resources/application.yml
@@ -86,7 +86,7 @@
  base-url: https://rych.9village.cn
  jwt:
    secret: ryc-jwt-secret-key-2024-secure-256bit-hmac-sha-algorithm-compatible
    expiration: 86400000 # 24小时
    expiration: 7200000 # 2小时
  media-url:  https://ryc-1379367838.cos.ap-chengdu.myqcloud.com
# 微信小程序配置
backend/src/main/resources/graphql/player.graphqls
@@ -96,6 +96,7 @@
    totalScore: Float
    status: Int
    remark: String
    ratedAt: String
    items: [CurrentJudgeRatingItemResponse!]!
}
@@ -105,6 +106,7 @@
    ratingItemName: String!
    score: Float
    weightedScore: Float
    maxScore: Float
}
type ProjectStageTimelineResponse {
clear-invalid-tokens.js
New file
@@ -0,0 +1,86 @@
// 清理无效token的脚本
// 这个脚本需要在小程序开发者工具的控制台中运行
console.log('🧹 开始清理无效的JWT token...');
// 检查当前存储的token
const currentToken = wx.getStorageSync('token');
console.log('当前token:', currentToken ? `${currentToken.substring(0, 20)}...` : '无');
// 检查token格式是否有效
function isValidJWTFormat(token) {
  if (!token || typeof token !== 'string') {
    return false;
  }
  // JWT应该有3个部分,用.分隔
  const parts = token.split('.');
  if (parts.length !== 3) {
    console.log('❌ Token格式无效:不是3个部分');
    return false;
  }
  // 检查是否包含测试用的无效签名
  if (token.includes('invalid_token')) {
    console.log('❌ 检测到测试用的无效token');
    return false;
  }
  try {
    // 尝试解码header和payload
    const header = JSON.parse(atob(parts[0]));
    const payload = JSON.parse(atob(parts[1]));
    console.log('Token header:', header);
    console.log('Token payload:', payload);
    // 检查是否过期
    const now = Math.floor(Date.now() / 1000);
    if (payload.exp && payload.exp < now) {
      console.log('❌ Token已过期');
      return false;
    }
    console.log('✅ Token格式有效');
    return true;
  } catch (e) {
    console.log('❌ Token解码失败:', e.message);
    return false;
  }
}
// 清理无效token
if (currentToken) {
  if (!isValidJWTFormat(currentToken)) {
    console.log('🗑️ 清理无效token...');
    // 清除存储的认证信息
    wx.removeStorageSync('token');
    wx.removeStorageSync('userInfo');
    wx.removeStorageSync('sessionKey');
    // 清除globalData中的认证信息
    const app = getApp();
    if (app) {
      app.globalData.token = null;
      app.globalData.userInfo = null;
      app.globalData.sessionKey = null;
    }
    console.log('✅ 无效token已清理');
    console.log('💡 建议重新启动小程序以获取新的有效token');
  } else {
    console.log('✅ 当前token有效,无需清理');
  }
} else {
  console.log('ℹ️ 当前没有存储token');
}
console.log('🎉 清理完成!');
// 使用说明
console.log('\n📋 使用说明:');
console.log('1. 在小程序开发者工具中打开控制台');
console.log('2. 复制并粘贴这段代码');
console.log('3. 按回车执行');
console.log('4. 如果清理了无效token,请重新启动小程序');
wx/app.js
@@ -4,7 +4,7 @@
    userInfo: null,
    token: null,
    sessionKey: null, // 微信会话密钥,用于解密手机号等敏感数据
    baseUrl: 'http://localhost:8080/graphql', // 后台GraphQL接口地址
    baseUrl: 'http://localhost:8080/api/graphql', // 后台GraphQL接口地址
    hasPhoneAuth: false, // 是否已授权手机号
    rejectPhone: false, // 是否拒绝过手机号授权
    cos: {
@@ -276,56 +276,174 @@
  // GraphQL请求封装
  graphqlRequest(query, variables = {}) {
    return new Promise((resolve, reject) => {
      // 确保token的一致性:优先使用globalData中的token,如果没有则从storage获取
      let token = this.globalData.token
      if (!token) {
        token = wx.getStorageSync('token')
        if (token) {
          this.globalData.token = token // 同步到globalData
        }
      }
      this._makeGraphQLRequest(query, variables, resolve, reject, false)
    })
  },
      wx.request({
        url: this.globalData.baseUrl,
        method: 'POST',
        header: {
          'Content-Type': 'application/json',
          'Authorization': token ? `Bearer ${token}` : ''
        },
        data: {
          query: query,
          variables: variables
        },
        success: (res) => {
          console.log('GraphQL响应:', res.data)
          // 检查HTTP状态码
          if (res.statusCode !== 200) {
  // 内部GraphQL请求方法,支持重试机制
  _makeGraphQLRequest(query, variables, resolve, reject, isRetry = false) {
    // 确保token的一致性:优先使用globalData中的token,如果没有则从storage获取
    let token = this.globalData.token
    if (!token) {
      token = wx.getStorageSync('token')
      if (token) {
        this.globalData.token = token // 同步到globalData
      }
    }
    wx.request({
      url: this.globalData.baseUrl,
      method: 'POST',
      header: {
        'Content-Type': 'application/json',
        'Authorization': token ? `Bearer ${token}` : ''
      },
      data: {
        query: query,
        variables: variables
      },
      success: (res) => {
        console.log('GraphQL响应:', res.data)
        // 检查HTTP状态码
        if (res.statusCode !== 200) {
          // 对于401状态码,可能是认证错误,需要检查响应内容
          if (res.statusCode === 401 && res.data && res.data.errors) {
            console.log('收到401状态码,检查是否为认证错误')
            // 继续处理,让下面的GraphQL错误检查逻辑处理认证错误
          } else {
            console.error('GraphQL HTTP错误:', res.statusCode)
            reject(new Error(`HTTP错误: ${res.statusCode}`))
            return
          }
        }
          // 检查GraphQL错误
          if (res.data.errors) {
            console.error('GraphQL错误:', res.data.errors)
            reject(new Error(res.data.errors[0]?.message || 'GraphQL请求错误'))
        // 检查GraphQL错误
        if (res.data && res.data.errors) {
          console.error('GraphQL错误:', res.data.errors)
          // 检查是否是认证错误(token过期或无效)
          const authErrors = res.data.errors.filter(error =>
            error.message && (
              error.message.includes('没有权限访问') ||
              error.message.includes('请先登录') ||
              error.message.includes('UNAUTHORIZED') ||
              error.extensions?.code === 'UNAUTHORIZED'
            )
          )
          if (authErrors.length > 0 && !isRetry) {
            console.log('🔄 检测到认证错误,尝试重新登录...')
            // 清除过期的认证信息
            this.globalData.token = null
            this.globalData.userInfo = null
            this.globalData.sessionKey = null
            wx.removeStorageSync('token')
            wx.removeStorageSync('userInfo')
            wx.removeStorageSync('sessionKey')
            // 重新登录
            wx.login({
              success: (loginRes) => {
                if (loginRes.code) {
                  console.log('🔄 重新获取微信code成功,调用后端登录...')
                  this._retryAfterLogin(loginRes.code, query, variables, resolve, reject)
                } else {
                  console.error('❌ 重新获取微信code失败')
                  reject(new Error('重新登录失败'))
                }
              },
              fail: (err) => {
                console.error('❌ 重新登录失败:', err)
                reject(new Error('重新登录失败'))
              }
            })
            return
          }
          // 检查数据
          if (res.data.data !== undefined) {
            resolve(res.data.data)
          } else {
            console.error('GraphQL响应异常:', res.data)
            reject(new Error('GraphQL响应数据异常'))
          }
        },
        fail: (err) => {
          console.error('GraphQL网络请求失败:', err)
          reject(new Error('网络请求失败'))
          reject(new Error(res.data.errors[0]?.message || 'GraphQL请求错误'))
          return
        }
      })
        // 检查数据
        if (res.data.data !== undefined) {
          resolve(res.data.data)
        } else {
          console.error('GraphQL响应异常:', res.data)
          reject(new Error('GraphQL响应数据异常'))
        }
      },
      fail: (err) => {
        console.error('GraphQL网络请求失败:', err)
        reject(new Error('网络请求失败'))
      }
    })
  },
  // 重新登录后重试GraphQL请求
  _retryAfterLogin(code, query, variables, resolve, reject) {
    const that = this
    const deviceInfo = this.getDeviceInfo()
    const requestData = {
      code: code,
      loginIp: '127.0.0.1', // 小程序无法获取真实IP,使用默认值
      deviceInfo: deviceInfo
    }
    wx.request({
      url: 'http://localhost:8080/api/auth/wx-login',
      method: 'POST',
      header: {
        'Content-Type': 'application/json'
      },
      data: requestData,
      success: (res) => {
        console.log('🔄 重新登录响应:', res.data)
        if (res.statusCode !== 200 || res.data.error) {
          console.error('❌ 重新登录失败:', res.data.error || res.data.message)
          reject(new Error('重新登录失败'))
          return
        }
        // 检查响应数据格式
        let loginResult = null
        if (res.data.token && res.data.userInfo) {
          loginResult = res.data
        } else if (res.data.success && res.data.data) {
          loginResult = res.data.data
        }
        if (loginResult && loginResult.token) {
          console.log('✅ 重新登录成功,更新token')
          // 保存新的登录信息
          try {
            wx.setStorageSync('token', loginResult.token)
            wx.setStorageSync('userInfo', loginResult.userInfo)
            if (loginResult.sessionKey) {
              wx.setStorageSync('sessionKey', loginResult.sessionKey)
            }
          } catch (storageErr) {
            console.error('❌ 保存重新登录信息失败:', storageErr)
          }
          that.globalData.token = loginResult.token
          that.globalData.userInfo = loginResult.userInfo
          that.globalData.sessionKey = loginResult.sessionKey
          // 使用新token重试原始请求
          console.log('🔄 使用新token重试GraphQL请求...')
          that._makeGraphQLRequest(query, variables, resolve, reject, true)
        } else {
          console.error('❌ 重新登录响应格式错误')
          reject(new Error('重新登录响应格式错误'))
        }
      },
      fail: (err) => {
        console.error('❌ 重新登录网络请求失败:', err)
        reject(new Error('重新登录网络请求失败'))
      }
    })
  }
})
wx/pages/judge/review.js
@@ -1,6 +1,6 @@
// pages/judge/review.js
const app = getApp()
const { graphqlRequest, formatDate } = require('../../lib/utils')
const { graphqlRequest, formatDate: formatDateUtil } = require('../../lib/utils')
Page({
  data: {
@@ -10,13 +10,15 @@
    // 提交作品信息
    submission: null,
    activityPlayerId: '',
    stageId: null,
    submissionId: null,
    // 活动信息
    activity: null,
    // 评审标准
    criteria: [],
    // 评分数据
    scores: {},
    
@@ -31,24 +33,7 @@
    reviewStatus: 'PENDING', // PENDING, COMPLETED
    
    // 已有评审记录
    existingReview: null,
    // 媒体预览
    showMediaPreview: false,
    currentMedia: null,
    mediaType: 'image',
    // 文件下载
    downloadingFiles: [],
    // 评分等级
    scoreOptions: [
      { value: 1, label: '1分 - 很差' },
      { value: 2, label: '2分 - 较差' },
      { value: 3, label: '3分 - 一般' },
      { value: 4, label: '4分 - 良好' },
      { value: 5, label: '5分 - 优秀' }
    ]
    existingReview: null
  },
  onLoad(options) {
@@ -62,6 +47,32 @@
    // 页面显示时检查评审状态
    if (this.data.submissionId) {
      this.checkReviewStatus()
    }
  },
  transformMediaFile(file) {
    const url = file.fullUrl || file.url
    const thumbUrl = file.fullThumbUrl || url
    const ext = (file.fileExt || '').toLowerCase()
    let mediaType = 'file'
    if (file.mediaType === 1 || ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic'].includes(ext)) {
      mediaType = 'image'
    } else if (file.mediaType === 2 || ['mp4', 'mov', 'avi', 'wmv', 'mkv', 'webm', 'flv'].includes(ext)) {
      mediaType = 'video'
    } else if (ext === 'pdf') {
      mediaType = 'pdf'
    } else if (['doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'wps', 'txt', 'rtf'].includes(ext)) {
      mediaType = 'word'
    }
    return {
      id: file.id,
      name: file.name,
      url,
      thumbUrl,
      mediaType,
      size: file.fileSize || 0
    }
  },
@@ -102,13 +113,11 @@
            submissionFiles {
              id
              name
              url
              fullUrl
              fullThumbUrl
              fileExt
              fileSize
              mediaType
              thumbUrl
              fullThumbUrl
            }
            ratingForm {
              schemeId
@@ -117,10 +126,8 @@
              items {
                id
                name
                description
                maxScore
                weight
                sortOrder
                orderNo
              }
            }
          }
@@ -137,62 +144,64 @@
          id: detail.id,
          title: detail.projectName,
          description: detail.description,
          files: detail.submissionFiles ? detail.submissionFiles.map(file => ({
            id: file.id,
            name: file.name,
            url: file.fullUrl || file.url,
            type: file.fileExt,
            size: file.fileSize,
            isDownloading: this.data.downloadingFiles.indexOf(file.id) > -1
          })) : [],
          images: detail.submissionFiles ? detail.submissionFiles
            .filter(file => file.mediaType === 1)
            .map(file => file.fullUrl || file.url) : [],
          videos: detail.submissionFiles ? detail.submissionFiles
            .filter(file => file.mediaType === 2)
            .map(file => file.fullUrl || file.url) : [],
          submittedAt: detail.submitTime || null,
          team: detail.team || null,
          participant: {
            id: detail.playerInfo.id,
            name: detail.playerInfo.name,
            id: detail.playerInfo?.id,
            name: detail.playerInfo?.name,
            phone: detail.playerInfo?.phone || detail.playerInfo?.userInfo?.phone || '',
            avatar: detail.playerInfo?.userInfo?.avatarUrl || '/images/default-avatar.svg',
            gender: this.getGenderLabel(detail.playerInfo?.gender),
            birthday: this.getBirthdayText(detail.playerInfo?.birthday),
            region: detail.regionInfo?.fullPath || detail.regionInfo?.name || '',
            education: detail.playerInfo?.education || '',
            school: detail.regionInfo ? detail.regionInfo.name : '',
            major: detail.playerInfo.education || '',
            avatar: detail.playerInfo.userInfo?.avatarUrl || '/images/default-avatar.svg'
            major: detail.playerInfo?.education || ''
          },
          status: detail.state === 1 ? 'APPROVED' : detail.state === 2 ? 'REJECTED' : 'PENDING'
          status: detail.state === 1 ? 'APPROVED' : detail.state === 2 ? 'REJECTED' : 'PENDING',
          mediaList: (detail.submissionFiles || []).map(file => this.transformMediaFile(file))
        }
        // 构建activity对象
        const activity = {
          id: detail.stageId,
          title: detail.activityName,
          description: detail.description,
          judgeCriteria: detail.ratingForm ? detail.ratingForm.items || [] : []
        }
        // 初始化评分数据
        const criteria = (detail.ratingForm?.items || []).map(item => {
          const maxScore = item.maxScore || 0
          return {
            id: item.id,
            name: item.name,
            maxScore,
            description: item.description || '暂无描述',
            step: maxScore > 20 ? 1 : 0.5,
            currentScore: 0
          }
        })
        const scores = {}
        let maxScore = 0
        if (activity.judgeCriteria) {
          activity.judgeCriteria.forEach(criterion => {
            scores[criterion.id] = 0 // 暂时设为0,后续需要查询已有评分
            maxScore += criterion.maxScore
          })
        }
        criteria.forEach(criterion => {
          scores[criterion.id] = 0
        })
        const maxScore = detail.ratingForm?.totalMaxScore || criteria.reduce((sum, item) => sum + (item.maxScore || 0), 0)
        this.setData({
          submission,
          activity,
          criteria: activity.judgeCriteria || [],
          activity: {
            id: detail.id,
            stageId: detail.stageId,
            ratingSchemeId: detail.ratingForm?.schemeId || null,
            totalMaxScore: maxScore
          },
          stageId: detail.stageId || null,
          submissionId: detail.id,
          criteria,
          scores,
          maxScore,
          existingReview: null, // 暂时设为null,后续需要查询已有评分
          totalScore: 0,
          existingReview: null,
          reviewStatus: 'PENDING',
          comment: ''
        })
        this.calculateTotalScore()
        // 检查是否已有评分
        this.checkReviewStatus()
      }
@@ -215,7 +224,7 @@
          currentJudgeRating(activityPlayerId: $activityPlayerId) {
            id
            totalScore
            comment
            remark
            status
            ratedAt
            items {
@@ -239,38 +248,98 @@
        
        if (rating.items) {
          rating.items.forEach(item => {
            scores[item.ratingItemId] = item.score
            totalScore += item.score
            const numericScore = item.score !== undefined && item.score !== null ? Number(item.score) : 0
            scores[item.ratingItemId] = numericScore
            totalScore += numericScore
          })
        }
        const updatedCriteria = this.data.criteria.map(criterion => {
          const value = scores[criterion.id] !== undefined ? scores[criterion.id] : 0
          return {
            ...criterion,
            currentScore: value
          }
        })
        const normalizedTotal = Number(totalScore.toFixed(2))
        this.setData({
          scores,
          totalScore,
          comment: rating.comment || '',
          existingReview: rating,
          reviewStatus: rating.status || 'COMPLETED'
          criteria: updatedCriteria,
          totalScore: normalizedTotal,
          comment: rating.remark || rating.comment || '',
          existingReview: {
            ...rating,
            totalScore: rating.totalScore ? Number(rating.totalScore) : normalizedTotal,
            reviewedAt: rating.ratedAt || rating.reviewedAt || rating.updateTime || null
          },
          reviewStatus: 'COMPLETED'
        })
        
        console.log('已加载现有评分:', rating)
      } else {
        console.log('当前评委尚未评分')
      }
      this.calculateTotalScore()
    } catch (error) {
      console.error('检查评审状态失败:', error)
    }
  },
  normalizeScore(value, criterion) {
    const maxScore = Number(criterion.maxScore || 0)
    const step = Number(criterion.step || (maxScore > 20 ? 1 : 0.5))
    if (Number.isNaN(value)) {
      value = 0
    }
    let normalized = Math.round(value / step) * step
    if (normalized < 0) normalized = 0
    if (normalized > maxScore) normalized = maxScore
    return Number(normalized.toFixed(2))
  },
  updateCriterionScore(criterionId, index, value) {
    const criterion = this.data.criteria[index]
    if (!criterion) return
    const normalized = this.normalizeScore(value, criterion)
    this.setData({
      [`scores.${criterionId}`]: normalized,
      [`criteria[${index}].currentScore`]: normalized
    })
    this.calculateTotalScore()
  },
  // 评分改变
  onScoreChange(e) {
    const { criterionId } = e.currentTarget.dataset
    const { value } = e.detail
    this.setData({
      [`scores.${criterionId}`]: parseInt(value)
    })
    this.calculateTotalScore()
    const { criterionId, index } = e.currentTarget.dataset
    const criterion = this.data.criteria[index]
    if (!criterion) return
    const inputValue = Number(e.detail.value)
    const newScore = this.normalizeScore(inputValue, criterion)
    this.updateCriterionScore(criterionId, index, newScore)
  },
  increaseScore(e) {
    const { criterionId, index } = e.currentTarget.dataset
    const criterion = this.data.criteria[index]
    if (!criterion) return
    const current = Number(this.data.scores[criterionId] || criterion.currentScore || 0)
    const step = criterion.step || (criterion.maxScore > 20 ? 1 : 0.5)
    this.updateCriterionScore(criterionId, index, current + step)
  },
  decreaseScore(e) {
    const { criterionId, index } = e.currentTarget.dataset
    const criterion = this.data.criteria[index]
    if (!criterion) return
    const current = Number(this.data.scores[criterionId] || criterion.currentScore || 0)
    const step = criterion.step || (criterion.maxScore > 20 ? 1 : 0.5)
    this.updateCriterionScore(criterionId, index, current - step)
  },
  // 计算总分
@@ -279,11 +348,11 @@
    let totalScore = 0
    
    criteria.forEach(criterion => {
      const score = scores[criterion.id] || 0
      totalScore += score * (criterion.weight || 1)
      const score = Number(scores[criterion.id] || 0)
      totalScore += score
    })
    
    this.setData({ totalScore })
    this.setData({ totalScore: Number(totalScore.toFixed(2)) })
  },
  // 评审意见输入
@@ -295,106 +364,58 @@
  // 媒体点击
  onMediaTap(e) {
    const { url, type } = e.currentTarget.dataset
    if (type === 'image') {
    const index = Number(e.currentTarget.dataset.index)
    const mediaList = this.data.submission?.mediaList || []
    const media = mediaList[index]
    if (!media) return
    if (media.mediaType === 'image') {
      const imageUrls = mediaList
        .filter(item => item.mediaType === 'image')
        .map(item => item.url)
      wx.previewImage({
        current: url,
        urls: this.data.submission.images || []
        current: media.url,
        urls: imageUrls
      })
    } else if (type === 'video') {
      this.setData({
        showMediaPreview: true,
        currentMedia: url,
        mediaType: 'video'
    } else if (media.mediaType === 'video') {
      wx.navigateTo({
        url: `/pages/video/video?url=${encodeURIComponent(media.url)}&title=${encodeURIComponent(media.name)}`
      })
    } else {
      this.openDocumentMedia(media)
    }
  },
  // 关闭媒体预览
  onCloseMediaPreview() {
    this.setData({
      showMediaPreview: false,
      currentMedia: null
    })
  },
  // 下载文件
  async onDownloadFile(e) {
    const { fileId, fileName, fileUrl } = e.currentTarget.dataset
  async openDocumentMedia(media) {
    try {
      // 添加到下载中列表
      const downloadingFiles = [...this.data.downloadingFiles, fileId]
      // 同时更新文件的isDownloading字段
      const submission = { ...this.data.submission }
      if (submission.files) {
        submission.files = submission.files.map(file => ({
          ...file,
          isDownloading: file.id === fileId ? true : file.isDownloading
        }))
      }
      this.setData({
        downloadingFiles,
        submission
      wx.showLoading({ title: '打开中...' })
      const downloadRes = await new Promise((resolve, reject) => {
        wx.downloadFile({
          url: media.url,
          success: resolve,
          fail: reject
        })
      })
      wx.showLoading({ title: '下载中...' })
      const result = await wx.downloadFile({
        url: fileUrl,
        success: (res) => {
          if (res.statusCode === 200) {
            // 保存到相册或文件
            wx.saveFile({
              tempFilePath: res.tempFilePath,
              success: () => {
                wx.showToast({
                  title: '下载成功',
                  icon: 'success'
                })
              },
              fail: () => {
                wx.showToast({
                  title: '保存失败',
                  icon: 'error'
                })
              }
            })
          }
        },
        fail: () => {
          wx.showToast({
            title: '下载失败',
            icon: 'error'
          })
        }
      if (downloadRes.statusCode !== 200) {
        throw new Error('文件下载失败')
      }
      await new Promise((resolve, reject) => {
        wx.openDocument({
          filePath: downloadRes.tempFilePath,
          showMenu: true,
          success: resolve,
          fail: reject
        })
      })
    } catch (error) {
      console.error('下载文件失败:', error)
      console.error('打开文件失败:', error)
      wx.showToast({
        title: '下载失败',
        title: '无法打开文件',
        icon: 'error'
      })
    } finally {
      // 从下载中列表移除
      const downloadingFiles = this.data.downloadingFiles.filter(id => id !== fileId)
      // 同时更新文件的isDownloading字段
      const submission = { ...this.data.submission }
      if (submission.files) {
        submission.files = submission.files.map(file => ({
          ...file,
          isDownloading: file.id === fileId ? false : file.isDownloading
        }))
      }
      this.setData({
        downloadingFiles,
        submission
      })
      wx.hideLoading()
    }
  },
@@ -402,6 +423,7 @@
  // 验证评审数据
  validateReview() {
    const { scores, criteria, comment } = this.data
    const commentText = (comment || '').trim()
    
    // 检查是否所有标准都已评分
    for (let criterion of criteria) {
@@ -415,7 +437,7 @@
    }
    
    // 检查评审意见
    if (!comment.trim()) {
    if (!commentText) {
      wx.showToast({
        title: '请填写评审意见',
        icon: 'error'
@@ -423,7 +445,7 @@
      return false
    }
    
    if (comment.trim().length < 10) {
    if (commentText.length < 10) {
      wx.showToast({
        title: '评审意见至少10个字符',
        icon: 'error'
@@ -432,54 +454,6 @@
    }
    
    return true
  },
  // 保存草稿
  async onSaveDraft() {
    try {
      wx.showLoading({ title: '保存中...' })
      const { activityPlayerId, scores, comment, criteria, activity } = this.data
      // 构建评分项数组
      const ratings = criteria.map(criterion => ({
        itemId: criterion.id,
        score: scores[criterion.id] || 0
      }))
      const mutation = `
        mutation SaveActivityPlayerRating($input: ActivityPlayerRatingInput!) {
          saveActivityPlayerRating(input: $input)
        }
      `
      const input = {
        activityPlayerId,
        stageId: activity.stageId,
        ratings,
        comment: comment.trim()
      }
      const result = await graphqlRequest(mutation, { input })
      if (result && result.saveActivityPlayerRating) {
        wx.showToast({
          title: '草稿已保存',
          icon: 'success'
        })
        // 重新加载评分状态
        await this.checkReviewStatus()
      }
    } catch (error) {
      console.error('保存草稿失败:', error)
      wx.showToast({
        title: '保存失败',
        icon: 'error'
      })
    } finally {
      wx.hideLoading()
    }
  },
  // 提交评审
@@ -505,12 +479,23 @@
      this.setData({ submitting: true })
      wx.showLoading({ title: '提交中...' })
      
      const { activityPlayerId, scores, comment, criteria, activity } = this.data
      const { activityPlayerId, scores, comment, criteria, stageId } = this.data
      const commentText = (comment || '').trim()
      if (!stageId) {
        wx.showToast({
          title: '缺少阶段信息,无法提交',
          icon: 'none'
        })
        this.setData({ submitting: false })
        wx.hideLoading()
        return
      }
      
      // 构建评分项数组
      const ratings = criteria.map(criterion => ({
        itemId: criterion.id,
        score: scores[criterion.id] || 0
        score: Number(scores[criterion.id] || 0)
      }))
      
      const mutation = `
@@ -521,9 +506,9 @@
      
      const input = {
        activityPlayerId,
        stageId: activity.stageId,
        stageId,
        ratings,
        comment: comment.trim()
        comment: commentText
      }
      
      const result = await graphqlRequest(mutation, { input })
@@ -569,34 +554,17 @@
  // 联系参赛者
  onContactParticipant() {
    const { submission } = this.data
    if (submission.participant) {
      wx.showActionSheet({
        itemList: ['发送消息', '查看详情'],
        success: (res) => {
          switch (res.tapIndex) {
            case 0:
              // 发送消息功能
              wx.navigateTo({
                url: `/pages/chat/chat?userId=${submission.participant.id}`
              })
              break
            case 1:
              // 查看用户详情
              wx.navigateTo({
                url: `/pages/user/profile?userId=${submission.participant.id}`
              })
              break
          }
        }
    const phone = submission?.participant?.phone
    if (phone) {
      wx.makePhoneCall({
        phoneNumber: phone
      })
    } else {
      wx.showToast({
        title: '暂无联系方式',
        icon: 'none'
      })
    }
  },
  // 获取评分等级文本
  getScoreLabel(score) {
    const option = this.data.scoreOptions.find(opt => opt.value === score)
    return option ? option.label : `${score}分`
  },
  // 获取文件大小文本
@@ -610,9 +578,47 @@
    }
  },
  // 统一处理性别显示文本
  getGenderLabel(gender) {
    if (gender === null || gender === undefined || gender === '') {
      return '未填写'
    }
    const normalized = String(gender).trim().toLowerCase()
    if (normalized === '') {
      return '未填写'
    }
    if (normalized === 'male' || normalized === 'm') {
      return '男'
    }
    if (normalized === 'female' || normalized === 'f') {
      return '女'
    }
    if (/^-?\d+$/.test(normalized)) {
      const numeric = Number(normalized)
      if (numeric === 1) return '男'
      if (numeric === 0) return '女'
      if (numeric === 2) return '女'
    }
    return gender === undefined || gender === null ? '未填写' : String(gender)
  },
  // 统一处理出生日期显示文本
  getBirthdayText(dateString) {
    if (!dateString) {
      return '未填写'
    }
    const formatted = formatDateUtil(dateString, 'YYYY-MM-DD')
    return formatted || '未填写'
  },
  // 格式化日期
  formatDate(dateString) {
    return formatDate(dateString, 'YYYY-MM-DD HH:mm')
    return formatDateUtil(dateString, 'YYYY-MM-DD HH:mm')
  },
  // 分享页面
@@ -622,4 +628,4 @@
      path: '/pages/index/index'
    }
  }
})
})
wx/pages/judge/review.wxml
@@ -16,32 +16,33 @@
          {{reviewStatus === 'COMPLETED' ? '已评审' : '待评审'}}
        </view>
      </view>
      <view class="submission-detail">
        <text class="submission-title">{{submission.title}}</text>
        <text class="submission-desc">{{submission.description}}</text>
        <text class="submission-desc">{{submission.description || '暂无项目描述'}}</text>
        <!-- 参赛者信息 -->
        <view class="participant-info">
          <view class="participant-header">
            <text class="participant-label">参赛者:</text>
            <view class="contact-btn" bindtap="onContactParticipant">
              <text class="contact-icon">💬</text>
              <text class="contact-text">联系</text>
              <text class="contact-icon">📞</text>
              <text class="contact-text">拨打电话</text>
            </view>
          </view>
          <view class="participant-detail">
            <image class="participant-avatar" src="{{submission.participant.avatar}}" mode="aspectFill"></image>
            <view class="participant-text">
              <text class="participant-name">{{submission.participant.name}}</text>
              <text class="participant-school">{{submission.participant.school}} - {{submission.participant.major}}</text>
              <text class="participant-name">{{submission.participant.name || '匿名'}}</text>
              <text class="participant-meta">性别:{{submission.participant.gender || '未填写'}},出生日期:{{submission.participant.birthday || '未填写'}}</text>
              <text class="participant-field">所属区域:{{submission.participant.region || '未填写'}}</text>
              <text class="participant-field">学历:{{submission.participant.education || '未填写'}}</text>
            </view>
          </view>
          <!-- 团队信息 -->
          <view wx:if="{{submission.team}}" class="team-info">
            <text class="team-label">团队:{{submission.team.name}}</text>
          <view wx:if="{{submission.team && submission.team.members}}" class="team-info">
            <text class="team-label">团队:{{submission.team.name || '未命名团队'}}</text>
            <view class="team-members">
              <view wx:for="{{submission.team.members}}" wx:key="id" class="team-member">
                <text class="member-name">{{item.name}}</text>
@@ -50,74 +51,36 @@
            </view>
          </view>
        </view>
        <text class="submit-time">提交时间:{{formatDate(submission.submittedAt)}}</text>
      </view>
    </view>
    <!-- 作品媒体 -->
    <view wx:if="{{submission.images.length > 0 || submission.videos.length > 0}}" class="media-section">
      <text class="section-title">作品展示</text>
      <!-- 图片 -->
      <view wx:if="{{submission.images.length > 0}}" class="media-grid">
        <view
          wx:for="{{submission.images}}"
          wx:key="*this"
          class="media-item image-item"
          data-url="{{item}}"
          data-type="image"
          bindtap="onMediaTap"
        >
          <image class="media-image" src="{{item}}" mode="aspectFill"></image>
        </view>
      </view>
      <!-- 视频 -->
      <view wx:if="{{submission.videos.length > 0}}" class="media-grid">
        <view
          wx:for="{{submission.videos}}"
          wx:key="*this"
          class="media-item video-item"
          data-url="{{item}}"
          data-type="video"
          bindtap="onMediaTap"
        >
          <video class="media-video" src="{{item}}" poster="{{item}}" controls></video>
          <view class="play-overlay">
            <text class="play-icon">▶</text>
          </view>
        </view>
      </view>
    </view>
    <!-- 作品文件 -->
    <view wx:if="{{submission.files.length > 0}}" class="files-section">
      <text class="section-title">作品文件</text>
      <view class="file-list">
        <view
          wx:for="{{submission.files}}"
    <!-- 作品素材 -->
    <view wx:if="{{submission.mediaList && submission.mediaList.length > 0}}" class="media-section">
      <text class="section-title">参赛作品</text>
      <view class="media-list">
        <view
          wx:for="{{submission.mediaList}}"
          wx:key="id"
          class="file-item"
          class="media-item"
          bindtap="onMediaTap"
          data-index="{{index}}"
        >
          <view class="file-info">
            <text class="file-icon">📄</text>
            <view class="file-detail">
              <text class="file-name">{{item.name}}</text>
              <text class="file-size">{{getFileSizeText(item.size)}}</text>
            </view>
          <view class="media-thumb-wrapper">
            <image
              wx:if="{{item.mediaType === 'image' || item.mediaType === 'video'}}"
              class="media-thumb"
              src="{{item.thumbUrl}}"
              mode="aspectFill"
            />
            <view wx:elif="{{item.mediaType === 'pdf'}}" class="media-icon pdf">PDF</view>
            <view wx:elif="{{item.mediaType === 'word'}}" class="media-icon doc">DOC</view>
            <view wx:else class="media-icon file">FILE</view>
            <view wx:if="{{item.mediaType === 'video'}}" class="media-play">▶</view>
          </view>
          <view
            class="download-btn {{item.isDownloading ? 'downloading' : ''}}"
            data-file-id="{{item.id}}"
            data-file-name="{{item.name}}"
            data-file-url="{{item.url}}"
            bindtap="onDownloadFile"
          >
            <text class="download-icon">{{item.isDownloading ? '⏳' : '⬇'}}</text>
            <text class="download-text">{{item.isDownloading ? '下载中' : '下载'}}</text>
          <view class="media-info">
            <text class="media-name">{{item.name}}</text>
            <text class="media-size">{{getFileSizeText(item.size)}}</text>
          </view>
        </view>
      </view>
@@ -126,84 +89,70 @@
    <!-- 评审标准 -->
    <view class="criteria-section">
      <text class="section-title">评审标准</text>
      <view class="criteria-list">
        <view wx:for="{{criteria}}" wx:key="id" class="criterion-item">
          <view class="criterion-header">
            <text class="criterion-name">{{item.name}}</text>
            <text class="criterion-score">{{scores[item.id] || 0}}/{{item.maxScore}}分</text>
            <text class="criterion-score">{{scores[item.id] || 0}} / {{item.maxScore}} 分</text>
          </view>
          <text class="criterion-desc">{{item.description}}</text>
          <!-- 评分选择器 -->
          <view class="score-selector">
            <picker
              range="{{scoreOptions}}"
              range-key="label"
              value="{{(scores[item.id] || 1) - 1}}"
          <text class="criterion-desc">{{item.description || '暂无评分说明'}}</text>
          <view class="score-control">
            <view class="score-btn" data-criterion-id="{{item.id}}" data-index="{{index}}" bindtap="decreaseScore">-</view>
            <input
              class="score-input"
              type="digit"
              value="{{scores[item.id] || 0}}"
              data-criterion-id="{{item.id}}"
              bindchange="onScoreChange"
              disabled="{{reviewStatus === 'COMPLETED'}}"
            >
              <view class="score-picker {{reviewStatus === 'COMPLETED' ? 'disabled' : ''}}">
                <text class="score-text">{{getScoreLabel(scores[item.id] || 0)}}</text>
                <text class="picker-arrow">{{reviewStatus === 'COMPLETED' ? '' : '▼'}}</text>
              </view>
            </picker>
              data-index="{{index}}"
              bindinput="onScoreChange"
              placeholder="0"
            />
            <view class="score-btn" data-criterion-id="{{item.id}}" data-index="{{index}}" bindtap="increaseScore">+</view>
          </view>
        </view>
      </view>
      <!-- 总分显示 -->
      <view class="total-score">
        <text class="total-label">总分:</text>
        <text class="total-value">{{totalScore}}/{{maxScore}}分</text>
        <text class="total-value">{{totalScore}} / {{maxScore}} 分</text>
      </view>
    </view>
    <!-- 评审意见 -->
    <view class="comment-section">
      <text class="section-title">评审意见</text>
      <textarea
        class="comment-input {{reviewStatus === 'COMPLETED' ? 'disabled' : ''}}"
        placeholder="请填写详细的评审意见,包括作品的优点、不足和改进建议..."
      <textarea
        class="comment-input"
        placeholder="请填写详细评审意见,包括作品优点、不足和改进建议..."
        value="{{comment}}"
        maxlength="1000"
        show-confirm-bar="{{false}}"
        disabled="{{reviewStatus === 'COMPLETED'}}"
        bindinput="onCommentInput"
      ></textarea>
      <view class="comment-counter">
        <text class="counter-text">{{comment.length}}/1000</text>
        <text class="counter-text">{{comment.length}} / 1000</text>
      </view>
    </view>
    <!-- 已有评审记录 -->
    <!-- 既有评审 -->
    <view wx:if="{{existingReview}}" class="existing-review">
      <text class="section-title">评审记录</text>
      <text class="section-title">历史评审</text>
      <view class="review-info">
        <text class="review-time">评审时间:{{formatDate(existingReview.reviewedAt)}}</text>
        <text class="review-total">总分:{{existingReview.totalScore}}/{{maxScore}}分</text>
        <text class="review-time">评审时间:{{formatDate(existingReview.reviewedAt) || '未知'}}</text>
        <text class="review-total">总分:{{existingReview.totalScore}} / {{maxScore}} 分</text>
      </view>
      <view class="other-reviews-btn" bindtap="onViewOtherReviews">
        <text class="other-reviews-icon">👥</text>
        <text class="other-reviews-text">查看其他评审</text>
        <text class="other-reviews-text">查看其他评委评分</text>
      </view>
    </view>
    <!-- 底部操作栏 -->
    <view wx:if="{{reviewStatus !== 'COMPLETED'}}" class="bottom-actions">
      <view class="action-btn draft-btn" bindtap="onSaveDraft">
        <text class="btn-icon">💾</text>
        <text class="btn-text">保存草稿</text>
      </view>
      <view
    <!-- 底部操作 -->
    <view class="bottom-actions">
      <view
        class="action-btn submit-btn {{submitting ? 'submitting' : ''}}"
        bindtap="onSubmitReview"
      >
@@ -212,27 +161,4 @@
      </view>
    </view>
  </view>
  <!-- 视频预览模态框 -->
  <view wx:if="{{showMediaPreview}}" class="media-preview-modal">
    <view class="modal-overlay" bindtap="onCloseMediaPreview"></view>
    <view class="modal-content">
      <view class="modal-header">
        <text class="modal-title">视频预览</text>
        <view class="close-btn" bindtap="onCloseMediaPreview">
          <text class="close-icon">✕</text>
        </view>
      </view>
      <view class="modal-body">
        <video
          wx:if="{{mediaType === 'video'}}"
          class="preview-video"
          src="{{currentMedia}}"
          controls
          autoplay
        ></video>
      </view>
    </view>
  </view>
</view>
</view>
wx/pages/judge/review.wxss
@@ -5,7 +5,7 @@
  padding-bottom: 120rpx;
}
/* 加载状态 */
/* Loading */
.loading-container {
  display: flex;
  flex-direction: column;
@@ -34,12 +34,10 @@
  100% { transform: rotate(360deg); }
}
/* 评审内容 */
.review-content {
  padding: 30rpx;
}
/* 通用样式 */
.section-title {
  font-size: 32rpx;
  font-weight: 600;
@@ -48,8 +46,11 @@
  display: block;
}
/* 作品信息 */
.submission-info {
.submission-info,
.media-section,
.criteria-section,
.comment-section,
.existing-review {
  background-color: #fff;
  border-radius: 16rpx;
  padding: 30rpx;
@@ -87,12 +88,6 @@
  color: #155724;
}
.submission-detail {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}
.submission-title {
  font-size: 30rpx;
  font-weight: 600;
@@ -128,15 +123,12 @@
  display: flex;
  align-items: center;
  gap: 8rpx;
  padding: 8rpx 16rpx;
  background-color: #007aff;
  padding: 10rpx 24rpx;
  background: linear-gradient(135deg, #34c759 0%, #2ea043 100%);
  color: #fff;
  border-radius: 16rpx;
  font-size: 22rpx;
}
.contact-icon {
  font-size: 20rpx;
  border-radius: 24rpx;
  font-size: 24rpx;
  box-shadow: 0 6rpx 16rpx rgba(46, 160, 67, 0.25);
}
.participant-detail {
@@ -146,13 +138,13 @@
}
.participant-avatar {
  width: 80rpx;
  height: 80rpx;
  border-radius: 40rpx;
  width: 96rpx;
  height: 96rpx;
  border-radius: 48rpx;
  background-color: #f0f0f0;
}
.participant-text {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 8rpx;
@@ -160,196 +152,127 @@
.participant-name {
  font-size: 28rpx;
  font-weight: 500;
  font-weight: 600;
  color: #333;
}
.participant-school {
.participant-meta {
  font-size: 24rpx;
  color: #555;
  line-height: 1.4;
}
.participant-field {
  font-size: 24rpx;
  color: #666;
  line-height: 1.4;
}
.team-info {
  margin-top: 20rpx;
  padding-top: 20rpx;
  border-top: 1rpx solid #f0f0f0;
  background-color: #f8f9fa;
  border-radius: 12rpx;
  padding: 20rpx;
}
.team-label {
  font-size: 26rpx;
  color: #666;
  font-weight: 500;
  margin-bottom: 16rpx;
  display: block;
  font-weight: 600;
  color: #333;
}
.team-members {
  margin-top: 12rpx;
  display: flex;
  flex-wrap: wrap;
  gap: 16rpx;
  flex-direction: column;
  gap: 8rpx;
}
.team-member {
  font-size: 24rpx;
  color: #555;
}
.media-list {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 12rpx 20rpx;
  background-color: #f8f9fa;
  border-radius: 12rpx;
}
.member-name {
  font-size: 24rpx;
  color: #333;
  font-weight: 500;
}
.member-role {
  font-size: 20rpx;
  color: #666;
  margin-top: 4rpx;
}
.submit-time {
  font-size: 24rpx;
  color: #999;
  margin-top: 10rpx;
}
/* 媒体展示 */
.media-section {
  background-color: #fff;
  border-radius: 16rpx;
  padding: 30rpx;
  margin-bottom: 30rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.media-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 20rpx;
}
.media-item {
  position: relative;
  border-radius: 12rpx;
  overflow: hidden;
  aspect-ratio: 1;
}
.media-image,
.media-video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.play-overlay {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 60rpx;
  height: 60rpx;
  background-color: rgba(0, 0, 0, 0.6);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.play-icon {
  color: #fff;
  font-size: 24rpx;
  margin-left: 4rpx;
}
/* 文件列表 */
.files-section {
  background-color: #fff;
  border-radius: 16rpx;
  padding: 30rpx;
  margin-bottom: 30rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.file-list {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}
.file-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 24rpx;
  padding: 20rpx;
  background-color: #f8f9fa;
  border-radius: 12rpx;
}
.file-info {
.media-thumb-wrapper {
  width: 120rpx;
  height: 120rpx;
  border-radius: 12rpx;
  overflow: hidden;
  position: relative;
  background: #eaeaea;
}
.media-thumb {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.media-icon {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  gap: 16rpx;
  flex: 1;
  min-width: 0;
}
.file-icon {
  justify-content: center;
  font-size: 32rpx;
  font-weight: 600;
  color: #fff;
}
.file-detail {
.media-icon.pdf { background: #e74c3c; }
.media-icon.doc { background: #1e88e5; }
.media-icon.file { background: #6c757d; }
.media-play {
  position: absolute;
  bottom: 8rpx;
  right: 8rpx;
  width: 44rpx;
  height: 44rpx;
  background: rgba(0, 0, 0, 0.6);
  border-radius: 50%;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24rpx;
}
.media-info {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 4rpx;
  gap: 6rpx;
}
.file-name {
  font-size: 26rpx;
.media-name {
  font-size: 28rpx;
  font-weight: 600;
  color: #333;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.file-size {
.media-size {
  font-size: 22rpx;
  color: #666;
}
.download-btn {
  display: flex;
  align-items: center;
  gap: 8rpx;
  padding: 12rpx 20rpx;
  background-color: #007aff;
  color: #fff;
  border-radius: 20rpx;
  font-size: 22rpx;
  transition: all 0.3s ease;
}
.download-btn.downloading {
  background-color: #6c757d;
}
.download-icon {
  font-size: 20rpx;
}
/* 评审标准 */
.criteria-section {
  background-color: #fff;
  border-radius: 16rpx;
  padding: 30rpx;
  margin-bottom: 30rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.criteria-list {
@@ -392,33 +315,35 @@
  display: block;
}
.score-selector {
  width: 100%;
}
.score-picker {
.score-control {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16rpx 20rpx;
  background-color: #fff;
  border: 2rpx solid #e0e0e0;
  border-radius: 8rpx;
  font-size: 26rpx;
  gap: 20rpx;
  margin-top: 16rpx;
}
.score-picker.disabled {
  background-color: #f5f5f5;
  color: #999;
.score-btn {
  width: 60rpx;
  height: 60rpx;
  border-radius: 12rpx;
  background: #eef2ff;
  color: #4c60ff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 36rpx;
  font-weight: 600;
}
.score-text {
  color: #333;
}
.picker-arrow {
  color: #999;
  font-size: 20rpx;
.score-input {
  flex: 1;
  height: 60rpx;
  border: 2rpx solid #dcdfe6;
  border-radius: 12rpx;
  text-align: center;
  font-size: 28rpx;
  padding: 0 12rpx;
  background: #fff;
}
.total-score {
@@ -444,15 +369,6 @@
  font-weight: 700;
}
/* 评审意见 */
.comment-section {
  background-color: #fff;
  border-radius: 16rpx;
  padding: 30rpx;
  margin-bottom: 30rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.comment-input {
  width: 100%;
  min-height: 200rpx;
@@ -466,29 +382,16 @@
  box-sizing: border-box;
}
.comment-input.disabled {
  background-color: #f5f5f5;
  color: #999;
}
.comment-counter {
  display: flex;
  justify-content: flex-end;
  text-align: right;
  margin-top: 12rpx;
}
.counter-text {
  font-size: 22rpx;
  color: #999;
}
/* 已有评审记录 */
.existing-review {
  background-color: #fff;
  border-radius: 16rpx;
  padding: 30rpx;
  margin-bottom: 30rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
  background-color: #fff7e6;
  border: 2rpx solid #ffb347;
}
.review-info {
@@ -496,49 +399,35 @@
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20rpx;
  padding: 20rpx;
  background-color: #f8f9fa;
  border-radius: 12rpx;
}
.review-time,
.review-total {
  font-size: 24rpx;
  font-size: 26rpx;
  color: #666;
}
.other-reviews-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12rpx;
  padding: 16rpx;
  background-color: #007aff;
  gap: 8rpx;
  padding: 14rpx 24rpx;
  border-radius: 24rpx;
  background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
  color: #fff;
  border-radius: 12rpx;
  font-size: 26rpx;
}
.other-reviews-icon {
  font-size: 24rpx;
}
/* 底部操作栏 */
.bottom-actions {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  gap: 20rpx;
  bottom: 0;
  padding: 20rpx 30rpx;
  background-color: #fff;
  border-top: 1rpx solid #e0e0e0;
  box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.1);
  background-color: rgba(255, 255, 255, 0.95);
  box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
  display: flex;
  justify-content: center;
  align-items: stretch;
}
.action-btn {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
@@ -548,11 +437,8 @@
  font-size: 28rpx;
  font-weight: 500;
  transition: all 0.3s ease;
}
.draft-btn {
  background-color: #6c757d;
  color: #fff;
  flex: 1;
  width: 100%;
}
.submit-btn {
@@ -568,101 +454,25 @@
  font-size: 24rpx;
}
/* 视频预览模态框 */
.media-preview-modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1000;
}
.modal-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.8);
}
.modal-content {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 90%;
  max-width: 600rpx;
  background-color: #fff;
  border-radius: 16rpx;
  overflow: hidden;
}
.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20rpx 30rpx;
  border-bottom: 1rpx solid #e0e0e0;
}
.modal-title {
  font-size: 30rpx;
  font-weight: 600;
  color: #333;
}
.close-btn {
  width: 60rpx;
  height: 60rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background-color: #f8f9fa;
}
.close-icon {
  font-size: 24rpx;
  color: #666;
}
.modal-body {
  padding: 30rpx;
}
.preview-video {
  width: 100%;
  height: 400rpx;
  border-radius: 12rpx;
}
/* 响应式适配 */
@media (max-width: 375px) {
  .container {
    padding-bottom: 100rpx;
  }
  .review-content {
    padding: 20rpx;
  }
  .submission-info,
  .media-section,
  .files-section,
  .criteria-section,
  .comment-section,
  .existing-review {
    padding: 20rpx;
    margin-bottom: 20rpx;
  }
  .media-grid {
    grid-template-columns: 1fr;
  }
  .bottom-actions {
    padding: 15rpx 20rpx;
  }
}
}
wx/pages/message/message.js
@@ -21,8 +21,22 @@
  // 加载消息列表
  loadMessages() {
    // 检查用户是否已登录
    const userInfo = app.globalData.userInfo
    // 检查用户是否已登录,如果globalData中没有,尝试从本地存储恢复
    let userInfo = app.globalData.userInfo
    if (!userInfo || !userInfo.userId) {
      console.log('globalData中没有userInfo,尝试从本地存储恢复')
      try {
        const storedUserInfo = wx.getStorageSync('userInfo')
        if (storedUserInfo && storedUserInfo.userId) {
          console.log('从本地存储恢复userInfo成功')
          app.globalData.userInfo = storedUserInfo
          userInfo = storedUserInfo
        }
      } catch (error) {
        console.error('从本地存储恢复userInfo失败:', error)
      }
    }
    if (!userInfo || !userInfo.userId) {
      console.error('用户未登录或userId不存在')
      wx.showToast({
wx/pages/project/detail.js
@@ -597,4 +597,4 @@
      path: `/pages/project/detail?id=${this.data.projectId}`
    }
  }
})
})
wx/pages/review/index.js
@@ -2,6 +2,14 @@
const app = getApp()
const { graphqlRequest, formatDate } = require('../../lib/utils')
const GET_RATING_STATS_QUERY = `
  query GetRatingStats($activityPlayerId: ID!) {
    judgeRatingsForPlayer(activityPlayerId: $activityPlayerId) {
      hasRated
    }
  }
`
Page({
  data: {
    loading: false,
@@ -55,7 +63,7 @@
  // 切换选项卡
  switchTab(e) {
    const index = e.currentTarget.dataset.index
    const index = parseInt(e.currentTarget.dataset.index) || 0
    if (index === this.data.currentTab) return
    
    this.setData({
@@ -110,10 +118,29 @@
      ])
    } catch (error) {
      console.error('加载数据失败:', error)
      wx.showToast({
        title: '加载失败',
        icon: 'none'
      })
      // 检查是否是认证相关错误
      if (error.message && (
        error.message.includes('没有权限访问') ||
        error.message.includes('请先登录') ||
        error.message.includes('重新登录')
      )) {
        wx.showToast({
          title: '正在重新登录...',
          icon: 'loading',
          duration: 2000
        })
        // 等待一段时间后重试
        setTimeout(() => {
          this.loadData()
        }, 2000)
      } else {
        wx.showToast({
          title: '加载失败,请重试',
          icon: 'none'
        })
      }
    } finally {
      this.setData({ loading: false })
      wx.stopPullDownRefresh()
@@ -223,13 +250,13 @@
        const projects = data.items.map(item => ({
          ...item,
          submitTime: item.submitTime ? formatDate(item.submitTime) : '',
          reviewTime: item.reviewTime ? formatDate(item.reviewTime) : '',
          statusText: this.getStatusText(item.status),
          statusType: this.getStatusType(item.status)
          reviewTime: item.reviewTime ? formatDate(item.reviewTime) : ''
        }))
        const projectsWithRatingCount = await this.enrichProjectsWithRatingCounts(projects)
        this.setData({
          projectList: isLoadMore ? [...this.data.projectList, ...projects] : projects,
          projectList: isLoadMore ? [...this.data.projectList, ...projectsWithRatingCount] : projectsWithRatingCount,
          hasMore: data.hasMore || false,
          currentPage: variables.page
        })
@@ -274,26 +301,39 @@
    }
  },
  // 获取状态文本
  getStatusText(status) {
    const statusMap = {
      'SUBMITTED': '已提交',
      'UNDER_REVIEW': '评审中',
      'REVIEWED': '已评审',
      'REJECTED': '已拒绝'
  async enrichProjectsWithRatingCounts(projects) {
    if (!Array.isArray(projects) || projects.length === 0) {
      return projects || []
    }
    return statusMap[status] || status
    try {
      const counts = await Promise.all(projects.map(project => this.getProjectRatingCount(project.id)))
      return projects.map((project, index) => ({
        ...project,
        ratingCount: counts[index]
      }))
    } catch (error) {
      console.error('批量获取评审次数失败:', error)
      return projects.map(project => ({
        ...project,
        ratingCount: typeof project.ratingCount === 'number' ? project.ratingCount : 0
      }))
    }
  },
  // 获取状态类型
  getStatusType(status) {
    const typeMap = {
      'SUBMITTED': 'info',
      'UNDER_REVIEW': 'warning',
      'REVIEWED': 'success',
      'REJECTED': 'danger'
  async getProjectRatingCount(activityPlayerId) {
    if (!activityPlayerId) {
      return 0
    }
    return typeMap[status] || 'info'
    try {
      const result = await graphqlRequest(GET_RATING_STATS_QUERY, { activityPlayerId })
      const ratings = result?.judgeRatingsForPlayer || []
      return ratings.filter(item => item?.hasRated).length
    } catch (error) {
      console.error(`获取项目 ${activityPlayerId} 的评审次数失败:`, error)
      return 0
    }
  },
  // 获取空状态文本
@@ -323,4 +363,4 @@
    ]
    return emptyDescs[currentTab] || ''
  }
})
})
wx/pages/review/index.wxml
@@ -74,8 +74,9 @@
        <view class="project-info">
          <view class="project-header">
            <text class="project-name">{{project.projectName || '未命名项目'}}</text>
            <view class="project-status" wx:if="{{project.status}}">
              <text class="status-text status-{{project.statusType}}">{{project.statusText}}</text>
            <view class="review-count" wx:if="{{project.ratingCount !== undefined}}">
              <text class="review-count-label">评审次数:</text>
              <text class="review-count-value">{{project.ratingCount}}</text>
            </view>
          </view>
          
@@ -87,13 +88,13 @@
        </view>
        <view class="project-actions">
          <view
            class="review-btn"
            bindtap="goToReviewDetail"
            data-project="{{project}}"
          >
            <text class="btn-text">评审</text>
          </view>
        <view
          class="review-btn"
          bindtap="goToReviewDetail"
          data-activity-player-id="{{project.activityPlayerId || project.id}}"
        >
          <text class="btn-text">评审</text>
        </view>
        </view>
      </view>
    </view>
@@ -104,4 +105,4 @@
      <text class="load-more-text" wx:else>加载中...</text>
    </view>
  </view>
</view>
</view>
wx/pages/review/index.wxss
@@ -204,30 +204,25 @@
  margin-right: 20rpx;
}
.project-status {
.review-count {
  flex-shrink: 0;
}
.status-text {
  font-size: 24rpx;
  display: flex;
  align-items: center;
  padding: 6rpx 12rpx;
  background: #f1f5ff;
  border-radius: 12rpx;
  font-weight: 500;
  gap: 6rpx;
}
.status-text.status-warning {
  background: #fff3e0;
  color: #f57c00;
.review-count-label {
  font-size: 24rpx;
  color: #5a6c90;
}
.status-text.status-success {
  background: #e8f5e8;
  color: #388e3c;
}
.status-text.status-info {
  background: #e3f2fd;
  color: #1976d2;
.review-count-value {
  font-size: 28rpx;
  color: #1a3a7b;
  font-weight: 600;
}
.project-details {
@@ -286,4 +281,4 @@
.load-more-text {
  font-size: 26rpx;
  color: #999;
}
}