| backend/pom.xml | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| backend/src/main/java/com/rongyichuang/config/SecurityConfig.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| backend/src/main/java/com/rongyichuang/config/WebConfig.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| backend/src/main/java/com/rongyichuang/player/api/PlayerExportController.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| backend/src/main/java/com/rongyichuang/player/service/PlayerApplicationService.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| web/src/views/activity-list.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| web/src/views/news-list.vue | ●●●●● 补丁 | 查看 | 原始文档 | 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 }">