Codex Assistant
2025-10-05 915d80766dd8e0157e9b9510b3634ed758eb5c5a
feat: 新增员工审核入口与审核页面
4个文件已修改
13个文件已添加
1456 ■■■■■ 已修改文件
backend/src/main/java/com/rongyichuang/employee/dto/response/EmployeeReviewApplicationResponse.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/employee/dto/response/EmployeeReviewPageResponse.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/employee/dto/response/EmployeeReviewStatsResponse.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/employee/resolver/EmployeeReviewResolver.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/employee/service/EmployeeReviewService.java 248 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/graphql/employee.graphqls 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/app.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/employee-review-detail.js 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/employee-review-detail.json 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/employee-review-detail.wxml 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/employee-review-detail.wxss 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/employee-review.js 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/employee-review.json 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/employee-review.wxml 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/employee-review.wxss 195 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/profile.js 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/profile.wxml 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/employee/dto/response/EmployeeReviewApplicationResponse.java
New file
@@ -0,0 +1,82 @@
package com.rongyichuang.employee.dto.response;
/**
 * 员工审核列表条目
 */
public class EmployeeReviewApplicationResponse {
    private Long id;
    private String playerName;
    private String projectName;
    private String activityName;
    private Integer state;
    private String stateText;
    private String stateType;
    private String applyTime;
    public EmployeeReviewApplicationResponse() {
    }
    public Long getId() {
        return id;
    }
    public void setId(Long id) {
        this.id = id;
    }
    public String getPlayerName() {
        return playerName;
    }
    public void setPlayerName(String playerName) {
        this.playerName = playerName;
    }
    public String getProjectName() {
        return projectName;
    }
    public void setProjectName(String projectName) {
        this.projectName = projectName;
    }
    public String getActivityName() {
        return activityName;
    }
    public void setActivityName(String activityName) {
        this.activityName = activityName;
    }
    public Integer getState() {
        return state;
    }
    public void setState(Integer state) {
        this.state = state;
    }
    public String getStateText() {
        return stateText;
    }
    public void setStateText(String stateText) {
        this.stateText = stateText;
    }
    public String getStateType() {
        return stateType;
    }
    public void setStateType(String stateType) {
        this.stateType = stateType;
    }
    public String getApplyTime() {
        return applyTime;
    }
    public void setApplyTime(String applyTime) {
        this.applyTime = applyTime;
    }
}
backend/src/main/java/com/rongyichuang/employee/dto/response/EmployeeReviewPageResponse.java
New file
@@ -0,0 +1,55 @@
package com.rongyichuang.employee.dto.response;
import java.util.List;
/**
 * 员工审核分页结果
 */
public class EmployeeReviewPageResponse {
    private List<EmployeeReviewApplicationResponse> content;
    private int totalElements;
    private int page;
    private int size;
    public EmployeeReviewPageResponse() {
    }
    public EmployeeReviewPageResponse(List<EmployeeReviewApplicationResponse> content, int totalElements, int page, int size) {
        this.content = content;
        this.totalElements = totalElements;
        this.page = page;
        this.size = size;
    }
    public List<EmployeeReviewApplicationResponse> getContent() {
        return content;
    }
    public void setContent(List<EmployeeReviewApplicationResponse> content) {
        this.content = content;
    }
    public int getTotalElements() {
        return totalElements;
    }
    public void setTotalElements(int totalElements) {
        this.totalElements = totalElements;
    }
    public int getPage() {
        return page;
    }
    public void setPage(int page) {
        this.page = page;
    }
    public int getSize() {
        return size;
    }
    public void setSize(int size) {
        this.size = size;
    }
}
backend/src/main/java/com/rongyichuang/employee/dto/response/EmployeeReviewStatsResponse.java
New file
@@ -0,0 +1,43 @@
package com.rongyichuang.employee.dto.response;
/**
 * 员工审核统计数据
 */
public class EmployeeReviewStatsResponse {
    private int pendingCount;
    private int approvedCount;
    private int rejectedCount;
    public EmployeeReviewStatsResponse() {
    }
    public EmployeeReviewStatsResponse(int pendingCount, int approvedCount, int rejectedCount) {
        this.pendingCount = pendingCount;
        this.approvedCount = approvedCount;
        this.rejectedCount = rejectedCount;
    }
    public int getPendingCount() {
        return pendingCount;
    }
    public void setPendingCount(int pendingCount) {
        this.pendingCount = pendingCount;
    }
    public int getApprovedCount() {
        return approvedCount;
    }
    public void setApprovedCount(int approvedCount) {
        this.approvedCount = approvedCount;
    }
    public int getRejectedCount() {
        return rejectedCount;
    }
    public void setRejectedCount(int rejectedCount) {
        this.rejectedCount = rejectedCount;
    }
}
backend/src/main/java/com/rongyichuang/employee/resolver/EmployeeReviewResolver.java
New file
@@ -0,0 +1,34 @@
package com.rongyichuang.employee.resolver;
import com.rongyichuang.employee.dto.response.EmployeeReviewPageResponse;
import com.rongyichuang.employee.dto.response.EmployeeReviewStatsResponse;
import com.rongyichuang.employee.service.EmployeeReviewService;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
/**
 * 员工审核GraphQL接口
 */
