lrj
2025-10-05 8337c34fcc761d07acaad796d10f3e12e9bbe2d1
feat: 微信项目详情支持阶段评分时间轴
6个文件已修改
5个文件已添加
1286 ■■■■■ 已修改文件
backend/src/main/java/com/rongyichuang/player/api/PlayerGraphqlApi.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/dto/response/ProjectStageTimelineItemResponse.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/dto/response/ProjectStageTimelineResponse.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/dto/response/StageJudgeRatingDetailResponse.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/dto/response/StageJudgeRatingItemResponse.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/repository/ActivityPlayerRepository.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/service/ProjectStageRatingService.java 260 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/graphql/player.graphqls 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/project/detail.js 294 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/project/detail.wxml 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/project/detail.wxss 273 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/api/PlayerGraphqlApi.java
@@ -6,12 +6,14 @@
import com.rongyichuang.player.dto.response.ActivityPlayerApplicationResponse;
import com.rongyichuang.player.dto.response.ActivityPlayerDetailResponse;
import com.rongyichuang.player.dto.response.ProjectReviewApplicationPageResponse;
import com.rongyichuang.player.dto.response.ProjectStageTimelineResponse;
import com.rongyichuang.player.dto.response.PlayerApplicationPageResponse;
import com.rongyichuang.player.dto.ActivityRegistrationResponse;
import com.rongyichuang.player.dto.response.JudgeRatingStatusResponse;
import com.rongyichuang.player.dto.response.CurrentJudgeRatingResponse;
import com.rongyichuang.player.dto.response.CurrentJudgeInfoResponse;
import com.rongyichuang.player.dto.response.PlayerRegistrationResponse;
import com.rongyichuang.player.dto.response.StageJudgeRatingDetailResponse;
import com.rongyichuang.player.dto.PromotionCompetitionResponse;
import com.rongyichuang.player.dto.CompetitionParticipantResponse;
import com.rongyichuang.player.dto.PromotionInput;
@@ -22,6 +24,7 @@
import com.rongyichuang.player.service.ActivityPlayerDetailService;
import com.rongyichuang.player.service.ActivityPlayerRatingService;
import com.rongyichuang.player.service.ActivityPlayerService;
import com.rongyichuang.player.service.ProjectStageRatingService;
import com.rongyichuang.player.service.PromotionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -43,17 +46,20 @@
    private final ActivityPlayerRatingService ratingService;
    private final ActivityPlayerService activityPlayerService;
    private final PromotionService promotionService;
    private final ProjectStageRatingService projectStageRatingService;
    public PlayerGraphqlApi(PlayerApplicationService service,
                           ActivityPlayerDetailService detailService,
                           ActivityPlayerRatingService ratingService,
                           ActivityPlayerService activityPlayerService,
                           PromotionService promotionService) {
                           PromotionService promotionService,
                           ProjectStageRatingService projectStageRatingService) {
        this.service = service;
        this.detailService = detailService;
        this.ratingService = ratingService;
        this.activityPlayerService = activityPlayerService;
        this.promotionService = promotionService;
        this.projectStageRatingService = projectStageRatingService;
    }
    @QueryMapping
@@ -98,6 +104,18 @@
        return detailService.getDetailForRating(id);
    }
    @QueryMapping
    public ProjectStageTimelineResponse projectStageTimeline(@Argument Long activityPlayerId) {
        log.info("获取参赛项目阶段时间轴,activityPlayerId: {}", activityPlayerId);
        return projectStageRatingService.getProjectStageTimeline(activityPlayerId);
    }
    @QueryMapping
    public StageJudgeRatingDetailResponse stageJudgeRatings(@Argument Long activityPlayerId) {
        log.info("获取阶段评分详情,activityPlayerId: {}", activityPlayerId);
        return projectStageRatingService.getStageJudgeRatings(activityPlayerId);
    }
    /**
     * 查询玩家在指定活动中的报名状态
     */
backend/src/main/java/com/rongyichuang/player/dto/response/ProjectStageTimelineItemResponse.java
New file
@@ -0,0 +1,98 @@
package com.rongyichuang.player.dto.response;
/**
 * 项目阶段时间轴条目响应
 */
public class ProjectStageTimelineItemResponse {
    private Long stageId;
    private String stageName;
    private String matchTime;
    private Integer sortOrder;
    private boolean participated;
    private Long activityPlayerId;
    private Double averageScore;
    private Integer ratingCount = 0;
    private boolean hasRating;
    private String latestRatingTime;
    public Long getStageId() {
        return stageId;
    }
    public void setStageId(Long stageId) {
        this.stageId = stageId;
    }
    public String getStageName() {
        return stageName;
    }
    public void setStageName(String stageName) {
        this.stageName = stageName;
    }
    public String getMatchTime() {
        return matchTime;
    }
    public void setMatchTime(String matchTime) {
        this.matchTime = matchTime;
    }
    public Integer getSortOrder() {
        return sortOrder;
    }
    public void setSortOrder(Integer sortOrder) {
        this.sortOrder = sortOrder;
    }
    public boolean isParticipated() {
        return participated;
    }
    public void setParticipated(boolean participated) {
        this.participated = participated;
    }
    public Long getActivityPlayerId() {
        return activityPlayerId;
    }
    public void setActivityPlayerId(Long activityPlayerId) {
        this.activityPlayerId = activityPlayerId;
    }
    public Double getAverageScore() {
        return averageScore;
    }
    public void setAverageScore(Double averageScore) {
        this.averageScore = averageScore;
    }
    public Integer getRatingCount() {
        return ratingCount;
    }
    public void setRatingCount(Integer ratingCount) {
        this.ratingCount = ratingCount;
    }
    public boolean isHasRating() {
        return hasRating;
    }
    public void setHasRating(boolean hasRating) {
        this.hasRating = hasRating;
    }
    public String getLatestRatingTime() {
        return latestRatingTime;
    }
    public void setLatestRatingTime(String latestRatingTime) {
        this.latestRatingTime = latestRatingTime;
    }
}
backend/src/main/java/com/rongyichuang/player/dto/response/ProjectStageTimelineResponse.java
New file
@@ -0,0 +1,38 @@
package com.rongyichuang.player.dto.response;
import java.util.ArrayList;
import java.util.List;
/**
 * 项目阶段时间轴响应
 */
