c4938f6f4e839890b032c75c7a57333a6a9157a9..858f515995fd1dca7cf825069ce38c32703298d0
2025-11-07 peng
报名人员导出
858f51 对比 | 目录
2025-11-07 peng
页面显示字段调整
356a9f 对比 | 目录
2025-11-07 peng
解决线上打包问题
f64693 对比 | 目录
6个文件已修改
1个文件已添加
504 ■■■■■ 已修改文件
backend/pom.xml 31 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/config/SecurityConfig.java 23 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/config/WebConfig.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/api/PlayerExportController.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/views/activity-list.vue 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/views/news-list.vue 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/pom.xml
@@ -168,15 +168,15 @@
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <!-- 生成可执行的胖包,便于本地直接运行 -->
                    <skip>false</skip>
                    <skip>true</skip>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
<!--                <executions>-->
<!--                    <execution>-->
<!--                        <goals>-->
<!--                            <goal>repackage</goal>-->
<!--                        </goals>-->
<!--                    </execution>-->
<!--                </executions>-->
            </plugin>
            <!-- 在打包阶段复制所有依赖到 target/lib -->
@@ -203,6 +203,21 @@
            </plugin>
            <!-- 使用 Spring Boot repackage 生成可执行胖包,移除自定义 Jar 清单以避免冲突 -->
            <!-- 生成可执行瘦 JAR:写入 Main-Class 与 Class-Path 指向 lib/ -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.4.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                            <classpathPrefix>lib/</classpathPrefix>
                            <mainClass>com.rongyichuang.RycBackendApplication</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
backend/src/main/java/com/rongyichuang/config/SecurityConfig.java
@@ -14,6 +14,7 @@
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@@ -48,12 +49,22 @@
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                // 注意:应用设置了 context-path=/api,为避免匹配歧义,这里同时匹配去除和包含 context-path 的路径
                .requestMatchers("/auth/**", "/api/auth/**", "/actuator/**", "/test/**", "/cleanup/**").permitAll()
                .requestMatchers("/api/health/**").permitAll() // 允许健康检查端点访问
                .requestMatchers("/upload/**").permitAll()
                .requestMatchers("/graphiql/**", "/graphql/**", "/api/graphql/**", "/api/graphiql/**").permitAll() // 允许GraphQL和GraphiQL访问
                .requestMatchers("/**/graphql", "/**/graphiql").permitAll() // 更宽泛的GraphQL路径匹配
                .requestMatchers(
                    new AntPathRequestMatcher("/auth/**"),
                    new AntPathRequestMatcher("/api/auth/**"),
                    new AntPathRequestMatcher("/actuator/**"),
                    new AntPathRequestMatcher("/test/**"),
                    new AntPathRequestMatcher("/cleanup/**"),
                    new AntPathRequestMatcher("/api/health/**"),
                    new AntPathRequestMatcher("/upload/**"),
                    new AntPathRequestMatcher("/api/upload/**"),
                    new AntPathRequestMatcher("/graphiql/**"),
                    new AntPathRequestMatcher("/graphql/**"),
                    new AntPathRequestMatcher("/api/graphql/**"),
                    new AntPathRequestMatcher("/api/graphiql/**"),
                    new AntPathRequestMatcher("/player/export/applications"),
                    new AntPathRequestMatcher("/api/player/export/applications")
                ).permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
backend/src/main/java/com/rongyichuang/config/WebConfig.java
@@ -3,15 +3,24 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.util.pattern.PathPatternParser;
/**
 * Web配置类
 */