@Controller
public class EmployeeReviewResolver {
    private final EmployeeReviewService employeeReviewService;
    public EmployeeReviewResolver(EmployeeReviewService employeeReviewService) {
        this.employeeReviewService = employeeReviewService;
    }
    @QueryMapping
    public EmployeeReviewStatsResponse employeeReviewStats(@Argument String keyword) {
        return employeeReviewService.getReviewStats(keyword);
    }
    @QueryMapping
    public EmployeeReviewPageResponse employeeReviewApplications(@Argument String keyword,
                                                                 @Argument Integer state,
                                                                 @Argument Integer page,
                                                                 @Argument Integer size) {
        return employeeReviewService.listReviewApplications(keyword, state, page, size);
    }
}
backend/src/main/java/com/rongyichuang/employee/service/EmployeeReviewService.java
New file
@@ -0,0 +1,248 @@
package com.rongyichuang.employee.service;
import com.rongyichuang.common.exception.BusinessException;
import com.rongyichuang.common.util.UserContextUtil;
import com.rongyichuang.employee.dto.response.EmployeeReviewApplicationResponse;
import com.rongyichuang.employee.dto.response.EmployeeReviewPageResponse;
import com.rongyichuang.employee.dto.response.EmployeeReviewStatsResponse;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
 * 员工审核数据服务
 */