public class ProjectStageTimelineResponse {
    private Long activityId;
    private String activityName;
    private List<ProjectStageTimelineItemResponse> stages = new ArrayList<>();
    public Long getActivityId() {
        return activityId;
    }
    public void setActivityId(Long activityId) {
        this.activityId = activityId;
    }
    public String getActivityName() {
        return activityName;
    }
    public void setActivityName(String activityName) {
        this.activityName = activityName;
    }
    public List<ProjectStageTimelineItemResponse> getStages() {
        return stages;
    }
    public void setStages(List<ProjectStageTimelineItemResponse> stages) {
        this.stages = stages;
    }
}
backend/src/main/java/com/rongyichuang/player/dto/response/StageJudgeRatingDetailResponse.java
New file
@@ -0,0 +1,74 @@
package com.rongyichuang.player.dto.response;
import java.util.ArrayList;
import java.util.List;
/**
 * 项目阶段评分详情响应
 */
public class StageJudgeRatingDetailResponse {
    private Long activityPlayerId;
    private Long stageId;
    private String stageName;
    private String matchTime;
    private Integer ratingCount = 0;
    private Double averageScore;
    private List<StageJudgeRatingItemResponse> judgeRatings = new ArrayList<>();
    public Long getActivityPlayerId() {
        return activityPlayerId;
    }
    public void setActivityPlayerId(Long activityPlayerId) {
        this.activityPlayerId = activityPlayerId;
    }
    public Long getStageId() {
        return stageId;
    }
    public void setStageId(Long stageId) {
        this.stageId = stageId;
    }
    public String getStageName() {
        return stageName;
    }
    public void setStageName(String stageName) {
        this.stageName = stageName;
    }
    public String getMatchTime() {
        return matchTime;
    }
    public void setMatchTime(String matchTime) {
        this.matchTime = matchTime;
    }
    public Integer getRatingCount() {
        return ratingCount;
    }
    public void setRatingCount(Integer ratingCount) {
        this.ratingCount = ratingCount;
    }
    public Double getAverageScore() {
        return averageScore;
    }
    public void setAverageScore(Double averageScore) {
        this.averageScore = averageScore;
    }
    public List<StageJudgeRatingItemResponse> getJudgeRatings() {
        return judgeRatings;
    }
    public void setJudgeRatings(List<StageJudgeRatingItemResponse> judgeRatings) {
        this.judgeRatings = judgeRatings;
    }
}
backend/src/main/java/com/rongyichuang/player/dto/response/StageJudgeRatingItemResponse.java
New file
@@ -0,0 +1,53 @@
package com.rongyichuang.player.dto.response;
/**
 * 阶段评委评分明细响应
 */
public class StageJudgeRatingItemResponse {
    private Long judgeId;
    private String judgeName;
    private Double totalScore;
    private String feedback;
    private String ratingTime;
    public Long getJudgeId() {
        return judgeId;
    }
    public void setJudgeId(Long judgeId) {
        this.judgeId = judgeId;
    }
    public String getJudgeName() {
        return judgeName;
    }
    public void setJudgeName(String judgeName) {
        this.judgeName = judgeName;
    }
    public Double getTotalScore() {
        return totalScore;
    }
    public void setTotalScore(Double totalScore) {
        this.totalScore = totalScore;
    }
    public String getFeedback() {
        return feedback;
    }
    public void setFeedback(String feedback) {
        this.feedback = feedback;
    }
    public String getRatingTime() {
        return ratingTime;
    }
    public void setRatingTime(String ratingTime) {
        this.ratingTime = ratingTime;
    }
}
backend/src/main/java/com/rongyichuang/player/repository/ActivityPlayerRepository.java
@@ -124,4 +124,9 @@
     * 检查选手是否已在指定阶段报名
     */
    boolean existsByStageIdAndPlayerId(Long stageId, Long playerId);
    /**
     * 根据阶段和选手查询报名记录
     */
    Optional<ActivityPlayer> findByStageIdAndPlayerId(Long stageId, Long playerId);
}
backend/src/main/java/com/rongyichuang/player/service/ProjectStageRatingService.java
New file
@@ -0,0 +1,260 @@
package com.rongyichuang.player.service;
import com.rongyichuang.activity.entity.Activity;
import com.rongyichuang.activity.entity.ActivityPlayerRating;
import com.rongyichuang.activity.repository.ActivityPlayerRatingRepository;
import com.rongyichuang.activity.repository.ActivityRepository;
import com.rongyichuang.judge.entity.Judge;
import com.rongyichuang.judge.repository.JudgeRepository;
import com.rongyichuang.player.dto.response.ProjectStageTimelineItemResponse;
import com.rongyichuang.player.dto.response.ProjectStageTimelineResponse;
import com.rongyichuang.player.dto.response.StageJudgeRatingDetailResponse;
import com.rongyichuang.player.dto.response.StageJudgeRatingItemResponse;
import com.rongyichuang.player.entity.ActivityPlayer;
import com.rongyichuang.player.repository.ActivityPlayerRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
 * 项目阶段评分相关服务
 */