@Configuration
public class WebConfig {
public class WebConfig implements WebMvcConfigurer {
    
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        // 使用PathPatternParser而不是AntPathMatcher
        configurer.setPatternParser(new PathPatternParser());
    }
}
backend/src/main/java/com/rongyichuang/player/api/PlayerExportController.java
New file
@@ -0,0 +1,63 @@
package com.rongyichuang.player.api;
import com.rongyichuang.player.service.PlayerApplicationService;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/player/export")
public class PlayerExportController {
    private static final Logger log = LoggerFactory.getLogger(PlayerExportController.class);
    private final PlayerApplicationService playerApplicationService;
    public PlayerExportController(PlayerApplicationService playerApplicationService) {
        this.playerApplicationService = playerApplicationService;
    }
    /**
     * 导出比赛报名人员Excel
     */
    @GetMapping("/applications")
    public void exportApplications(
            @RequestParam(required = false) String name,
            @RequestParam(required = false) Long activityId,
            @RequestParam(required = false) Integer state,
            HttpServletResponse response) {
        try {
            log.info("导出比赛报名人员Excel, name: {}, activityId: {}, state: {}", name, activityId, state);
            // 获取Excel数据
            byte[] excelData = playerApplicationService.exportApplicationsToExcel(name, activityId, state);
            // 设置响应头
            response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
            String fileName = "报名人员_" + System.currentTimeMillis() + ".xlsx";
            String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString());
            response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + encodedFileName);
            response.setHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION);
            response.setContentLength(excelData.length);
            // 写入响应
            response.getOutputStream().write(excelData);
            response.getOutputStream().flush();
        } catch (Exception e) {
            log.error("导出比赛报名人员Excel失败", e);
            try {
                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                response.getWriter().write("导出失败: " + e.getMessage());
            } catch (IOException ioException) {
                log.error("写入错误响应失败", ioException);
            }
        }
    }
}
backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java
@@ -4,8 +4,12 @@
import com.rongyichuang.player.dto.response.ActivityPlayerApplicationResponse;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -124,6 +128,135 @@
    }
    /**
     * 导出活动报名信息为Excel
     */
    @SuppressWarnings("unchecked")
    public byte[] exportApplicationsToExcel(String name, Long activityId, Integer state) throws IOException {
        String baseSql =
            "SELECT ap.id, p.name AS player_name, parent.name AS activity_name, ap.project_name AS project_name, u.phone AS phone, ap.create_time AS apply_time, ap.state AS state, " +
            "COALESCE(rating_stats.rating_count, 0) AS rating_count, rating_stats.average_score " +
            "FROM t_activity_player ap " +
            "JOIN t_player p ON p.id = ap.player_id " +
            "JOIN t_user u ON u.id = p.user_id " +
            "JOIN t_activity stage ON stage.id = ap.stage_id " +
            "JOIN t_activity parent ON parent.id = stage.pid " +
            "LEFT JOIN (" +
            "  SELECT activity_player_id, COUNT(*) AS rating_count, AVG(total_score) AS average_score " +
            "  FROM t_activity_player_rating " +
            "  WHERE state = 1 " +
            "  GROUP BY activity_player_id" +
            ") rating_stats ON rating_stats.activity_player_id = ap.id ";
        StringBuilder whereClause = new StringBuilder();
        boolean hasCondition = false;
        // 默认只显示第一阶段的数据(基于sort_order=1),避免硬编码阶段名称
        whereClause.append("stage.sort_order = 1");
        hasCondition = true;
        if (name != null && !name.isEmpty()) {
            if (hasCondition) {
                whereClause.append(" AND ");
            }
            whereClause.append("p.name LIKE CONCAT('%', :name, '%')");
            hasCondition = true;
        }
        if (activityId != null) {
            if (hasCondition) {
                whereClause.append(" AND ");
            }
            // 查询指定主比赛的第一阶段报名项目:activity_id=主比赛ID, stage_id=第一阶段ID
            whereClause.append("ap.activity_id = :activityId AND ap.stage_id = stage.id AND stage.pid = :activityId AND stage.sort_order = 1");
            hasCondition = true;
        }
        if (state != null) {
            if (hasCondition) {
                whereClause.append(" AND ");
            }
            whereClause.append("ap.state = :state");
            hasCondition = true;
        }
        String where = hasCondition ? "WHERE " + whereClause.toString() + " " : "";
        String order = "ORDER BY ap.create_time DESC ";
        var q = em.createNativeQuery(baseSql + where + order);
        if (name != null && !name.isEmpty()) {
            q.setParameter("name", name);
        }
        if (activityId != null) {
            q.setParameter("activityId", activityId);
        }
        if (state != null) {
            q.setParameter("state", state);
        }
        List<Object[]> rows = q.getResultList();
        // 创建Excel工作簿
        Workbook workbook = new XSSFWorkbook();
        Sheet sheet = workbook.createSheet("报名人员");
        // 创建标题行
        Row headerRow = sheet.createRow(0);
        String[] headers = {"ID", "学员名称", "比赛名称", "项目名称", "联系电话", "申请时间", "状态", "评分次数", "平均分"};
        for (int i = 0; i < headers.length; i++) {
            Cell cell = headerRow.createCell(i);
            cell.setCellValue(headers[i]);
            // 设置标题样式
            CellStyle headerStyle = workbook.createCellStyle();
            Font font = workbook.createFont();
            font.setBold(true);
            headerStyle.setFont(font);
            cell.setCellStyle(headerStyle);
        }
        // 填充数据
        int rowNum = 1;
        for (Object[] r : rows) {
            Row row = sheet.createRow(rowNum++);
            row.createCell(0).setCellValue(r[0] != null ? r[0].toString() : "");
            row.createCell(1).setCellValue(r[1] != null ? r[1].toString() : "");
            row.createCell(2).setCellValue(r[2] != null ? r[2].toString() : "");
            row.createCell(3).setCellValue(r[3] != null ? r[3].toString() : "");
            row.createCell(4).setCellValue(r[4] != null ? r[4].toString() : "");
            row.createCell(5).setCellValue(r[5] != null ? r[5].toString() : "");
            // 状态转换
            String stateText = "未知";
            if (r[6] != null) {
                int stateValue = Integer.parseInt(r[6].toString());
                switch (stateValue) {
                    case 0: stateText = "未审核"; break;
                    case 1: stateText = "审核通过"; break;
                    case 2: stateText = "审核驳回"; break;
                    default: stateText = "未知";
                }
            }
            row.createCell(6).setCellValue(stateText);
            row.createCell(7).setCellValue(r[7] != null ? r[7].toString() : "0");
            row.createCell(8).setCellValue(r[8] != null ? r[8].toString() : "");
        }
        // 自动调整列宽
        for (int i = 0; i < headers.length; i++) {
            sheet.autoSizeColumn(i);
        }
        // 将工作簿写入字节数组
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        workbook.write(outputStream);
        workbook.close();
        outputStream.close();
        return outputStream.toByteArray();
    }
    /**
     * 项目评审专用查询,包含所有阶段数据(包括复赛、决赛)
     * 与listApplications的区别:不过滤复赛和决赛阶段
     */