@Service
public class EmployeeReviewService {
    private static final Logger log = LoggerFactory.getLogger(EmployeeReviewService.class);
    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    @PersistenceContext
    private EntityManager entityManager;
    private final UserContextUtil userContextUtil;
    private final EmployeeService employeeService;
    public EmployeeReviewService(UserContextUtil userContextUtil, EmployeeService employeeService) {
        this.userContextUtil = userContextUtil;
        this.employeeService = employeeService;
    }
    /**
     * 获取员工审核统计
     */
    public EmployeeReviewStatsResponse getReviewStats(String keyword) {
        ensureEmployeeIdentity();
        StringBuilder sql = new StringBuilder();
        sql.append("SELECT COALESCE(ap.state, 0) AS state, COUNT(*) AS total ");
        sql.append("FROM t_activity_player ap ");
        sql.append("JOIN t_player p ON p.id = ap.player_id ");
        sql.append("JOIN t_activity stage ON stage.id = ap.stage_id ");
        sql.append("JOIN t_activity parent ON parent.id = stage.pid ");
        sql.append("WHERE stage.sort_order = 1 ");
        if (StringUtils.hasText(keyword)) {
            sql.append("AND (p.name LIKE :keyword OR parent.name LIKE :keyword OR ap.project_name LIKE :keyword) ");
        }
        sql.append("GROUP BY ap.state");
        var query = entityManager.createNativeQuery(sql.toString());
        if (StringUtils.hasText(keyword)) {
            query.setParameter("keyword", wrapKeyword(keyword));
        }
        @SuppressWarnings("unchecked")
        List<Object[]> rows = query.getResultList();
        int pending = 0;
        int approved = 0;
        int rejected = 0;
        for (Object[] row : rows) {
            int state = 0;
            int total = 0;
            if (row[0] instanceof Number) {
                state = ((Number) row[0]).intValue();
            } else if (row[0] != null) {
                state = Integer.parseInt(row[0].toString());
            }
            if (row[1] instanceof Number) {
                total = ((Number) row[1]).intValue();
            } else if (row[1] != null) {
                total = Integer.parseInt(row[1].toString());
            }
            switch (state) {
                case 0 -> pending = total;
                case 1 -> approved = total;
                case 2 -> rejected = total;
                default -> log.debug("忽略状态{}的统计", state);
            }
        }
        return new EmployeeReviewStatsResponse(pending, approved, rejected);
    }
    /**
     * 分页查询员工审核列表
     */
    public EmployeeReviewPageResponse listReviewApplications(String keyword, Integer state, Integer page, Integer size) {
        ensureEmployeeIdentity();
        int pageNumber = (page != null && page > 0) ? page : 1;
        int pageSize = (size != null && size > 0) ? size : 10;
        int offset = (pageNumber - 1) * pageSize;
        String baseSql = "SELECT ap.id, p.name AS player_name, ap.project_name, parent.name AS activity_name, ap.state, ap.create_time " +
                "FROM t_activity_player ap " +
                "JOIN t_player p ON p.id = ap.player_id " +
                "JOIN t_activity stage ON stage.id = ap.stage_id " +
                "JOIN t_activity parent ON parent.id = stage.pid ";
        StringBuilder whereClause = new StringBuilder();
        whereClause.append("stage.sort_order = 1");
        if (StringUtils.hasText(keyword)) {
            whereClause.append(" AND (p.name LIKE :keyword OR parent.name LIKE :keyword OR ap.project_name LIKE :keyword)");
        }
        if (state != null) {
            whereClause.append(" AND ap.state = :state");
        }
        String where = whereClause.length() > 0 ? " WHERE " + whereClause + " " : "";
        String order = "ORDER BY ap.create_time DESC ";
        String limit = "LIMIT " + pageSize + " OFFSET " + offset + " ";
        var query = entityManager.createNativeQuery(baseSql + where + order + limit);
        if (StringUtils.hasText(keyword)) {
            query.setParameter("keyword", wrapKeyword(keyword));
        }
        if (state != null) {
            query.setParameter("state", state);
        }
        @SuppressWarnings("unchecked")
        List<Object[]> rows = query.getResultList();
        List<EmployeeReviewApplicationResponse> content = new ArrayList<>();
        for (Object[] row : rows) {
            EmployeeReviewApplicationResponse dto = new EmployeeReviewApplicationResponse();
            dto.setId(row[0] instanceof Number ? ((Number) row[0]).longValue() : parseLong(row[0]));
            dto.setPlayerName(row[1] != null ? row[1].toString() : "");
            dto.setProjectName(row[2] != null ? row[2].toString() : "");
            dto.setActivityName(row[3] != null ? row[3].toString() : "");
            Integer reviewState = row[4] instanceof Number ? ((Number) row[4]).intValue() : parseInt(row[4]);
            dto.setState(reviewState);
            dto.setStateText(resolveStateText(reviewState));
            dto.setStateType(resolveStateType(reviewState));
            dto.setApplyTime(formatDateTime(row[5]));
            content.add(dto);
        }
        String countSql = "SELECT COUNT(*) FROM t_activity_player ap " +
                "JOIN t_player p ON p.id = ap.player_id " +
                "JOIN t_activity stage ON stage.id = ap.stage_id " +
                "JOIN t_activity parent ON parent.id = stage.pid " +
                where;
        var countQuery = entityManager.createNativeQuery(countSql);
        if (StringUtils.hasText(keyword)) {
            countQuery.setParameter("keyword", wrapKeyword(keyword));
        }
        if (state != null) {
            countQuery.setParameter("state", state);
        }
        Number totalNumber = (Number) countQuery.getSingleResult();
        int total = totalNumber != null ? totalNumber.intValue() : 0;
        return new EmployeeReviewPageResponse(content, total, pageNumber, pageSize);
    }
    private void ensureEmployeeIdentity() {
        Long userId = userContextUtil.getCurrentUserId();
        if (userId == null) {
            throw new BusinessException("UNAUTHORIZED", "请先登录");
        }
        if (employeeService.findByUserId(userId) == null) {
            throw new BusinessException("EMPLOYEE_REQUIRED", "当前用户没有审核权限");
        }
    }
    private String wrapKeyword(String keyword) {
        return "%" + keyword.trim() + "%";
    }
    private Integer parseInt(Object value) {
        if (value == null) {
            return null;
        }
        try {
            return Integer.parseInt(value.toString());
        } catch (NumberFormatException ex) {
            log.warn("无法解析整型值: {}", value, ex);
            return null;
        }
    }
    private Long parseLong(Object value) {
        if (value == null) {
            return null;
        }
        try {
            return Long.parseLong(value.toString());
        } catch (NumberFormatException ex) {
            log.warn("无法解析长整型值: {}", value, ex);
            return null;
        }
    }
    private String formatDateTime(Object value) {
        if (value instanceof Timestamp timestamp) {
            LocalDateTime dateTime = timestamp.toLocalDateTime();
            return DATE_TIME_FORMATTER.format(dateTime);
        }
        if (value instanceof LocalDateTime localDateTime) {
            return DATE_TIME_FORMATTER.format(localDateTime);
        }
        return value != null ? value.toString() : "";
    }
    private String resolveStateText(Integer state) {
        if (state == null) {
            return "未知";
        }
        return switch (state) {
            case 0 -> "未审核";
            case 1 -> "审核通过";
            case 2 -> "审核驳回";
            default -> "未知";
        };
    }
    private String resolveStateType(Integer state) {
        if (state == null) {
            return "info";
        }
        return switch (state) {
            case 0 -> "warning";
            case 1 -> "success";
            case 2 -> "danger";
            default -> "info";
        };
    }
}
backend/src/main/resources/graphql/employee.graphqls
@@ -26,6 +26,32 @@
# Spring Data的Page对象会自动映射到GraphQL
# 扩展查询类型
type EmployeeReviewApplication {
    id: Long!
    playerName: String
    projectName: String
    activityName: String
    state: Int
    stateText: String
    stateType: String
    applyTime: String
}
type EmployeeReviewPage {
    content: [EmployeeReviewApplication!]!
    totalElements: Int!
    page: Int!
    size: Int!
}
type EmployeeReviewStats {
    pendingCount: Int!
    approvedCount: Int!
    rejectedCount: Int!
}
extend type Query {
    # 获取所有员工列表
    employees: [EmployeeResponse!]!
@@ -35,6 +61,12 @@
    
    # 根据ID获取员工详情
    employee(id: Long!): EmployeeResponse
    # 员工审核统计
    employeeReviewStats(keyword: String): EmployeeReviewStats!
    # 员工审核列表
    employeeReviewApplications(keyword: String, state: Int, page: Int, size: Int): EmployeeReviewPage!
}
# 扩展变更类型
wx/app.json
@@ -5,6 +5,8 @@
    "pages/registration/registration",
    "pages/profile/profile",
    "pages/profile/personal-info",
    "pages/profile/employee-review",
    "pages/profile/employee-review-detail",
    "pages/project/detail",
    "pages/message/message",
    "pages/review/index",