@Service
public class ProjectStageRatingService {
    private static final Logger log = LoggerFactory.getLogger(ProjectStageRatingService.class);
    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    private final ActivityPlayerRepository activityPlayerRepository;
    private final ActivityRepository activityRepository;
    private final ActivityPlayerRatingRepository activityPlayerRatingRepository;
    private final JudgeRepository judgeRepository;
    public ProjectStageRatingService(ActivityPlayerRepository activityPlayerRepository,
                                     ActivityRepository activityRepository,
                                     ActivityPlayerRatingRepository activityPlayerRatingRepository,
                                     JudgeRepository judgeRepository) {
        this.activityPlayerRepository = activityPlayerRepository;
        this.activityRepository = activityRepository;
        this.activityPlayerRatingRepository = activityPlayerRatingRepository;
        this.judgeRepository = judgeRepository;
    }
    /**
     * 获取项目在各阶段的时间轴与评分概况
     */
    public ProjectStageTimelineResponse getProjectStageTimeline(Long activityPlayerId) {
        ActivityPlayer current = activityPlayerRepository.findById(activityPlayerId)
                .orElseThrow(() -> new IllegalArgumentException("未找到参赛记录,ID: " + activityPlayerId));
        Long activityId = current.getActivityId();
        Long playerId = current.getPlayerId();
        ProjectStageTimelineResponse response = new ProjectStageTimelineResponse();
        response.setActivityId(activityId);
        Activity activity = activityRepository.findActivityById(activityId);
        if (activity != null) {
            response.setActivityName(activity.getName());
        }
        List<Activity> stages = new ArrayList<>(activityRepository.findByPidAndStateOrderBySortOrderAsc(activityId, 1));
        Map<Long, ActivityPlayer> stagePlayerMap = activityPlayerRepository
                .findByActivityIdAndPlayerIdOrderByCreateTimeDesc(activityId, playerId)
                .stream()
                .filter(ap -> ap.getStageId() != null)
                .collect(Collectors.toMap(ActivityPlayer::getStageId, ap -> ap, (existing, replacement) -> existing));
        // 确保当前阶段包含在时间轴中
        if (current.getStageId() != null && !stagePlayerMap.containsKey(current.getStageId())) {
            stagePlayerMap.put(current.getStageId(), current);
        }
        // 如果阶段列表中缺少已参赛阶段,补全它们
        Set<Long> stageIdsInList = stages.stream().map(Activity::getId).collect(Collectors.toSet());
        for (Long stageId : stagePlayerMap.keySet()) {
            if (!stageIdsInList.contains(stageId)) {
                Activity stage = activityRepository.findActivityById(stageId);
                if (stage != null) {
                    stages.add(stage);
                    stageIdsInList.add(stageId);
                }
            }
        }
        // 按 sortOrder 及创建时间排序,确保展示顺序
        stages.sort(Comparator
                .comparing((Activity stage) -> Optional.ofNullable(stage.getSortOrder()).orElse(Integer.MAX_VALUE))
                .thenComparing(Activity::getId));
        List<ProjectStageTimelineItemResponse> items = new ArrayList<>();
        for (Activity stage : stages) {
            ProjectStageTimelineItemResponse item = new ProjectStageTimelineItemResponse();
            item.setStageId(stage.getId());
            item.setStageName(stage.getName());
            item.setMatchTime(formatDateTime(stage.getMatchTime()));
            item.setSortOrder(stage.getSortOrder());
            ActivityPlayer stagePlayer = stagePlayerMap.get(stage.getId());
            boolean participated = stagePlayer != null;
            item.setParticipated(participated);
            if (participated) {
                item.setActivityPlayerId(stagePlayer.getId());
                applyRatingMetrics(item, stagePlayer.getId());
            } else {
                item.setHasRating(false);
                item.setRatingCount(0);
            }
            items.add(item);
        }
        response.setStages(items);
        return response;
    }
    /**
     * 获取指定阶段的评委评分详情
     */
    public StageJudgeRatingDetailResponse getStageJudgeRatings(Long activityPlayerId) {
        ActivityPlayer stagePlayer = activityPlayerRepository.findById(activityPlayerId)
                .orElseThrow(() -> new IllegalArgumentException("未找到参赛记录,ID: " + activityPlayerId));
        StageJudgeRatingDetailResponse response = new StageJudgeRatingDetailResponse();
        response.setActivityPlayerId(activityPlayerId);
        Activity stage = null;
        if (stagePlayer.getStageId() != null) {
            stage = activityRepository.findActivityById(stagePlayer.getStageId());
        }
        if (stage != null) {
            response.setStageId(stage.getId());
            response.setStageName(stage.getName());
            response.setMatchTime(formatDateTime(stage.getMatchTime()));
        } else {
            response.setStageId(stagePlayer.getStageId());
            response.setStageName("阶段信息缺失");
        }
        List<ActivityPlayerRating> ratings = activityPlayerRatingRepository.findByActivityPlayerId(activityPlayerId);
        Map<Long, ActivityPlayerRating> latestRatingByJudge = new HashMap<>();
        for (ActivityPlayerRating rating : ratings) {
            Long judgeId = rating.getJudgeId();
            if (judgeId == null) {
                continue;
            }
            ActivityPlayerRating existing = latestRatingByJudge.get(judgeId);
            if (existing == null) {
                latestRatingByJudge.put(judgeId, rating);
            } else {
                LocalDateTime existingTime = existing.getUpdateTime();
                LocalDateTime currentTime = rating.getUpdateTime();
                if (existingTime == null || (currentTime != null && currentTime.isAfter(existingTime))) {
                    latestRatingByJudge.put(judgeId, rating);
                }
            }
        }
        Collection<ActivityPlayerRating> effectiveRatings = latestRatingByJudge.values();
        List<BigDecimal> validScores = effectiveRatings.stream()
                .map(ActivityPlayerRating::getTotalScore)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        int ratingCount = validScores.size();
        response.setRatingCount(ratingCount);
        if (ratingCount > 0) {
            BigDecimal sum = validScores.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
            BigDecimal avg = sum.divide(BigDecimal.valueOf(ratingCount), 2, RoundingMode.HALF_UP);
            response.setAverageScore(avg.doubleValue());
        }
        if (!effectiveRatings.isEmpty()) {
            Set<Long> judgeIds = effectiveRatings.stream()
                    .map(ActivityPlayerRating::getJudgeId)
                    .filter(Objects::nonNull)
                    .collect(Collectors.toSet());
            Map<Long, String> judgeNameMap = new HashMap<>();
            if (!judgeIds.isEmpty()) {
                List<Judge> judges = judgeRepository.findAllById(judgeIds);
                for (Judge judge : judges) {
                    judgeNameMap.put(judge.getId(), judge.getName());
                }
            }
            List<StageJudgeRatingItemResponse> judgeItems = effectiveRatings.stream()
                    .sorted(Comparator.comparing(ActivityPlayerRating::getUpdateTime, Comparator.nullsLast(LocalDateTime::compareTo)).reversed())
                    .map(rating -> {
                        StageJudgeRatingItemResponse item = new StageJudgeRatingItemResponse();
                        Long judgeId = rating.getJudgeId();
                        item.setJudgeId(judgeId);
                        item.setJudgeName(judgeNameMap.getOrDefault(judgeId, "未知评委"));
                        if (rating.getTotalScore() != null) {
                            item.setTotalScore(rating.getTotalScore().doubleValue());
                        }
                        item.setFeedback(rating.getFeedback());
                        item.setRatingTime(formatDateTime(rating.getUpdateTime()));
                        return item;
                    })
                    .collect(Collectors.toList());
            response.setJudgeRatings(judgeItems);
        }
        return response;
    }
    private void applyRatingMetrics(ProjectStageTimelineItemResponse item, Long stageActivityPlayerId) {
        List<ActivityPlayerRating> ratings = activityPlayerRatingRepository
                .findCompletedRatingsByActivityPlayerId(stageActivityPlayerId);
        List<BigDecimal> validScores = ratings.stream()
                .map(ActivityPlayerRating::getTotalScore)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        int ratingCount = validScores.size();
        item.setRatingCount(ratingCount);
        item.setHasRating(ratingCount > 0);
        if (ratingCount > 0) {
            BigDecimal sum = validScores.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
            BigDecimal avg = sum.divide(BigDecimal.valueOf(ratingCount), 2, RoundingMode.HALF_UP);
            item.setAverageScore(avg.doubleValue());
        }
        ratings.stream()
                .map(ActivityPlayerRating::getUpdateTime)
                .filter(Objects::nonNull)
                .max(LocalDateTime::compareTo)
                .ifPresent(time -> item.setLatestRatingTime(formatDateTime(time)));
    }
    private String formatDateTime(LocalDateTime time) {
        if (time == null) {
            return null;
        }
        return DATE_TIME_FORMATTER.format(time);
    }
}
backend/src/main/resources/graphql/player.graphqls
@@ -17,6 +17,10 @@
    # 获取当前评委对选手的评分
    currentJudgeRating(activityPlayerId: ID!): CurrentJudgeRatingResponse
    activityPlayerDetail(id: ID!): ActivityPlayerDetailResponse
    # 获取参赛项目阶段时间轴及评分概况
    projectStageTimeline(activityPlayerId: ID!): ProjectStageTimelineResponse
    # 获取指定阶段的评委评分详情
    stageJudgeRatings(activityPlayerId: ID!): StageJudgeRatingDetailResponse
    # 微信端获取选手报名状态
    getPlayerRegistrationState(activityId: ID!): PlayerRegistrationResponse
    # 获取比赛晋级列表