web/src/views/activity-list.vue
@@ -54,9 +54,25 @@
            <el-tag :type="getStatusType(row.stateName)">{{ row.stateName }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column label="操作" width="180" fixed="right" align="center">
        <el-table-column label="操作" width="220" fixed="right" align="center">
          <template #default="{ row }">
            <div class="table-actions">
              <el-button
                text
                :icon="User"
                size="small"
                @click="handleViewPlayers(row)"
                class="action-btn players-btn"
                title="查看报名人员"
              />
              <el-button
                text
                :icon="Download"
                size="small"
                @click="handleExportPlayers(row)"
                class="action-btn export-btn"
                title="导出报名人员"
              />
              <el-button
                text
                :icon="View"
@@ -99,6 +115,52 @@
        />
      </div>
    </div>
    <!-- 查看报名人员弹窗 -->
    <el-dialog
      v-model="playerDialogVisible"
      :title="'报名人员 - ' + (currentActivity?.name || '')"
      width="80%"
      @close="handlePlayerDialogClose"
    >
      <!-- 弹窗工具栏 -->
      <div class="dialog-toolbar">
        <el-button type="primary" :icon="Download" @click="handleExportPlayersFromDialog">
          导出Excel
        </el-button>
      </div>
      <el-table :data="playerList" v-loading="playerListLoading" style="width: 100%">
        <el-table-column prop="playerName" label="学员名称" min-width="120" />
        <el-table-column prop="projectName" label="项目名称" min-width="150" />
        <el-table-column prop="phone" label="联系电话" width="140" />
        <el-table-column prop="applyTime" label="申请时间" width="180" />
        <el-table-column prop="state" label="状态" width="100" align="center">
          <template #default="{ row }">
            <el-tag :type="getPlayerStateType(row.state)">{{ getPlayerStateText(row.state) }}</el-tag>
          </template>
        </el-table-column>
      </el-table>
      <!-- 报名人员分页 -->
      <div class="pagination" v-if="playerPagination.total > 0">
        <el-pagination
          v-model:current-page="playerPagination.page"
          v-model:page-size="playerPagination.size"
          :page-sizes="[10, 20, 50, 100]"
          :total="playerPagination.total"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handlePlayerSizeChange"
          @current-change="handlePlayerCurrentChange"
        />
      </div>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="playerDialogVisible = false">关闭</el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>
@@ -106,8 +168,10 @@
import { reactive, ref, onMounted, onActivated, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getActivities, updateActivityState } from '@/api/activity'
import { Search, Plus, Edit, Delete, View } from '@element-plus/icons-vue'
import { getActivities, deleteActivity } from '@/api/activity'
// @ts-ignore
import { PlayerApi } from '@/api/player'
import { Search, Plus, Edit, Delete, View, User, Download } from '@element-plus/icons-vue'
console.log('=== activity-list.vue 组件开始加载 ===')
@@ -136,6 +200,17 @@
// 表格数据
const tableData = ref([])
// 报名人员弹窗相关
const playerDialogVisible = ref(false)
const playerListLoading = ref(false)
const playerList = ref([])
const playerPagination = reactive({
  page: 1,
  size: 10,
  total: 0
})
const currentActivity = ref<any>(null)
// 调试用途:监听表格数据变化
watch(
  tableData,
@@ -158,6 +233,26 @@
    关闭: 'danger'
  }
  return typeMap[status] || 'info'
}
// 获取报名人员状态标签类型
const getPlayerStateType = (state: number) => {
  const typeMap: Record<number, string> = {
    0: 'info',    // 未审核
    1: 'success', // 审核通过
    2: 'danger'   // 审核驳回
  }
  return typeMap[state] || 'info'
}
// 获取报名人员状态文本
const getPlayerStateText = (state: number) => {
  const textMap: Record<number, string> = {
    0: '未审核',
    1: '审核通过',
    2: '审核驳回'
  }
  return textMap[state] || '未知'
}
// 搜索
@@ -192,6 +287,107 @@
  router.push(`/activity/${row.id}`)
}
// 查看报名人员弹窗关闭处理
const handlePlayerDialogClose = () => {
  // 清空当前活动
  currentActivity.value = null
  // 清空报名人员列表
  playerList.value = []
  // 重置分页
  playerPagination.page = 1
  playerPagination.total = 0
}
// 查看报名人员
const handleViewPlayers = async (row: any) => {
  // 设置当前活动
  currentActivity.value = row
  // 重置分页
  playerPagination.page = 1
  playerPagination.size = 10
  // 显示弹窗
  playerDialogVisible.value = true
  // 加载数据
  await loadPlayerList()
}
// 加载报名人员列表
const loadPlayerList = async () => {
  if (!currentActivity.value) return
  playerListLoading.value = true
  try {
    // @ts-ignore 忽略TypeScript检查,因为函数实际支持更多参数
    const data = await PlayerApi.getApplications('', currentActivity.value.id, null, playerPagination.page - 1, playerPagination.size)
    playerList.value = data.content || []
    playerPagination.total = data.totalElements || 0
  } catch (e: any) {
    console.error('加载报名人员失败:', e)
    ElMessage.error(e?.message || '加载报名人员失败')
  } finally {
    playerListLoading.value = false
  }
}
// 报名人员分页处理
const handlePlayerSizeChange = (size: number) => {
  playerPagination.size = size
  loadPlayerList()
}
const handlePlayerCurrentChange = (page: number) => {
  playerPagination.page = page
  loadPlayerList()
}
// 从弹窗导出报名人员Excel
const handleExportPlayersFromDialog = async () => {
  if (!currentActivity.value) {
    ElMessage.error('当前没有选中的比赛')
    return
  }
  try {
    // 构造导出URL,使用完整的API路径
    const exportUrl = `/api/player/export/applications?activityId=${currentActivity.value.id}`
    // 创建一个隐藏的a标签来触发下载
    const link = document.createElement('a')
    link.href = exportUrl
    link.download = `报名人员_${currentActivity.value.name}_${new Date().getTime()}.xlsx`
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    ElMessage.success('导出成功')
  } catch (error) {
    console.error('导出失败:', error)
    ElMessage.error('导出失败: ' + (error as Error).message)
  }
}
// 导出报名人员(原列表中的导出功能保持不变)
const handleExportPlayers = async (row: any) => {
  try {
    // 构造导出URL,使用完整的API路径
    const exportUrl = `/api/player/export/applications?activityId=${row.id}`
    // 创建一个隐藏的a标签来触发下载
    const link = document.createElement('a')
    link.href = exportUrl
    link.download = `报名人员_${row.name}_${new Date().getTime()}.xlsx`
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    ElMessage.success('导出成功')
  } catch (error) {
    console.error('导出失败:', error)
    ElMessage.error('导出失败: ' + (error as Error).message)
  }
}
// 删除比赛
const handleDelete = async (row: any) => {
  try {
@@ -205,7 +401,7 @@
      }
    )
    await updateActivityState(row.id, 2)
    await deleteActivity(row.id)
    ElMessage.success('删除成功')
    loadData()
  } catch {
@@ -230,15 +426,17 @@
  loading.value = true
  try {
    const keyword = (searchForm.name || '').trim()
    const data = await getActivities(
      pagination.page - 1,
      pagination.size,
      keyword,
      searchForm.state
    )
    // 使用条件调用避免TypeScript错误
    let data;
    if (searchForm.state !== '') {
      // @ts-ignore 忽略TypeScript检查,因为函数实际支持4个参数
      data = await getActivities(pagination.page - 1, pagination.size, keyword, Number(searchForm.state))
    } else {
      data = await getActivities(pagination.page - 1, pagination.size, keyword)
    }
    // 数据映射:将 API 返回字段转换为表格需要的字段
    const mappedData = (data?.content || []).map(item => ({
    const mappedData = (data?.content || []).map((item: any) => ({
      ...item,
      playerCount: item.playerCount || 0,
      stateName: item.stateName || ''
@@ -374,6 +572,26 @@
  background: rgba(103, 194, 58, 0.1) !important;
}
.players-btn {
  color: #E6A23C;
}
.players-btn:hover {
  color: #cf9236;
  transform: scale(1.2);
  background: rgba(230, 162, 60, 0.1) !important;
}
.export-btn {
  color: #909399;
}
.export-btn:hover {
  color: #a6a9ad;
  transform: scale(1.2);
  background: rgba(144, 147, 153, 0.1) !important;
}
.pagination {
  margin-top: 20px;
  display: flex;
@@ -398,4 +616,4 @@
    max-width: 280px;
  }
}
</style>
</style>
web/src/views/news-list.vue
@@ -60,7 +60,6 @@
          </template>
        </el-table-column>
        <el-table-column prop="author" label="作者" width="120" />
        <el-table-column prop="viewCount" label="浏览量" width="100" align="center" />
        <el-table-column prop="createTime" label="创建时间" width="180" />
        <el-table-column prop="stateName" label="状态" width="100" align="center">
          <template #default="{ row }">