@@ -19,7 +21,6 @@
    "navigationBarTextStyle": "black",
    "backgroundColor": "#f5f5f5"
  },
    "tabBar": {
      "custom": true,
      "color": "#999999",
@@ -40,7 +41,6 @@
        }
      ]
    },
  "networkTimeout": {
    "request": 10000,
    "downloadFile": 10000
@@ -51,6 +51,8 @@
      "desc": "你的位置信息将用于小程序位置接口的效果展示"
    }
  },
  "requiredBackgroundModes": ["audio"],
  "requiredBackgroundModes": [
    "audio"
  ],
  "sitemapLocation": "sitemap.json"
}
wx/pages/profile/employee-review-detail.js
New file
@@ -0,0 +1,165 @@
const { graphqlRequest } = require('../../lib/utils')
const DETAIL_QUERY = `
  query ActivityPlayerDetail($id: ID!) {
    activityPlayerDetail(id: $id) {
      id
      activityName
      projectName
      description
      feedback
      state
      playerInfo {
        id
        name
        phone
      }
      submissionFiles {
        id
        name
        fullUrl
        fullThumbUrl
        fileExt
        mediaType
      }
    }
  }
`
const APPROVE_MUTATION = `
  mutation ApproveActivityPlayer($id: ID!, $feedback: String) {
    approveActivityPlayer(activityPlayerId: $id, feedback: $feedback)
  }
`
const REJECT_MUTATION = `
  mutation RejectActivityPlayer($id: ID!, $feedback: String) {
    rejectActivityPlayer(activityPlayerId: $id, feedback: $feedback)
  }
`
Page({
  data: {
    loading: false,
    submitting: false,
    activityPlayerId: null,
    detail: null,
    feedback: ''
  },
  onLoad(options) {
    if (options && options.id) {
      if (typeof this.getOpenerEventChannel === 'function') {
        this.eventChannel = this.getOpenerEventChannel()
      }
      this.setData({ activityPlayerId: options.id })
      this.loadDetail()
    } else {
      wx.showToast({ title: '缺少报名ID', icon: 'none' })
      setTimeout(() => wx.navigateBack(), 800)
    }
  },
  async loadDetail() {
    if (!this.data.activityPlayerId) {
      return
    }
    this.setData({ loading: true })
    try {
      const result = await graphqlRequest(DETAIL_QUERY, { id: this.data.activityPlayerId })
      const detail = result && result.activityPlayerDetail
      if (!detail) {
        wx.showToast({ title: '未找到报名信息', icon: 'none' })
        return
      }
      this.setData({
        detail: {
          ...detail,
          stateText: this.getStateText(detail.state),
          submissionFiles: (detail.submissionFiles || []).map(file => ({
            id: file.id,
            name: file.name,
            url: file.fullUrl || file.fullThumbUrl || ''
          }))
        },
        feedback: detail.feedback || ''
      })
    } catch (error) {
      console.error('加载审核详情失败:', error)
      wx.showToast({ title: '加载失败', icon: 'none' })
    } finally {
      this.setData({ loading: false })
    }
  },
  onFeedbackInput(e) {
    this.setData({ feedback: e.detail.value })
  },
  onApprove() {
    this.handleAudit('APPROVE')
  },
  onReject() {
    this.handleAudit('REJECT')
  },
  async handleAudit(action) {
    if (!this.data.activityPlayerId) {
      return
    }
    const mutation = action === 'APPROVE' ? APPROVE_MUTATION : REJECT_MUTATION
    const successText = action === 'APPROVE' ? '审核通过' : '已驳回'
    this.setData({ submitting: true })
    try {
      const variables = {
        id: Number(this.data.activityPlayerId),
        feedback: this.data.feedback ? this.data.feedback.trim() : null
      }
      const result = await graphqlRequest(mutation, variables)
      const success = result && (action === 'APPROVE' ? result.approveActivityPlayer : result.rejectActivityPlayer)
      if (success) {
        wx.showToast({ title: successText, icon: 'success' })
        if (this.eventChannel) {
          this.eventChannel.emit('auditUpdated', { id: this.data.activityPlayerId })
        }
        setTimeout(() => wx.navigateBack(), 600)
      } else {
        wx.showToast({ title: '操作失败', icon: 'none' })
      }
    } catch (error) {
      console.error('提交审核决策失败:', error)
      wx.showToast({ title: '操作失败', icon: 'none' })
    } finally {
      this.setData({ submitting: false })
    }
  },
  previewFile(e) {
    const url = e.currentTarget.dataset.url
    if (url) {
      wx.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
    }
  },
  getStateText(state) {
    switch (state) {
      case 0:
        return '未审核'
      case 1:
        return '审核通过'
      case 2:
        return '审核驳回'
      default:
        return '未知状态'
    }
  }
})
wx/pages/profile/employee-review-detail.json
New file
@@ -0,0 +1,4 @@
{
  "navigationBarTitleText": "审核详情",
  "enablePullDownRefresh": false
}
wx/pages/profile/employee-review-detail.wxml
New file
@@ -0,0 +1,71 @@
<view class="container">
  <view wx:if="{{loading}}" class="loading">加载中...</view>
  <view wx:else>
    <view class="info-card">
      <text class="section-title">项目信息</text>
      <view class="info-row">
        <text class="label">比赛名称</text>
        <text class="value">{{detail.activityName || '-'}} </text>
      </view>
      <view class="info-row">
        <text class="label">项目名称</text>
        <text class="value">{{detail.projectName || '-'}} </text>
      </view>
      <view class="info-row">
        <text class="label">当前状态</text>
        <text class="status">{{detail.stateText || '未知'}}</text>
      </view>
      <view class="info-block">
        <text class="label">项目简介</text>
        <text class="description">{{detail.description || '暂无简介'}}</text>
      </view>
    </view>
    <view class="info-card">
      <text class="section-title">学员信息</text>
      <view class="info-row">
        <text class="label">姓名</text>
        <text class="value">{{detail.playerInfo.name || '-'}}</text>
      </view>
      <view class="info-row">
        <text class="label">联系方式</text>
        <text class="value">{{detail.playerInfo.phone || '-'}}</text>
      </view>
    </view>
    <view class="info-card" wx:if="{{detail.submissionFiles && detail.submissionFiles.length}}">
      <text class="section-title">提交资料</text>
      <view class="file-item" wx:for="{{detail.submissionFiles}}" wx:key="id" data-url="{{item.url}}" bindtap="previewFile">
        <text class="file-name">{{item.name || '资料文件'}}</text>
        <text class="file-action">预览</text>
      </view>
    </view>
    <view class="info-card">
      <text class="section-title">审核意见</text>
      <textarea
        class="feedback-input"
        placeholder="请输入审核意见(可选)"
        value="{{feedback}}"
        bindinput="onFeedbackInput"
        maxlength="500"
        auto-height
      />
    </view>
    <view class="action-bar">
      <button
        class="reject-btn"
        bindtap="onReject"
        loading="{{submitting}}"
        disabled="{{submitting}}"
      >驳回</button>
      <button
        class="approve-btn"
        bindtap="onApprove"
        loading="{{submitting}}"
        disabled="{{submitting}}"
      >审核通过</button>
    </view>
  </view>