@@ -103,6 +107,43 @@
    weightedScore: Float
}
type ProjectStageTimelineResponse {
    activityId: ID!
    activityName: String
    stages: [ProjectStageTimelineItemResponse!]!
}
type ProjectStageTimelineItemResponse {
    stageId: ID!
    stageName: String!
    matchTime: String
    sortOrder: Int
    participated: Boolean!
    activityPlayerId: ID
    averageScore: Float
    ratingCount: Int!
    hasRating: Boolean!
    latestRatingTime: String
}
type StageJudgeRatingDetailResponse {
    activityPlayerId: ID!
    stageId: ID
    stageName: String
    matchTime: String
    ratingCount: Int!
    averageScore: Float
    judgeRatings: [StageJudgeRatingItemResponse!]!
}
type StageJudgeRatingItemResponse {
    judgeId: ID!
    judgeName: String!
    totalScore: Float
    feedback: String
    ratingTime: String
}
type ActivityPlayerApplicationResponse {
    id: ID
    playerName: String
wx/pages/project/detail.js
@@ -5,12 +5,18 @@
  data: {
    projectId: '',
    projectDetail: null,
    ratingStats: null,
    timeline: [],
    loading: true,
    error: '',
    statusText: '',
    genderText: '',
    educationText: ''
    educationText: '',
    timelineLoading: false,
    timelineError: '',
    showRatingDetail: false,
    ratingDetail: null,
    ratingDetailLoading: false,
    ratingDetailError: ''
  },
  onLoad(options) {
@@ -35,46 +41,33 @@
        error: '' 
      })
      // 调用API获取项目详情
      const projectDetail = await this.getProjectDetailFromAPI(this.data.projectId)
      
      if (projectDetail) {
        // 处理文件大小显示
      if (!projectDetail) {
        throw new Error('项目详情获取失败')
      }
        if (projectDetail.submissionFiles) {
          projectDetail.submissionFiles.forEach(file => {
            file.fileSizeText = this.formatFileSize(file.fileSize)
          })
        }
        // 获取评分统计
        const ratingStats = await this.getRatingStatsFromAPI(this.data.projectId)
        // 处理评分时间显示
        if (ratingStats && ratingStats.judgeRatings) {
          ratingStats.judgeRatings.forEach(rating => {
            if (rating.ratingTime) {
              rating.ratingTimeText = this.formatDateTime(rating.ratingTime)
            }
          })
        }
        this.setData({
          projectDetail,
          ratingStats,
          statusText: this.getStatusText(projectDetail.state),
          genderText: this.getGenderText(projectDetail.playerInfo?.gender),
          educationText: this.getEducationText(projectDetail.playerInfo?.education),
          loading: false
        educationText: this.getEducationText(projectDetail.playerInfo?.education)
        })
      } else {
        throw new Error('项目详情获取失败')
      }
      await this.loadProjectTimeline(this.data.projectId)
    } catch (error) {
      console.error('加载项目详情失败:', error)
      this.setData({
        error: error.message || '加载失败,请重试',
        loading: false
        error: error.message || '加载失败,请重试'
      })
    } finally {
      this.setData({ loading: false })
    }
  },
@@ -85,42 +78,62 @@
      query GetProjectDetail($id: ID!) {
        activityPlayerDetail(id: $id) {
          id
          activityId
          playerId
          playerName
          playerGender
          playerPhone
          playerEducation
          playerBirthDate
          playerIdCard
          playerAddress
          projectName
          projectDescription
          projectCategory
          projectTags
          projectFiles {
          playerInfo {
            id
            fileName
            fileUrl
            name
            phone
            gender
            birthday
            education
            introduction
            description
            avatarUrl
            avatar {
              id
              fullUrl
              fullThumbUrl
              name
            fileSize
            fileType
            uploadTime
              fileExt
          }
          submitTime
          reviewTime
          reviewerId
          reviewerName
          score
          rating {
            userInfo {
              userId
              name
              phone
              avatarUrl
            }
          }
          regionInfo {
            id
            judgeId
            judgeName
            score
            feedback
            ratingTime
            name
            fullPath
          }
          state
          activityName
          projectName
          description
          feedback
          state
          stageId
          submissionFiles {
            id
            fullUrl
            fullThumbUrl
            name
            fileSize
            fileExt
            mediaType
          }
          ratingForm {
            schemeId
            schemeName
            items {
              id
              name
              maxScore
              orderNo
            }
            totalMaxScore
          }
        }
      }
    `
@@ -133,47 +146,176 @@
    }
  },
  // 获取评分统计
  async getRatingStatsFromAPI(projectId) {
  async loadProjectTimeline(activityPlayerId) {
    if (!activityPlayerId) {
      return
    }
    const idNumber = Number(activityPlayerId)
    const variables = {
      activityPlayerId: Number.isNaN(idNumber) ? activityPlayerId : idNumber
    }
    this.setData({
      timelineLoading: true,
      timelineError: ''
    })
    const query = `
      query GetRatingStats($activityPlayerId: ID!) {
        ratingStats(activityPlayerId: $activityPlayerId) {
      query ProjectStageTimeline($activityPlayerId: ID!) {
        projectStageTimeline(activityPlayerId: $activityPlayerId) {
          activityId
          activityName
          stages {
            stageId
            stageName
            matchTime
            sortOrder
            participated
            activityPlayerId
          averageScore
          totalRatings
          scoreDistribution {
            score
            count
            ratingCount
            hasRating
            latestRatingTime
          }
        }
      }
    `
    try {
      const result = await app.graphqlRequest(query, { activityPlayerId: projectId })
      return result.ratingStats
      const result = await app.graphqlRequest(query, variables)
      const projectStageTimeline = result && result.projectStageTimeline ? result.projectStageTimeline : null
      const stages = projectStageTimeline && projectStageTimeline.stages ? projectStageTimeline.stages : []
      const timeline = stages.map(stage => {
        const hasScore = stage.hasRating && stage.averageScore !== null && stage.averageScore !== undefined
        let scoreText = '未参赛'
        if (stage.participated) {
          scoreText = hasScore ? `平均分:${Number(stage.averageScore).toFixed(2)}` : '未评分'
        }
        return {
          ...stage,
          matchTimeText: stage.matchTime ? this.formatDateTime(stage.matchTime) : '',
          scoreText,
          displayAverageScore: hasScore ? Number(stage.averageScore).toFixed(2) : null,
          isClickable: stage.participated && hasScore && !!stage.activityPlayerId
        }
      })
      this.setData({
        timeline,
        timelineLoading: false
      })
    } catch (error) {
      throw error
      console.error('加载阶段时间轴失败:', error)
      this.setData({
        timelineError: error.message || '时间轴加载失败',
        timelineLoading: false
      })
    }
  },
  // 获取评委评分详情
  async getJudgeRatingDetail(activityPlayerId, judgeId) {
  async fetchStageRatingDetail(activityPlayerId) {
    const idNumber = Number(activityPlayerId)
    const variables = {
      activityPlayerId: Number.isNaN(idNumber) ? activityPlayerId : idNumber
    }
    const query = `
      query GetJudgeRatingDetail($activityPlayerId: ID!, $judgeId: ID!) {
        judgeRatingDetail(activityPlayerId: $activityPlayerId, judgeId: $judgeId) {
          remark
      query StageJudgeRatings($activityPlayerId: ID!) {
        stageJudgeRatings(activityPlayerId: $activityPlayerId) {
          activityPlayerId
          stageId
          stageName
          matchTime
          ratingCount
          averageScore
          judgeRatings {
            judgeId
            judgeName
            totalScore
            feedback
            ratingTime
          }
        }
      }
    `
    try {
      const result = await app.graphqlRequest(query, { activityPlayerId, judgeId })
      return result.judgeRatingDetail
    } catch (error) {
      throw error
    const result = await app.graphqlRequest(query, variables)
    const detail = result && result.stageJudgeRatings ? result.stageJudgeRatings : null
    const sourceJudgeRatings = detail && detail.judgeRatings ? detail.judgeRatings : []
    const judgeRatings = sourceJudgeRatings.map(item => ({
      ...item,
      totalScoreText: item.totalScore !== null && item.totalScore !== undefined ? `${Number(item.totalScore).toFixed(2)}分` : '未评分',
      ratingTimeText: item.ratingTime ? this.formatDateTime(item.ratingTime) : ''
    }))
    const averageScoreValue = detail && detail.averageScore !== undefined && detail.averageScore !== null
      ? detail.averageScore
      : null
    return {
      activityPlayerId: detail && detail.activityPlayerId ? detail.activityPlayerId : variables.activityPlayerId,
      stageId: detail && detail.stageId ? detail.stageId : null,
      stageName: detail && detail.stageName ? detail.stageName : '阶段信息',
      matchTime: detail && detail.matchTime ? detail.matchTime : null,
      matchTimeText: detail && detail.matchTime ? this.formatDateTime(detail.matchTime) : '',
      ratingCount: detail && detail.ratingCount ? detail.ratingCount : 0,
      averageScore: averageScoreValue,
      averageScoreText: averageScoreValue !== null ? Number(averageScoreValue).toFixed(2) : '暂无评分',
      judgeRatings
    }
  },
  async openStageDetail(e) {
    const { playerId } = e.currentTarget.dataset
    const clickable = e.currentTarget.dataset.clickable === true || e.currentTarget.dataset.clickable === 'true'
    const participated = e.currentTarget.dataset.participated === true || e.currentTarget.dataset.participated === 'true'
    if (!playerId || !participated) {
      return
    }
    if (!clickable) {
      wx.showToast({
        title: '暂无评分',
        icon: 'none'
      })
      return
    }
    this.setData({
      showRatingDetail: true,
      ratingDetailLoading: true,
      ratingDetailError: '',
      ratingDetail: { judgeRatings: [] }
    })
    try {
      const detail = await this.fetchStageRatingDetail(playerId)
      this.setData({
        ratingDetail: detail,
        ratingDetailLoading: false
      })
    } catch (error) {
      console.error('加载阶段评分详情失败:', error)
      this.setData({
        ratingDetailError: error.message || '加载失败',
        ratingDetailLoading: false
      })
    }
  },
  closeStageDetail() {
    this.setData({
      showRatingDetail: false,
      ratingDetail: null,
      ratingDetailError: ''
    })
  },
  // 预览文件
  previewFile(e) {
    const file = e.currentTarget.dataset.file
wx/pages/project/detail.wxml
@@ -75,6 +75,57 @@
    </view>
  </view>
  <!-- 比赛阶段时间轴 -->
  <view class="timeline-card">
    <view class="card-header">
      <text class="card-title">比赛阶段</text>
    </view>
    <view class="timeline-loading" wx:if="{{timelineLoading}}">
      <view class="loading-spinner small"></view>
      <text class="loading-text">时间轴加载中...</text>
    </view>
    <view class="timeline-error" wx:elif="{{timelineError}}">
      {{timelineError}}
    </view>
    <view class="timeline-wrapper" wx:elif="{{timeline.length > 0}}">
      <view
        class="timeline-item {{item.participated ? 'timeline-item-active' : 'timeline-item-inactive'}} {{item.isClickable ? 'timeline-item-clickable' : ''}}"
        wx:for="{{timeline}}"
        wx:key="stageId"
        bindtap="openStageDetail"
        data-player-id="{{item.activityPlayerId}}"
        data-clickable="{{item.isClickable}}"
        data-participated="{{item.participated}}"
      >
        <view class="timeline-axis">
          <view class="timeline-dot"></view>
          <view class="timeline-line" wx:if="{{index < timeline.length - 1}}"></view>
        </view>
        <view class="timeline-body">
          <view class="timeline-title">
            <text class="stage-name">{{item.stageName}}</text>
            <text class="stage-score">{{item.scoreText}}</text>
          </view>
          <view class="timeline-sub">
            <text class="stage-time">{{item.matchTimeText || '时间待定'}}</text>
            <text class="stage-status" wx:if="{{item.participated}}">已参赛</text>
            <text class="stage-status stage-status-inactive" wx:else>未参赛</text>
          </view>
          <view class="timeline-actions" wx:if="{{item.hasRating && item.activityPlayerId}}">
            <text class="detail-link">【详情】</text>
          </view>
        </view>
      </view>
    </view>
    <view class="timeline-empty" wx:else>
      暂无阶段信息
    </view>
  </view>
  <!-- 参赛人信息 - 已隐藏 -->
  <!-- 
  <view class="player-card" wx:if="{{projectDetail.playerInfo}}">
@@ -128,49 +179,6 @@
    </view>
  </view>
  <!-- 评审信息 -->
  <view class="rating-card" wx:if="{{ratingStats}}">
    <view class="card-header">
      <text class="card-title">评审信息</text>
    </view>
    <view class="rating-info">
      <!-- 总平均分显示 -->
      <view class="average-score-section">
        <view class="average-score-container">
          <text class="average-score-label">总平均分</text>
          <text class="average-score-value">{{ratingStats.averageScore || '暂无评分'}}</text>
          <text class="rating-count-text">({{ratingStats.ratingCount || 0}}位评委已评分)</text>
        </view>
      </view>
      <!-- 各评委评分详情 -->
      <view class="judge-ratings-section" wx:if="{{ratingStats.judgeRatings && ratingStats.judgeRatings.length > 0}}">
        <text class="section-title">评委评分详情</text>
        <view
          class="judge-rating"
          wx:for="{{ratingStats.judgeRatings}}"
          wx:key="judgeId"
          wx:if="{{item.hasRated}}"
        >
          <view class="rating-header">
            <view class="judge-info">
              <text class="judge-label">评委 {{index + 1}}</text>
              <text class="rating-status rated">已评分</text>
            </view>
            <view class="score-info">
              <text class="score">{{item.totalScore}}分</text>
              <text class="rating-time">{{item.ratingTimeText}}</text>
            </view>
          </view>
          <view class="rating-comment" wx:if="{{item.remark}}">
            <text class="comment-label">点评:</text>
            <text class="comment-text">{{item.remark}}</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</view>
<!-- 加载状态 -->
@@ -185,3 +193,39 @@
  <text class="error-text">{{error}}</text>
  <button class="retry-btn" bindtap="loadProjectDetail">重试</button>
</view>
<!-- 阶段评分详情弹窗 -->
<view class="rating-detail-overlay" wx:if="{{showRatingDetail}}">
  <view class="overlay-mask" bindtap="closeStageDetail"></view>
  <view class="overlay-panel">
    <view class="overlay-header">
      <text class="overlay-title">{{ratingDetail ? ratingDetail.stageName : '评分详情'}}</text>
      <view class="overlay-close" bindtap="closeStageDetail">×</view>
    </view>
    <text class="overlay-subtitle">{{ratingDetail && ratingDetail.matchTimeText ? ratingDetail.matchTimeText : '时间待定'}}</text>
    <view class="overlay-summary">
      <text class="summary-score">平均分:{{ratingDetail ? ratingDetail.averageScoreText : '暂无评分'}}</text>
      <text class="summary-count">评委数:{{ratingDetail ? ratingDetail.ratingCount : 0}}</text>
    </view>
    <view class="overlay-body">
      <view class="panel-loading" wx:if="{{ratingDetailLoading}}">
        <view class="loading-spinner small"></view>
        <text class="loading-text">加载评分详情...</text>
      </view>
      <view class="panel-error" wx:elif="{{ratingDetailError}}">{{ratingDetailError}}</view>
      <block wx:else>
        <view class="judge-item" wx:for="{{(ratingDetail && ratingDetail.judgeRatings) ? ratingDetail.judgeRatings : []}}" wx:key="judgeId">
          <view class="judge-header">
            <text class="judge-name">{{item.judgeName}}</text>
            <text class="judge-score">{{item.totalScoreText}}</text>
          </view>
          <text class="judge-time" wx:if="{{item.ratingTimeText}}">{{item.ratingTimeText}}</text>
          <view class="judge-feedback" wx:if="{{item.feedback}}">{{item.feedback}}</view>
        </view>
        <view class="panel-empty" wx:if="{{ratingDetail && ratingDetail.judgeRatings && ratingDetail.judgeRatings.length === 0}}">
          暂无评分详情
        </view>
      </block>
    </view>
  </view>
</view>
wx/pages/project/detail.wxss
@@ -9,8 +9,8 @@
.project-card,
.attachments-card,
.player-card,
.rating-card,
.feedback-card {
.feedback-card,
.timeline-card {
  background-color: #ffffff;
  border-radius: 16rpx;
  margin-bottom: 20rpx;
@@ -456,6 +456,275 @@
  border-left: 6rpx solid #409eff;
}
/* 时间轴 */
.timeline-loading,
.timeline-error,
.timeline-empty {
  padding: 40rpx 10rpx;
  text-align: center;
  color: #666666;
  font-size: 28rpx;
}
.timeline-wrapper {
  margin-top: 10rpx;
}
.timeline-item {
  display: flex;
  position: relative;
  padding-left: 80rpx;
  padding-bottom: 40rpx;
}
.timeline-item:last-child {
  padding-bottom: 0;
}
.timeline-item.timeline-item-clickable .timeline-body {
  border-color: rgba(22, 119, 255, 0.45);
  box-shadow: 0 6rpx 16rpx rgba(22, 119, 255, 0.08);
}
.timeline-axis {
  position: absolute;
  left: 32rpx;
  top: 0;
  bottom: 0;
  display: flex;
  align-items: flex-start;
}
.timeline-dot {
  width: 24rpx;
  height: 24rpx;
  border-radius: 50%;
  background-color: #d0d3d8;
  margin-top: 8rpx;
}
.timeline-item-active .timeline-dot {
  background-color: #1677ff;
  box-shadow: 0 0 0 8rpx rgba(22, 119, 255, 0.12);
}
.timeline-line {
  width: 4rpx;
  background-color: #e5e9ef;
  flex: 1;
  margin: 0 auto;
  margin-top: 8rpx;
}
.timeline-item-active .timeline-line {
  background-color: rgba(22, 119, 255, 0.2);
}
.timeline-body {
  flex: 1;
  background-color: #f8f9fa;
  border-radius: 16rpx;
  padding: 24rpx;
  border: 2rpx solid #e0e4eb;
}
.timeline-item-active .timeline-body {
  border-color: rgba(22, 119, 255, 0.35);
  background-color: rgba(22, 119, 255, 0.05);
}
.timeline-title {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 20rpx;
}
.stage-name {
  font-size: 32rpx;
  font-weight: 600;
  color: #333333;
}
.stage-score {
  font-size: 28rpx;
  color: #1677ff;
  font-weight: 600;
}
.timeline-item-inactive .stage-score {
  color: #999999;
}
.timeline-sub {
  margin-top: 12rpx;
  display: flex;
  align-items: center;
  gap: 16rpx;
  font-size: 26rpx;
  color: #666666;
}
.stage-status {
  padding: 4rpx 16rpx;
  border-radius: 16rpx;
  background-color: rgba(22, 119, 255, 0.1);
  color: #1677ff;
  font-size: 24rpx;
}
.stage-status-inactive {
  background-color: #f4f5f8;
  color: #909399;
}
.timeline-actions {
  margin-top: 12rpx;
}
.detail-link {
  color: #1677ff;
  font-size: 26rpx;
}
.detail-link:active {
  opacity: 0.7;
}
/* 评分详情弹窗 */
.rating-detail-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 999;
}
.overlay-mask {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.45);
}
.overlay-panel {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: #ffffff;
  border-top-left-radius: 24rpx;
  border-top-right-radius: 24rpx;
  padding: 40rpx 32rpx 60rpx;
  max-height: 80vh;
  overflow-y: auto;
}
.overlay-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.overlay-title {
  font-size: 34rpx;
  font-weight: 600;
  color: #333333;
}
.overlay-close {
  font-size: 44rpx;
  color: #999999;
  padding: 0 10rpx;
}
.overlay-subtitle {
  font-size: 26rpx;
  color: #666666;
  margin-top: 12rpx;
}
.overlay-summary {
  margin-top: 24rpx;
  padding: 24rpx;
  background-color: #f8f9fa;
  border-radius: 16rpx;
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 26rpx;
  color: #555555;
}
.summary-score {
  font-weight: 600;
  color: #1677ff;
}
.overlay-body {
  margin-top: 30rpx;
}
.panel-loading,
.panel-error,
.panel-empty {
  text-align: center;
  font-size: 28rpx;
  color: #666666;
  padding: 40rpx 0;
}
.judge-item {
  border: 2rpx solid #e4e7ed;
  border-radius: 16rpx;
  padding: 24rpx;
  margin-bottom: 24rpx;
  background-color: #ffffff;
}
.judge-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8rpx;
}
.judge-name {
  font-size: 30rpx;
  font-weight: 600;
  color: #333333;
}
.judge-score {
  font-size: 30rpx;
  font-weight: 600;
  color: #1677ff;
}
.judge-time {
  font-size: 24rpx;
  color: #999999;
}
.judge-feedback {
  margin-top: 16rpx;
  font-size: 28rpx;
  color: #333333;
  line-height: 1.5;
  background-color: #f8f9fa;
  padding: 16rpx;
  border-radius: 12rpx;
}
.loading-spinner.small {
  width: 40rpx;
  height: 40rpx;
  border-width: 5rpx;
}
/* 加载状态 */
.loading-container {
  display: flex;