</view>
wx/pages/profile/employee-review-detail.wxss
New file
@@ -0,0 +1,124 @@
.container {
  min-height: 100vh;
  background: #f5f5f5;
  padding: 24rpx;
}
.loading {
  text-align: center;
  color: #666666;
  margin-top: 200rpx;
  font-size: 28rpx;
}
.info-card {
  background: #ffffff;
  border-radius: 16rpx;
  padding: 24rpx;
  margin-bottom: 24rpx;
  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.section-title {
  font-size: 32rpx;
  font-weight: 600;
  color: #1a1a1a;
  margin-bottom: 16rpx;
  display: block;
}
.info-row {
  display: flex;
  justify-content: space-between;
  font-size: 28rpx;
  color: #444444;
  margin-bottom: 12rpx;
}
.label {
  color: #888888;
}
.value {
  color: #333333;
}
.status {
  color: #1677ff;
}
.info-block {
  margin-top: 12rpx;
}
.description {
  color: #555555;
  font-size: 26rpx;
  line-height: 1.6;
  margin-top: 8rpx;
}
.file-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20rpx 0;
  border-bottom: 1rpx solid #f0f0f0;
  font-size: 26rpx;
}
.file-item:last-child {
  border-bottom: none;
}
.file-name {
  color: #333333;
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  padding-right: 20rpx;
}
.file-action {
  color: #1677ff;
}
.feedback-input {
  background: #f8f8f8;
  border-radius: 12rpx;
  padding: 16rpx;
  min-height: 120rpx;
  font-size: 26rpx;
  color: #333333;
}
.action-bar {
  position: sticky;
  bottom: 0;
  display: flex;
  gap: 16rpx;
  background: #f5f5f5;
  padding-bottom: 24rpx;
}
.reject-btn {
  flex: 1;
  background: #ffffff;
  color: #ff5454;
  border: 2rpx solid #ff5454;
  border-radius: 36rpx;
  height: 80rpx;
  line-height: 80rpx;
  font-size: 28rpx;
}
.approve-btn {
  flex: 1;
  background: linear-gradient(135deg, #16a75c 0%, #4bc077 100%);
  color: #ffffff;
  border-radius: 36rpx;
  height: 80rpx;
  line-height: 80rpx;
  font-size: 28rpx;
}
wx/pages/profile/employee-review.js
New file
@@ -0,0 +1,217 @@
const { graphqlRequest } = require('../../lib/utils')
const LIST_QUERY = `
  query EmployeeReviewApplications($keyword: String, $state: Int, $page: Int, $size: Int) {
    employeeReviewApplications(keyword: $keyword, state: $state, page: $page, size: $size) {
      content {
        id
        playerName
        projectName
        activityName
        state
        stateText
        stateType
        applyTime
      }
      totalElements
      page
      size
    }
  }
`
const STATS_QUERY = `
  query EmployeeReviewStats($keyword: String) {
    employeeReviewStats(keyword: $keyword) {
      pendingCount
      approvedCount
      rejectedCount
    }
  }
`
Page({
  data: {
    loading: false,
    loadingMore: false,
    hasMore: true,
    tabs: [
      { label: '待审核', value: 0 },
      { label: '已审核', value: 1 },
      { label: '驳回', value: 2 }
    ],
    currentTab: 0,
    searchKeyword: '',
    list: [],
    page: 1,
    pageSize: 10,
    stats: {
      pendingCount: 0,
      approvedCount: 0,
      rejectedCount: 0
    },
    employeeReviewStats: {
      pendingCount: 0,
      approvedCount: 0,
      rejectedCount: 0
    },
    needRefresh: false
  },
  onLoad() {
    this.initData()
  },
  onShow() {
    if (this.data.needRefresh) {
      this.initData()
      this.setData({ needRefresh: false })
    }
  },
  onPullDownRefresh() {
    this.initData().finally(() => {
      wx.stopPullDownRefresh()
    })
  },
  onReachBottom() {
    if (this.data.hasMore && !this.data.loadingMore) {
      this.loadList(false)
    }
  },
  async initData() {
    this.setData({ loading: true, page: 1, list: [], hasMore: true })
    try {
      await Promise.all([
        this.loadList(true),
        this.loadStats(this.data.searchKeyword)
      ])
    } catch (error) {
      console.error('初始化审核数据失败:', error)
    } finally {
      this.setData({ loading: false })
    }
  },
  async loadStats(keyword) {
    try {
      const variables = {}
      const trimmed = keyword && typeof keyword === 'string' ? keyword.trim() : ''
      if (trimmed) {
        variables.keyword = trimmed
      }
      const result = await graphqlRequest(STATS_QUERY, variables)
      if (result && result.employeeReviewStats) {
        this.setData({ stats: result.employeeReviewStats, employeeReviewStats: result.employeeReviewStats })
      }
    } catch (error) {
      console.error('加载审核统计失败:', error)
    }
  },
  async loadList(reset = false) {
    if (reset) {
      this.setData({ loading: true })
    } else {
      this.setData({ loadingMore: true })
    }
    const nextPage = reset ? 1 : this.data.page + 1
    const keywordInput = this.data.searchKeyword || ''
    const trimmedKeyword = keywordInput.trim()
    const variables = {
      keyword: trimmedKeyword ? trimmedKeyword : null,
      state: this.getStateByTab(this.data.currentTab),
      page: nextPage,
      size: this.data.pageSize
    }
    try {
      const result = await graphqlRequest(LIST_QUERY, variables)
      const pageData = result && result.employeeReviewApplications
      const items = pageData && Array.isArray(pageData.content) ? pageData.content : []
      const list = reset ? items : this.data.list.concat(items)
      const total = pageData && typeof pageData.totalElements === 'number' ? pageData.totalElements : 0
      const hasMore = nextPage * this.data.pageSize < total
      this.setData({
        list,
        page: nextPage,
        hasMore
      })
    } catch (error) {
      console.error('加载审核列表失败:', error)
      wx.showToast({ title: '加载失败', icon: 'none' })
    } finally {
      if (reset) {
        this.setData({ loading: false })
      } else {
        this.setData({ loadingMore: false })
      }
    }
  },
  onSearchInput(e) {
    this.setData({ searchKeyword: e.detail.value || '' })
  },
  onSearch() {
    this.initData()
  },
  clearSearch() {
    if (!this.data.searchKeyword) return
    this.setData({ searchKeyword: '' })
    this.initData()
  },
  switchTab(e) {
    const index = Number(e.currentTarget.dataset.index || 0)
    if (index === this.data.currentTab) {
      return
    }
    this.setData({
      currentTab: index,
      page: 1,
      list: [],
      hasMore: true
    })
    this.initData()
  },
  getStateByTab(index) {
    switch (index) {
      case 0:
        return 0
      case 1:
        return 1
      case 2:
        return 2
      default:
        return null
    }
  },
  goToDetail(e) {
    const id = e.currentTarget.dataset.id
    if (!id) {
      wx.showToast({ title: '报名ID无效', icon: 'none' })
      return
    }
    wx.navigateTo({
      url: `/pages/profile/employee-review-detail?id=${id}`,
      events: {
        auditUpdated: () => {
          this.setData({ needRefresh: true })
        }
      }
    })
  }
})
wx/pages/profile/employee-review.json
New file
@@ -0,0 +1,5 @@
{
  "navigationBarTitleText": "我的审核",
  "enablePullDownRefresh": true,
  "backgroundTextStyle": "dark"
}
wx/pages/profile/employee-review.wxml
New file
@@ -0,0 +1,72 @@
<view class="container">
  <view class="search-bar">
    <input
      class="search-input"
      value="{{searchKeyword}}"
      placeholder="搜索比赛 / 项目 / 学员"
      bindinput="onSearchInput"
      confirm-type="search"
      bindconfirm="onSearch"
    />
    <view class="search-actions">
      <button class="primary-btn" bindtap="onSearch">搜索</button>
      <button class="plain-btn" bindtap="clearSearch" wx:if="{{searchKeyword}}">清空</button>
    </view>
  </view>
  <view class="tab-bar">
    <view
      class="tab-item {{currentTab === index ? 'active' : ''}}"
      wx:for="{{tabs}}"
      wx:key="value"
      data-index="{{index}}"
      bindtap="switchTab"
    >
      <text class="tab-label">{{item.label}}</text>
      <text class="tab-count">
        {{item.value === 0 ? employeeReviewStats.pendingCount || 0 :
          item.value === 1 ? employeeReviewStats.approvedCount || 0 :
          employeeReviewStats.rejectedCount || 0}}
      </text>
    </view>
  </view>
  <view class="list-container">
    <view wx:if="{{list.length > 0}}">
      <view class="audit-card" wx:for="{{list}}" wx:key="id">
        <view class="card-header">
          <text class="card-title">{{item.projectName || '未命名项目'}}</text>
          <text class="card-status status-{{item.stateType || 'info'}}">{{item.stateText || ''}}</text>
        </view>
        <view class="card-row">
          <text class="card-label">比赛</text>
          <text class="card-value">{{item.activityName || '-'}}</text>
        </view>
        <view class="card-row">
          <text class="card-label">学员</text>
          <text class="card-value">{{item.playerName || '-'}}</text>
        </view>
        <view class="card-row">
          <text class="card-label">报名时间</text>
          <text class="card-value">{{item.applyTime || '-'}}</text>
        </view>
        <view class="card-footer">
          <text class="card-tip">最新状态:{{item.stateText || '未知'}}</text>
          <button class="action-btn" data-id="{{item.id}}" bindtap="goToDetail">审核</button>
        </view>
      </view>
      <view class="load-more" wx:if="{{loadingMore}}">
        <text>加载中...</text>
      </view>
      <view class="load-more" wx:elif="{{!hasMore}}">
        <text>没有更多数据</text>
      </view>
    </view>
    <view wx:else class="empty-state">
      <text class="empty-icon">📂</text>
      <text class="empty-text">暂无符合条件的项目</text>
      <text class="empty-desc">尝试调整筛选条件或搜索其他关键词</text>
    </view>
  </view>
</view>
wx/pages/profile/employee-review.wxss
New file
@@ -0,0 +1,195 @@
.container {
  min-height: 100vh;
  background: #f5f5f5;
  padding: 24rpx;
}
.search-bar {
  display: flex;
  align-items: center;
  gap: 16rpx;
  margin-bottom: 24rpx;
}
.search-input {
  flex: 1;
  height: 72rpx;
  background: #ffffff;
  border-radius: 36rpx;
  padding: 0 24rpx;
  font-size: 28rpx;
  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.search-actions {
  display: flex;
  gap: 12rpx;
}
.primary-btn {
  background: #1677ff;
  color: #ffffff;
  border-radius: 36rpx;
  padding: 0 32rpx;
  height: 72rpx;
  line-height: 72rpx;
  font-size: 28rpx;
}
.plain-btn {
  background: #ffffff;
  color: #1677ff;
  border-radius: 36rpx;
  padding: 0 32rpx;
  height: 72rpx;
  line-height: 72rpx;
  font-size: 28rpx;
  border: 2rpx solid #1677ff;
}
.tab-bar {
  display: flex;
  background: #ffffff;
  border-radius: 16rpx;
  padding: 12rpx;
  margin-bottom: 24rpx;
  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.tab-item {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20rpx 0;
  border-radius: 12rpx;
  font-size: 26rpx;
  color: #666666;
  transition: all 0.2s ease;
}
.tab-item.active {
  background: linear-gradient(135deg, #1677ff 0%, #4091ff 100%);
  color: #ffffff;
}
.tab-count {
  margin-top: 8rpx;
  font-size: 32rpx;
  font-weight: 600;
}
.list-container {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}
.audit-card {
  background: #ffffff;
  border-radius: 16rpx;
  padding: 24rpx;
  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
  display: flex;
  flex-direction: column;
  gap: 16rpx;
}
.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.card-title {
  font-size: 32rpx;
  font-weight: 600;
  color: #1a1a1a;
}
.card-status {
  font-size: 26rpx;
  padding: 4rpx 16rpx;
  border-radius: 24rpx;
}
.status-warning {
  background: rgba(255, 180, 0, 0.15);
  color: #ffa114;
}
.status-success {
  background: rgba(17, 201, 128, 0.15);
  color: #16a75c;
}
.status-danger {
  background: rgba(255, 84, 84, 0.15);
  color: #ff5454;
}
.status-info {
  background: rgba(64, 145, 255, 0.15);
  color: #1677ff;
}
.card-row {
  display: flex;
  justify-content: space-between;
  font-size: 26rpx;
  color: #444444;
}
.card-label {
  color: #888888;
}
.card-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 8rpx;
}
.card-tip {
  font-size: 24rpx;
  color: #999999;
}
.action-btn {
  background: linear-gradient(135deg, #1677ff 0%, #4091ff 100%);
  color: #ffffff;
  padding: 0 32rpx;
  height: 64rpx;
  line-height: 64rpx;
  border-radius: 32rpx;
  font-size: 26rpx;
}
.load-more {
  text-align: center;
  color: #999999;
  font-size: 26rpx;
  padding: 20rpx 0;
}
.empty-state {
  margin-top: 120rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
  color: #999999;
  gap: 12rpx;
}
.empty-icon {
  font-size: 72rpx;
}
.empty-text {
  font-size: 28rpx;
}
.empty-desc {
  font-size: 24rpx;
}
wx/pages/profile/profile.js
@@ -27,6 +27,14 @@
    isJudge: false,
    isOrganizer: false,
    hasPlayer: false,
    isEmployee: false,
    // 员工审核统计
    employeeReviewStats: {
      pendingCount: 0,
      approvedCount: 0,
      rejectedCount: 0
    },
    
    // 评委相关数据
    judgeStats: {
@@ -198,6 +206,12 @@
            grade
            roles
            createdAt
            employee {
              id
              name
              roleId
              description
            }
            player {
              id
              name
@@ -218,6 +232,7 @@
        const isJudge = userRoles.includes('JUDGE')
        const isOrganizer = userRoles.includes('ORGANIZER')
        const hasPlayer = userInfo.player && userInfo.player.id
        const isEmployee = !!(userInfo.employee && userInfo.employee.id)
        
        // 处理头像文字
        const avatarText = (userInfo.name || '用户').substring(0, 1)
@@ -228,7 +243,8 @@
          userRoles,
          isJudge,
          isOrganizer,
          hasPlayer
          hasPlayer,
          isEmployee
        })
        
        // 更新全局用户信息
@@ -241,6 +257,18 @@
        
        if (isOrganizer) {
          this.loadOrganizerStats()
        }
        if (isEmployee) {
          this.loadEmployeeReviewStats()
        } else {
          this.setData({
            employeeReviewStats: {
              pendingCount: 0,
              approvedCount: 0,
              rejectedCount: 0
            }
          })
        }
      }
    } catch (error) {
@@ -358,6 +386,39 @@
    }
  },
  // 加载员工审核统计
  async loadEmployeeReviewStats(keyword = null) {
    if (!this.data.isEmployee) {
      return
    }
    try {
      const query = `
        query EmployeeReviewStats($keyword: String) {
          employeeReviewStats(keyword: $keyword) {
            pendingCount
            approvedCount
            rejectedCount
          }
        }
      `
      const variables = {}
      if (keyword && typeof keyword === 'string' && keyword.trim()) {
        variables.keyword = keyword.trim()
      }
      const result = await graphqlRequest(query, variables)
      if (result && result.employeeReviewStats) {
        this.setData({
          employeeReviewStats: result.employeeReviewStats
        })
      }
    } catch (error) {
      console.error('加载员工审核统计失败:', error)
    }
  },
  // 加载评委统计数据
  async loadJudgeStats() {
    try {
@@ -465,6 +526,17 @@
    })
  },
  // 跳转到员工审核页面
  goToEmployeeReviewPage() {
    if (!this.data.isEmployee) {
      return
    }
    wx.navigateTo({
      url: '/pages/profile/employee-review'
    })
  },
  // 查看报名详情
wx/pages/profile/profile.wxml
@@ -97,6 +97,33 @@
      </view>
    </view>
    <!-- 我的审核区域 - 仅员工可见 -->
    <view class="review-section" wx:if="{{isEmployee}}">
      <view class="section-header">
        <text class="section-title">我的审核</text>
        <view class="review-action-btn" bindtap="goToEmployeeReviewPage">
          <text class="action-text">审核</text>
          <text class="action-arrow">></text>
        </view>
      </view>
      <view class="review-stats">
        <view class="stat-item">
          <text class="stat-number">{{employeeReviewStats.pendingCount || 0}}</text>
          <text class="stat-label">待审核</text>
        </view>
        <view class="stat-divider"></view>
        <view class="stat-item">
          <text class="stat-number">{{employeeReviewStats.approvedCount || 0}}</text>
          <text class="stat-label">已审核</text>
        </view>
        <view class="stat-divider"></view>
        <view class="stat-item">
          <text class="stat-number">{{employeeReviewStats.rejectedCount || 0}}</text>
          <text class="stat-label">驳回</text>
        </view>
      </view>
    </view>
  </view>
</view>