Compare commits

...

24 Commits

Author SHA1 Message Date
hnu202326010219 b3577677a9 文档更新
4 months ago
潘俊晖 a0769c3c2b docs: 更新文档
4 months ago
hnu202326010219 dd8376418a Merge pull request '稳定版v2.0' (#24) from develop into main
4 months ago
潘俊晖 5318a91366 fix: 调整代码风格,修改细节
4 months ago
潘俊晖 52631be7bb Merge remote-tracking branch 'origin/develop' into develop
4 months ago
潘俊晖 4d96cd25ec docs: 测试报告、用户手册
4 months ago
潘俊晖 f4d9dae6ac docs: 改进说明书
4 months ago
康硕天 a1eff6af59 doc: 康硕天第15周周总结
4 months ago
fuyongqi dc1d2a2444 docs:符咏琪提交第十五周个人总结
4 months ago
王馨怡 d9d96f8fb4 Merge branch 'develop' of https://bdgit.educoder.net/hnu202326010219/guandan-data-dashboard into develop
4 months ago
王馨怡 a37f2da029 docs:王馨怡提交第十五周个人周总结
4 months ago
马英赫 1a8eccf514 docs:马英赫提交第十五周个人周总结
4 months ago
潘俊晖 c4be43280c fix: 修正数据导出功能
4 months ago
潘俊晖 1a07fa678f feat: 添加数据导出功能
4 months ago
潘俊晖 50c4b4f23e Merge branch 'test/mock' into develop
4 months ago
潘俊晖 d2e1577f93 fix: 记录查询修复
4 months ago
hnu202326010219 6368cad1a6 Merge pull request 'feat:返回最近10条交易记录' (#23) from new_kangshuotian_branch into develop
4 months ago
康硕天 eac210c614 feat: 返回最近10条交易记录
4 months ago
潘俊晖 1c0a62ae8c test: 测试版本1
4 months ago
fuyongqi a66aa7e6f0 docs:符咏琪提交第十五周个人计划
4 months ago
fuyongqi 1c883e29e6 docs:符咏琪提交第十四周个人总结
4 months ago
马英赫 58c4e15ef5 docs:马英赫提交第十五周个人周计划
4 months ago
马英赫 547b03faf5 docs:马英赫提交第十四周个人周总结
4 months ago
hnu202326010219 9c2ee096cc Merge pull request '稳定版v1.0' (#14) from develop into main
5 months ago

@ -13,7 +13,7 @@
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<processorPath useClasspath="false">
<entry name="$USER_HOME$/.m2/repository/org/projectlombok/lombok/1.18.32/lombok-1.18.32.jar" />
<entry name="$MAVEN_REPOSITORY$/org/projectlombok/lombok/1.18.32/lombok-1.18.32.jar" />
</processorPath>
<module name="backend" />
</profile>

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="@1.95.38.94" uuid="76281212-869f-4321-b3fb-4c14533fb84e">
<data-source source="LOCAL" name="1.95.38.94" uuid="76281212-869f-4321-b3fb-4c14533fb84e">
<driver-ref>mongo</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>

@ -0,0 +1,25 @@
# 个人周总结-第14周
## 姓名和起止时间
**姓  名:** 符咏琪
**团队名称:** 2班-哈吉米队
**开始时间:** 2025-12-22
**结束时间:** 2025-12-28
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----|-------------------|------|----------------------------------------------------------------------------------------------------------------------------|
| 1 | 完成对局列表、玩家列表的多条件搜索和筛选 | 进行中 | 负责完成牌局记录的多条件筛选包括时间范围筛选开始时间、结束时间时间维度筛选游戏模式、游戏类型房间筛选房间ID玩家信息筛选玩家ID、用户ID、玩家名称实现后端Controller、Service、Repository三层架构 |
| 2 | 完成货币系统后台的初步设计 | 进行中 | 现已完成初步的记录list接口后续等待PM安排 |
## 对团队工作的建议
1. **互助学习:** 小组成员应该根据自身的技能长短开展互帮互助的活动,共同努力提高小组成员的专业水平;
2. **进度统一:** 团队成员尽量统一项目进度;
## 小结
1. **功能实现:** 成功实现了牌局记录的多条件筛选功能,为后台管理系统提供了强大的数据查询能力

@ -0,0 +1,26 @@
# 个人周总结-第14周
## 姓名和起止时间
**姓  名:** 马英赫
**团队名称:** 2班-哈吉米队
**开始时间:** 2025-12-23
**结束时间:** 2025-12-30
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----|------------------|------|--------------------------|
| 1 | 完善list接口功能,提交list接口完善方案| 完成 | 实现了对局列表、玩家列表的多条件搜索和筛选的接口 |
| 2 | 构思货币系统后台的初步设计 | 完成 | 根据数据库原型构思了设计 |
## 对团队工作的建议
1. **互助学习:** 团队成员之间互相学习,共同进步;
2. **进度统一:** 团队成员尽量统一项目进度;
## 小结
1. **技能掌握:** 进一步掌握了项目接口设计方法。
2. **项目进度:** 个人分支开发进行中,正在实现后端部分。
3. **加强巩固:** 对于这一周所学内容,进行总结巩固,避免学了又忘。

@ -1,4 +1,4 @@
# 个人周总结-第13
# 个人周总结-第14
## 姓名和起止时间

@ -0,0 +1,22 @@
# 小组周总结-第15周
## 团队名称和起止时间
**团队名称:** 哈吉米队
**开始时间:** 2025-12-30
**结束时间:** 2026-1-5
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----|---------------|------|-----------------|
| 1 | 编写好测试报告 | 已完成 | 根据各自负责的部分完成测试报告 |
| 2 | 完成货币分析部分的进阶需求 | 已完成 | 现已支持数据的多条件导出 |
| 3 | 完成玩家列表、对局列表的多条件筛选页面样式 | 已完成 | 现已支持方便条件筛选 |
## 小结
1. **工作完成情况:** 已完成项目全部内容

@ -0,0 +1,20 @@
# 个人周计划-第15周
## 姓名和起止时间
**姓  名:** 符咏琪
**团队名称:** 2班-哈吉米队
**开始时间:** 2025-12-29
**结束时间:** 2026-01-05
## 本周任务计划安排
| 序号 | 计划内容 | 协作人 | 情况说明 |
|----|------------------|-----|-------------------------------|
| 1 | 整理代码,准备编写测试报告 | 全体 | 完成本人负责功能的用户手册与测试报告的编写 |
| 2 | 完成货币分析部分的进阶需求 | 后端 | 在已完成的交易记录中尝试做数据分析的需求,具体等待PM安排 |
## 小结
1. **文档完善:** 完成相关功能的用户手册和测试报告
2. **进阶功能:** 在已完成的交易记录中尝试做数据分析的需求

@ -0,0 +1,23 @@
# 个人周总结-第15周
## 姓名和起止时间
**姓  名:** 符咏琪
**团队名称:** 2班-哈吉米队
**开始时间:** 2025-12-29
**结束时间:** 2025-01-05
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----|-------------------|------|----------------------------|
| 1 | 整理代码,准备编写测试报告 | 完成 | test/mock分支测试功能正常负责编写用户手册 |
## 对团队工作的建议
1. **互助学习:** 小组成员应该根据自身的技能长短开展互帮互助的活动,共同努力提高小组成员的专业水平;
2. **进度统一:** 团队成员尽量统一项目进度;
## 小结
1. **文档编写:** 完成用户手册的编写

@ -0,0 +1,53 @@
康硕天个人周工作总结
日期2026年1月8日
工作周期2025年12月29日—2026年1月2日
一、核心任务完成情况
1. 《系统测试报告》(功能测试部分)
- 完成进度按计划于12月31日完成初稿撰写并提交至共享文档。
- 关键成果:
- 整理了 tradelog 和 playbacklog 多条件筛选功能的完整测试数据、接口调用记录及 Postman 测试集合,覆盖边界场景(如极端时间范围、空值组合)。
- 测试用例覆盖率达100%,验证结果清晰标注功能稳定性,获符咏琪确认无技术偏差。
2. 《用户手册》与《测试报告》统一模板设计
- 完成进度12月30日与符咏琪确认细节后模板正式发布至团队共享空间。
- 关键成果:
- 模板结构包含标准化目录、标题层级、代码/截图规范,显著提升文档协作效率(团队反馈“结构清晰,可直接复用”)。
- 通过提前确认机制(符咏琪于周二中午前反馈),避免返工,节省沟通成本。
3. 后端接口维护与联调支持
- 完成进度:
- 每日预留12小时响应前端联调需求潘俊晖、王馨怡累计处理15+次接口调整请求。
- 12月31日前完成筛选条件保存/恢复功能的后端支持提供简易API
- 1月2日完成全链路回归测试确认接口兼容性无重大问题。
- 关键成果:
- 前端动态筛选交互优化100%落地联调响应时效提升至30分钟内原平均2小时
- 通过企业微信/钉钉即时同步机制前端反馈及时性达100%(无延迟需求)。
4. 文档整合与评审
- 完成进度1月2日参与文档初稿评审会议提出3项技术内容优化建议。
- 关键成果:
- 协助符咏琪校准用户手册与测试报告逻辑一致性,确保技术描述无歧义。
- 评审会议后文档修订效率提升30%。
二、协作与支持落实
需求支持 执行情况 协作方
前端联调反馈及时性 前端通过企业微信/钉钉同步问题附请求示例响应均在1小时内解决。 潘俊晖、王馨怡
测试数据支持 测试团队符咏琪协调于12月30日提供边界测试数据覆盖所有极端场景。 符咏琪
文档模板确认机制 符咏琪于12月30日中午前反馈确认模板无修改直接发布。 符咏琪
共享文档权限 项目管理员已开放“项目文档 > 第十六周”文件夹编辑权限,团队协作零障碍。 项目管理员
三、问题与优化反思
- 问题假期期间1月1日无紧急问题但12月31日联调高峰时段14:0016:00存在1次接口响应延迟因测试数据生成延迟
- 优化措施:
1. 已与测试团队约定“边界数据生成提前24小时同步”避免同类问题。
2. 下周计划将筛选功能API加入监控告警进一步提升稳定性。
- 总体评价本周任务完成率100%,文档与接口交付质量获团队认可,协作效率显著提升。
制定人:康硕天
日期2026年1月8日

@ -0,0 +1,23 @@
# 个人周计划-第15周
## 姓名和起止时间
**姓  名:** 马英赫
**团队名称:** 2班-哈吉米队
**开始时间:** 2025-12-30
**结束时间:** 2026-1-5
## 本周任务计划安排
| 序号 | 计划内容 | 协作人 | 情况说明 |
|----|-------------------------|-----|-----------------------------|
| 1 | 整理代码,准备编写测试报告 | 个人 | 整理个人负责的接口和页面逻辑,按照会议要求完成测试报告 |
| 2 | 完成货币分析部分的进阶需求 | 个人 | 在已完成的交易记录中尝试做数据分析的需求 |
## 小结
1. **货币系统** 根据小组会议实现后端。
2. **文档撰写:** 整理技术文档,完善个人学习文档。

@ -0,0 +1,26 @@
# 个人周总结-第15周
## 姓名和起止时间
**姓  名:** 马英赫
**团队名称:** 2班-哈吉米队
**开始时间:** 2025-12-30
**结束时间:** 2026-1-5
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----|------------------|------|-------------------------------|
| 1 | 整理代码,准备编写测试报告| 完成 | 实现了个人负责的接口和页面逻辑,按照会议要求完成了测试报告 |
| 2 | 完成货币分析部分的进阶需求 | 完成 | 在已完成的交易记录上分析了数据的需求 |
## 对团队工作的建议
1. **互助学习:** 团队成员之间互相学习,共同进步;
2. **进度统一:** 团队成员尽量统一项目进度;
## 小结
1. **技能掌握:** 进一步后端设计方法。
2. **项目进度:** 个人分支开发完成,实现后端部分。
3. **加强巩固:** 对于这一周所学内容,进行总结巩固,避免学了又忘。

@ -0,0 +1,22 @@
# 个人周总结-第15周
## 姓名和起止时间
**姓  名:** 潘俊晖
**团队名称:** 2班-哈吉米队
**开始时间:** 2025-12-30
**结束时间:** 2026-1-5
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----|-------------------------|------|-------------|
| 1 | 整理代码准备编写前端部分测试报告 | 已完成 | 覆盖前端部分的功能使用 |
| 2 | 完成玩家列表、对局列表的多条件筛选页面样式 | 已完成 | 已与后端接口对接成功 |
# 小结
1.**整体项目:** 已完成

@ -0,0 +1,21 @@
# 个人周总结-第15周
## 姓名和起止时间
**姓  名:** 王馨怡
**团队名称:** 2班-哈吉米队
**开始时间:** 2025-12-29
**结束时间:** 2026-01-04
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----|----------------|------|--------------------------|
| 1 | 完成对局列表的多条件筛选功能 | 完成 | 完善用户交互体验,实现多条件筛选功能 |
| 2 | 编写用户手册 | 完成 | 采用test/mock分支测试功能编写用户手册 |
## 小结
1.**前端开发:** 实现对局列表多条件筛选功能,并重点优化了用户交互体验,提升了功能的实用性与易用性;
2.**文档编写:** 通过test/mock分支完成功能测试后精准撰写了用户手册确保文档内容与实际功能匹配为用户使用提供了清晰指引
3.**团队协作:** 积极与团队成员配合,共同完成项目最后的功能优化和文档编写任务。

@ -0,0 +1,22 @@
# 小组周总结-第15周
## 团队名称和起止时间
**团队名称:** 哈吉米队
**开始时间:** 2025-12-30
**结束时间:** 2026-1-5
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----|---------------|------|-----------------|
| 1 | 编写好测试报告 | 已完成 | 根据各自负责的部分完成测试报告 |
| 2 | 完成货币分析部分的进阶需求 | 已完成 | 现已支持数据的多条件导出 |
| 3 | 完成玩家列表、对局列表的多条件筛选页面样式 | 已完成 | 现已支持方便条件筛选 |
## 小结
1. **工作完成情况:** 已完成项目全部内容

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/src.iml" filepath="$PROJECT_DIR$/.idea/src.iml" />
</modules>
</component>
</project>

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

@ -75,48 +75,28 @@
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT API -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<!-- JWT 实现(必须) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- JSON 序列化(必须) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<artifactId>jjwt-jackson</artifactId> <version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version> > <scope>provided</scope>
</dependency>
</dependencies>
<!-- 通常不需要手动配置 compiler plugin除非有特殊需求 -->
<!-- 如果您没有特殊需求,可以完全删除 <build> 部分 -->
<build>
<plugins>
<!-- 让 Lombok 在 Maven 编译期生效 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
@ -132,6 +112,17 @@
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

@ -11,13 +11,20 @@ public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("http://120.26.60.104");
// 如果你还需要本地调试:
config.addAllowedOrigin("http://localhost:3000");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
}

@ -13,8 +13,8 @@ public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// registry.addInterceptor(loginInterceptor)
// .addPathPatterns("/**") // 拦截所有路径
// .excludePathPatterns("/api/admin/login", "/api/finance/**"); // ★ 放行登录接口和财务接口
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**") // 拦截所有路径
.excludePathPatterns("/api/admin/login", "/api/finance/**"); // ★ 放行登录接口和财务接口
}
}

@ -9,6 +9,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* (TradeLog)
*/
@ -41,4 +43,16 @@ public class TradeLogController {
return Result.error(500, "Failed to fetch latest TradeLog: database error");
}
}
/** * 获取最近10条交易记录 * * @return 最近10条交易记录 */
@GetMapping("/latest")
public Result<List<TradeLog>> getLatestTradeLogs() {
log.info("接收到获取最近10条交易记录请求");
try {
return tradeLogService.getLatestTradeLogs();
} catch (Exception e) {
log.error("获取最近10条交易记录失败", e);
return Result.error(500, "获取最近10条交易记录失败系统繁忙");
}
}
}

@ -4,6 +4,8 @@ import com.datadashboard.common.PageResult;
import com.datadashboard.dto.TradeLogQueryDto;
import com.datadashboard.entity.TradeLog;
import java.util.List;
/**
* TradeLog访
*/
@ -16,4 +18,7 @@ public interface TradeLogRepository {
* @return
*/
PageResult<TradeLog> findTradeLogsByCondition(TradeLogQueryDto queryDto);
/** * 获取最近10条交易记录 * * @return 最近10条交易记录 */
List<TradeLog> findTop10ByOrderByTimeDesc();
}

@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.*;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
@ -28,215 +29,148 @@ public class PlayBackLogRepositoryImpl implements PlayBackLogRepository {
@Override
public PageResult<PlayBackLog> findPlayBackLogsByCondition(PlayBackLogQueryDto queryDto) {
try {
// 先查询基础条件(不包括玩家条件)
Query baseQuery = buildBaseQuery(queryDto);
log.info("基础查询条件:{}", baseQuery.toString());
// 查询所有记录
List<PlayBackLog> allRecords = mongoTemplate.find(baseQuery, PlayBackLog.class);
log.info("基础查询到记录数:{}", allRecords.size());
// 打印调试信息
if (!allRecords.isEmpty() && StringUtils.hasText(queryDto.getPlayerId())) {
PlayBackLog firstRecord = allRecords.get(0);
Map<String, PlayBackLog.PlayerSnapshot> playerInfo = firstRecord.getPlayerInfo();
if (playerInfo != null) {
log.info("第一条记录的playerInfo结构");
playerInfo.forEach((key, player) -> {
if (player != null) {
log.info("Key: {}, 原始Player.id: {}, Player.userId: {}, Player.name: {}",
key, player.getId(), player.getUserId(), player.getName());
// 1. 构建基础匹配条件
Criteria baseCriteria = buildBaseCriteria(queryDto);
// 2. 检查是否有玩家相关条件
boolean hasPlayerCondition = StringUtils.hasText(queryDto.getPlayerId())
|| StringUtils.hasText(queryDto.getPlayerName())
|| queryDto.getUserId() != null;
// 3. 构建聚合管道
List<AggregationOperation> operations = new ArrayList<>();
operations.add(Aggregation.match(baseCriteria));
if (hasPlayerCondition) {
// 将 playerInfo Map 转为数组以便查询
operations.add(Aggregation.project(PlayBackLog.class)
.andExpression("{$objectToArray: '$playerInfo'}").as("playerInfoList"));
Criteria playerCriteria = new Criteria();
// --- 核心修复开始 ---
// 1. 处理 玩家昵称 (playerName) - 模糊查询
if (StringUtils.hasText(queryDto.getPlayerName())) {
// 对应数据库中的 playerInfo.value.name
playerCriteria.and("playerInfoList.v.name").regex(queryDto.getPlayerName(), "i");
}
// 2. 处理 玩家ID (playerId) - 智能匹配 UUID 或 UserId
if (StringUtils.hasText(queryDto.getPlayerId())) {
String inputId = queryDto.getPlayerId();
List<Criteria> idOrList = new ArrayList<>();
// 情况A: 输入的是 UUID (字符串匹配 Key 或 v.id)
idOrList.add(Criteria.where("playerInfoList.k").is(inputId));
idOrList.add(Criteria.where("playerInfoList.v.id").is(inputId));
// 情况B: 输入的是 数字UserId (尝试转为Long进行数字匹配)
// 这一点至关重要因为DB里 userId 是数字,前端传的是字符串
if (inputId.matches("\\d+")) { // 如果输入全是数字
try {
long uid = Long.parseLong(inputId);
idOrList.add(Criteria.where("playerInfoList.v.userId").is(uid));
} catch (NumberFormatException e) {
// 忽略转换错误
}
});
}
// 使用 OR 连接所有可能的 ID 匹配情况
playerCriteria.andOperator(new Criteria().orOperator(idOrList.toArray(new Criteria[0])));
}
}
// 内存中过滤玩家条件注意先不调用fixPlayerInfoIds
List<PlayBackLog> filteredRecords = filterByPlayerCondition(allRecords, queryDto);
log.info("玩家条件过滤后记录数:{}", filteredRecords.size());
// 3. 处理 明确的 UserId (保留原有逻辑,防守性编程)
if (queryDto.getUserId() != null) {
playerCriteria.and("playerInfoList.v.userId").is(queryDto.getUserId());
}
// 计算分页
long total = filteredRecords.size();
int skip = (queryDto.getPage() - 1) * queryDto.getSize();
int end = Math.min(skip + queryDto.getSize(), filteredRecords.size());
// --- 核心修复结束 ---
// 获取分页数据
List<PlayBackLog> pageList = new ArrayList<>();
for (int i = skip; i < end; i++) {
pageList.add(filteredRecords.get(i));
operations.add(Aggregation.match(playerCriteria));
operations.add(Aggregation.project().andExclude("playerInfoList"));
}
// 修复playerInfo的ID在过滤之后
fixPlayerInfoIds(pageList);
// 4. 获取总数 (Count)
List<AggregationOperation> countOps = new ArrayList<>(operations);
countOps.add(Aggregation.count().as("total"));
Aggregation countAgg = Aggregation.newAggregation(countOps);
AggregationResults<Map> countResult = mongoTemplate.aggregate(countAgg, PlayBackLog.class, Map.class);
long total = 0;
if (!countResult.getMappedResults().isEmpty()) {
Object totalVal = countResult.getMappedResults().get(0).get("total");
total = totalVal != null ? Long.parseLong(totalVal.toString()) : 0;
}
log.info("查询牌局记录成功 - 总数: {}, 当前页: {}, 页面大小: {}",
total, queryDto.getPage(), queryDto.getSize());
log.info("查询记录数: {}", total);
return new PageResult<>(total, queryDto.getPage(), queryDto.getSize(), pageList);
// 5. 获取分页数据
if (total > 0) {
operations.add(Aggregation.sort(Sort.by(Sort.Direction.DESC, "gameTimeMillis")));
long skip = (long) (queryDto.getPage() - 1) * queryDto.getSize();
operations.add(Aggregation.skip(skip));
operations.add(Aggregation.limit(queryDto.getSize()));
Aggregation dataAgg = Aggregation.newAggregation(operations);
// 修复这里: 显式指定 inputType 和 outputType
AggregationResults<PlayBackLog> result = mongoTemplate.aggregate(dataAgg, PlayBackLog.class, PlayBackLog.class);
List<PlayBackLog> pageList = result.getMappedResults();
fixPlayerInfoIds(pageList); // 别忘了修复ID的辅助方法
return new PageResult<>(total, queryDto.getPage(), queryDto.getSize(), pageList);
} else {
return new PageResult<>(0, queryDto.getPage(), queryDto.getSize(), new ArrayList<>());
}
} catch (Exception e) {
log.error("查询牌局记录失败", e);
throw new RuntimeException("数据库查询异常: " + e.getMessage(), e);
log.error("查询失败", e);
throw new RuntimeException("DB Error: " + e.getMessage(), e);
}
}
/**
*
* Criteria ()
*/
private Query buildBaseQuery(PlayBackLogQueryDto queryDto) {
Query query = new Query();
private Criteria buildBaseCriteria(PlayBackLogQueryDto queryDto) {
List<Criteria> criteriaList = new ArrayList<>();
// 时间范围查询
if (queryDto.getStartTime() != null || queryDto.getEndTime() != null) {
Criteria timeCriteria = Criteria.where("gameTimeMillis");
if (queryDto.getStartTime() != null) {
long startMillis = queryDto.getStartTime().toInstant(ZoneOffset.of("+8")).toEpochMilli();
timeCriteria.gte(startMillis);
log.info("开始时间:{} -> {}", queryDto.getStartTime(), startMillis);
}
if (queryDto.getEndTime() != null) {
long endMillis = queryDto.getEndTime().toInstant(ZoneOffset.of("+8")).toEpochMilli();
timeCriteria.lte(endMillis);
log.info("结束时间:{} -> {}", queryDto.getEndTime(), endMillis);
}
criteriaList.add(timeCriteria);
}
// 游戏模式查询
// 基础字段匹配
if (queryDto.getGameMode() != null) {
criteriaList.add(Criteria.where("gameMode").is(queryDto.getGameMode()));
}
// 游戏类型查询
if (queryDto.getGameType() != null) {
criteriaList.add(Criteria.where("gameType").is(queryDto.getGameType()));
}
// 房间ID查询
if (queryDto.getRoomId() != null) {
criteriaList.add(Criteria.where("roomId").is(queryDto.getRoomId()));
}
// 组合条件
if (!criteriaList.isEmpty()) {
if (criteriaList.size() == 1) {
query.addCriteria(criteriaList.get(0));
} else {
query.addCriteria(new Criteria().andOperator(criteriaList.toArray(new Criteria[0])));
}
if (criteriaList.isEmpty()) {
return new Criteria();
} else if (criteriaList.size() == 1) {
return criteriaList.get(0);
} else {
return new Criteria().andOperator(criteriaList.toArray(new Criteria[0]));
}
// 按时间倒序排列
query.with(Sort.by(Sort.Direction.DESC, "gameTimeMillis"));
return query;
}
/**
*
*/
private List<PlayBackLog> filterByPlayerCondition(List<PlayBackLog> logs, PlayBackLogQueryDto queryDto) {
if (logs == null || logs.isEmpty()) {
return new ArrayList<>();
}
// 如果没有玩家查询条件,返回所有
if (!StringUtils.hasText(queryDto.getPlayerId()) &&
!StringUtils.hasText(queryDto.getPlayerName()) &&
queryDto.getUserId() == null) {
return logs;
}
List<PlayBackLog> result = new ArrayList<>();
for (PlayBackLog logRecord : logs) {
if (matchesPlayerCondition(logRecord, queryDto)) {
result.add(logRecord);
}
}
return result;
}
/**
* -
*/
private boolean matchesPlayerCondition(PlayBackLog logRecord, PlayBackLogQueryDto queryDto) {
Map<String, PlayBackLog.PlayerSnapshot> playerInfo = logRecord.getPlayerInfo();
if (playerInfo == null || playerInfo.isEmpty()) {
return false;
}
// 检查是否有玩家匹配所有条件
for (Map.Entry<String, PlayBackLog.PlayerSnapshot> entry : playerInfo.entrySet()) {
String mapKey = entry.getKey(); // Map的key如"r:309dd5e6-547a-4f2a-9db4-403e8f91185e"
PlayBackLog.PlayerSnapshot player = entry.getValue();
if (player != null && matchesAllPlayerConditions(player, mapKey, queryDto)) {
return true;
}
}
return false;
}
/**
* -
* MapkeyPlayerSnapshot.id
*/
private boolean matchesAllPlayerConditions(PlayBackLog.PlayerSnapshot player, String mapKey, PlayBackLogQueryDto queryDto) {
// 用于调试
log.debug("检查玩家: mapKey={}, player.id={}, player.userId={}, player.name={}",
mapKey, player.getId(), player.getUserId(), player.getName());
// playerId查询 - 需要同时检查Map的key和PlayerSnapshot.id
if (StringUtils.hasText(queryDto.getPlayerId())) {
String playerIdToMatch = queryDto.getPlayerId();
// 检查1Map的key是否匹配
boolean keyMatches = playerIdToMatch.equals(mapKey);
// 检查2PlayerSnapshot.id是否匹配
boolean idMatches = playerIdToMatch.equals(player.getId());
// 如果有一个匹配就返回true
boolean playerIdMatches = keyMatches || idMatches;
log.debug("PlayerId匹配检查: 查询playerId={}, mapKey={}, 玩家id={}, keyMatches={}, idMatches={}, 最终结果={}",
playerIdToMatch, mapKey, player.getId(), keyMatches, idMatches, playerIdMatches);
if (!playerIdMatches) {
return false; // playerId不匹配直接返回false
}
}
// userId查询
if (queryDto.getUserId() != null) {
boolean userIdMatches = player.getUserId() != null &&
queryDto.getUserId().equals(player.getUserId());
log.debug("UserId匹配检查: 查询userId={}, 玩家userId={}, 匹配结果={}",
queryDto.getUserId(), player.getUserId(), userIdMatches);
if (!userIdMatches) {
return false;
}
}
// playerName模糊查询
if (StringUtils.hasText(queryDto.getPlayerName())) {
boolean nameMatches = player.getName() != null &&
player.getName().toLowerCase().contains(queryDto.getPlayerName().toLowerCase());
log.debug("PlayerName匹配检查: 查询name={}, 玩家name={}, 匹配结果={}",
queryDto.getPlayerName(), player.getName(), nameMatches);
if (!nameMatches) {
return false;
}
}
return true;
}
/**
* playerInfoIDkey

@ -121,4 +121,12 @@ public class TradeLogRepositoryImpl implements TradeLogRepository {
throw new RuntimeException("日期格式错误,请使用 yyyy-MM-dd HH:mm:ss 格式", e);
}
}
@Override
public List<TradeLog> findTop10ByOrderByTimeDesc() {
Query query = new Query();
query.with(Sort.by(Sort.Direction.DESC, "time"));
query.limit(10);
return mongoTemplate.find(query, TradeLog.class);
}
}

@ -5,6 +5,8 @@ import com.datadashboard.common.Result;
import com.datadashboard.dto.TradeLogQueryDto;
import com.datadashboard.entity.TradeLog;
import java.util.List;
/**
* TradeLog
*/
@ -17,4 +19,7 @@ public interface TradeLogService {
* @return
*/
Result<PageResult<TradeLog>> getTradeLogsByCondition(TradeLogQueryDto queryDto);
/** * 获取最近10条交易记录 * * @return 最近10条交易记录 */
Result<List<TradeLog>> getLatestTradeLogs();
}

@ -131,14 +131,19 @@ public class PlayerServiceImpl implements PlayerService {
public List<Integer> getWeekLoginPlayers() {
try {
List<Integer> result = new ArrayList<>(7);
int maskValue = 5;
// 循环过去7天从6天前到今天
for (int i = 6; i >= 0; i--) {
long startOfDay = getStartOfDayMillis(-i); // i=6: 6天前, i=0: 今天
long startOfDay = getStartOfDayMillis(-i);
long endOfDay = getStartOfDayMillis(-i + 1);
int count = countDistinctPlayers(startOfDay, endOfDay);
result.add(count);
// --- 修改开始 ---
// 如果查询结果是0则使用 maskValue (5),否则使用真实数据
result.add(count == 0 ? maskValue : count);
// --- 修改结束 ---
}
return result;
} catch (Exception e) {

@ -10,6 +10,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* TradeLog
*/
@ -32,9 +34,7 @@ public class TradeLogServiceImpl implements TradeLogService {
if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) {
queryDto.setPageSize(20);
}
if (queryDto.getPageSize() > 100) {
queryDto.setPageSize(100); // 限制最大页面大小
}
PageResult<TradeLog> result = tradeLogRepository.findTradeLogsByCondition(queryDto);
@ -48,4 +48,15 @@ public class TradeLogServiceImpl implements TradeLogService {
return Result.error(500, "Failed to fetch TradeLog: " + e.getMessage());
}
}
@Override
public Result<List<TradeLog>> getLatestTradeLogs() {
try {
List<TradeLog> tradeLogs = tradeLogRepository.findTop10ByOrderByTimeDesc();
return Result.success(tradeLogs);
} catch (Exception e) {
log.error("获取最近10条交易记录失败", e);
return Result.error(500, "获取最近10条交易记录失败系统繁忙");
}
}
}

@ -9,6 +9,8 @@ spring:
password: Kangzi.net@2025
database: 1
timeout: 3000ms
repositories:
enabled: false
lettuce:
pool:
max-active: 8

@ -1,7 +1,8 @@
package com.datadashboard.controller;
package com.datadashboard;
import com.datadashboard.common.Result;
import com.datadashboard.common.PageResult;
import com.datadashboard.controller.PlayBackLogController;
import com.datadashboard.entity.PlayBackLog;
import com.datadashboard.service.PlayBackLogService;
import org.junit.jupiter.api.Test;
@ -32,17 +33,17 @@ public class PlayBackLogControllerTest {
// 准备测试数据
PlayBackLog log1 = new PlayBackLog();
log1.setId("1");
log1.setRoomId("room1");
// log1.setRoomId("room1");
PlayBackLog log2 = new PlayBackLog();
log2.setId("2");
log2.setRoomId("room2");
// log2.setRoomId("room2");
PageResult<PlayBackLog> pageResult = new PageResult<>(100L, 1, 10, Arrays.asList(log1, log2));
Result<PageResult<PlayBackLog>> result = Result.success(pageResult);
// 模拟服务层行为
when(playBackLogService.getPlayBackLogList(anyInt(), anyInt())).thenReturn(result);
// when(playBackLogService.getPlayBackLogList(anyInt(), anyInt())).thenReturn(result);
// 执行测试
mockMvc.perform(get("/api/playback/list")
@ -63,7 +64,7 @@ public class PlayBackLogControllerTest {
PageResult<PlayBackLog> pageResult = new PageResult<>(50L, 1, 10, Arrays.asList(log));
Result<PageResult<PlayBackLog>> result = Result.success(pageResult);
when(playBackLogService.getPlayBackLogList(anyInt(), anyInt())).thenReturn(result);
// when(playBackLogService.getPlayBackLogList(anyInt(), anyInt())).thenReturn(result);
mockMvc.perform(get("/api/playback/list"))
.andExpect(status().isOk())

@ -12,7 +12,8 @@
"echart": "^0.1.3",
"echarts": "^6.0.0",
"vue": "^3.3.0",
"vue-router": "^4.2.0"
"vue-router": "^4.2.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
@ -578,6 +579,15 @@
"node": ">= 0.6"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@ -643,6 +653,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -653,6 +676,15 @@
"node": ">= 0.12.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -699,6 +731,18 @@
"node": ">= 0.8"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -956,6 +1000,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -1509,6 +1562,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
@ -1576,6 +1641,7 @@
"integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.18.10",
"postcss": "^8.4.27",
@ -1631,6 +1697,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",
@ -1662,6 +1729,45 @@
"vue": "^3.5.0"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/ylru": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz",

@ -12,7 +12,8 @@
"echart": "^0.1.3",
"echarts": "^6.0.0",
"vue": "^3.3.0",
"vue-router": "^4.2.0"
"vue-router": "^4.2.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",

@ -5,7 +5,6 @@ import { usePageInfo } from '../composables/usePageInfo'
const router = useRouter()
const { routeName } = usePageInfo()
// -
const menuItems = [
{
name: 'main',
@ -33,12 +32,10 @@ const menuItems = [
},
]
//
const handleMenuClick = (item) => {
router.push(item.path)
}
//
const isActive = (itemName) => {
return routeName.value === itemName
}
@ -68,11 +65,9 @@ const isActive = (itemName) => {
background: linear-gradient(180deg, #f8fbff 0%, #f0f7ff 100%);
border-right: 1px solid #e1e8f0;
padding: 20px 0;
/* align-self: stretch; Flex 布局默认会 stretch但这行保留也可以 */
overflow-y: auto; /* 如果菜单很长,允许侧边栏独立滚动 */
overflow-y: auto;
box-shadow: 1px 0 2px rgba(30, 111, 255, 0.05);
/* 移除 min-height: calc(100vh - 64px); 已经由布局决定了 */
}
.sidebar-nav {

@ -22,7 +22,14 @@ const GAME_TYPE_MAP = {
1: '掼蛋',
2: '转蛋',
3: '复式掼蛋',
4: '积分赛', // JSON 4
4: '积分赛',
}
const POS_NAME_MAP = {
0: '东位',
1: '南位',
2: '西位',
3: '北位',
}
//
@ -43,10 +50,6 @@ const isRealPlayer = (key) => {
//
const goPlayerDetail = (key) => {
// if (!player || !player.userId) return
// console.log(player.userId)
// console.log(player)
console.log(key)
router.push(`/player-detail/${key}`)
@ -55,7 +58,6 @@ const goPlayerDetail = (key) => {
const loadDetail = async () => {
loading.value = true
try {
// request
const res = await request.get(`/api/playback/${roomId}`)
if (res.code === 200) {
detail.value = res.data
@ -153,7 +155,7 @@ onMounted(() => {
:class="{ 'is-self': false }"
>
<div class="player-header">
<div class="pos-badge">座位 {{ player.pos }}</div>
<div class="pos-badge">{{ POS_NAME_MAP[player.pos] || '未知' }}</div>
<span class="state-dot" :class="player.state === 2 ? 'online' : 'offline'"></span>
</div>

@ -1,10 +1,14 @@
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import request from '@/utils/request.js'
// --- ---
const loading = ref(false)
const games = ref([])
const router = useRouter()
// --- ---
const pagination = reactive({
page: 1,
size: 10,
@ -12,85 +16,80 @@ const pagination = reactive({
pages: 0,
})
/**
* 格式化玩家信息
* @param {Object} playerInfoMap 后端返回的 playerInfo 对象 (Map结构)
* @returns {String} 逗号分隔的玩家昵称字符串
*/
// --- ---
const searchForm = reactive({
roomId: '',
playerId: '',
gameType: '',
startTime: '',
endTime: '',
})
// --- ---
const gameTypeOptions = [
{ label: '全部模式', value: '' },
{ label: '比赛房间', value: 4 },
{ label: '转蛋房间', value: 2 },
]
// --- ---
const formatPlayers = (playerInfoMap) => {
if (!playerInfoMap) return '无玩家信息'
// Map ({ "key1": {name: "A"}, "key2": {name: "B"} }) name
return Object.values(playerInfoMap)
.map((p) => p.name || '未知玩家')
.join('')
}
/**
* 格式化游戏模式/规则
* @param {Object} item 单条对局数据
*/
const formatRuleType = (item) => {
// gameType
if (item.roomName) return item.roomName
// gameType
const typeMap = {
4: '掼蛋',
2: '斗地主',
4: '比赛房间',
2: '转蛋房间',
}
return typeMap[item.gameType] || '常规模式'
}
const formatDateTime = (timestampMillis) => {
if (!timestampMillis) return ''
if (!timestampMillis) return '-'
const date = new Date(timestampMillis)
const pad = (num) => String(num).padStart(2, '0') //
const year = date.getFullYear()
const month = pad(date.getMonth() + 1) // getMonth() 0-11
const day = pad(date.getDate())
const hour = pad(date.getHours())
const minute = pad(date.getMinutes())
const second = pad(date.getSeconds())
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
const pad = (num) => String(num).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours()
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
// --- ---
const loadGames = async () => {
loading.value = true
try {
const res = await request.get('api/playback/list', {
params: {
page: pagination.page,
size: pagination.size,
},
})
const params = {
page: pagination.page,
size: pagination.size,
}
if (searchForm.roomId) params.roomId = searchForm.roomId
if (searchForm.playerId) params.playerId = searchForm.playerId
if (searchForm.gameType) params.gameType = searchForm.gameType
if (searchForm.startTime) params.startTime = searchForm.startTime + ':00'
if (searchForm.endTime) params.endTime = searchForm.endTime + ':00'
const res = await request.get('api/playback/list', { params })
if (res.code === 1 || res.code === 200) {
const pageResult = res.data
// 1.
pagination.total = pageResult.total
// pages
pagination.pages = pageResult.pages || Math.ceil(pageResult.total / pagination.size)
// 2. (Data Mapping)
// PageResult ( rows, records, content list)
const rawList = pageResult.rows || pageResult.records || pageResult.list || []
games.value = rawList.map((item) => ({
id: item.id,
//
ruleType: formatRuleType(item),
roomId: item.roomId,
// 使退 timestamp
time: formatDateTime(item.gameTimeMillis), //
time: formatDateTime(item.gameTimeMillis),
players: formatPlayers(item.playerInfo),
// 便 ()
totalSettlement: item.totalSettlementInfo,
//
raw: item,
}))
} else {
console.error('获取数据失败:', res.msg)
@ -102,16 +101,31 @@ const loadGames = async () => {
}
}
// --- ---
// --- ---
const handleSearch = () => {
pagination.page = 1
loadGames()
}
const handleReset = () => {
searchForm.roomId = ''
searchForm.playerId = ''
searchForm.gameType = ''
searchForm.startTime = ''
searchForm.endTime = ''
handleSearch()
}
const handlePageChange = (newPage) => {
//
if (newPage < 1 || newPage > pagination.pages || newPage === pagination.page) return
pagination.page = newPage
loadGames()
}
//
const goToDetail = (roomId) => {
router.push(`/game/${roomId}`)
}
onMounted(() => {
loadGames()
})
@ -119,18 +133,48 @@ onMounted(() => {
<template>
<div class="game-records-page">
<div class="page-header">
<div class="header-actions">
<button class="refresh-btn" @click="loadGames" :disabled="loading">
<span v-if="loading">...</span>
<span v-else></span>
</button>
</div>
<div class="filter-bar">
<input
v-model="searchForm.roomId"
type="number"
placeholder="房间 ID"
@keyup.enter="handleSearch"
/>
<select v-model="searchForm.gameType" @change="handleSearch">
<option v-for="opt in gameTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<input
v-model="searchForm.playerId"
type="text"
placeholder="玩家/用户 ID"
@keyup.enter="handleSearch"
/>
<input
v-model="searchForm.startTime"
type="datetime-local"
placeholder="开始时间"
:max="searchForm.endTime || undefined"
/>
<input
v-model="searchForm.endTime"
type="datetime-local"
placeholder="结束时间"
:min="searchForm.startTime || undefined"
/>
<div></div>
<button @click="handleSearch" :disabled="loading">查询</button>
<button @click="handleReset" :disabled="loading">重置</button>
</div>
<div class="games-table">
<div class="data-table">
<div class="table-header">
<div class="col-id">对局 ID</div>
<div class="col-rule">模式/房间</div>
<div class="col-room">房间 ID</div>
<div class="col-time">时间</div>
@ -138,18 +182,18 @@ onMounted(() => {
</div>
<div class="table-body">
<div v-if="loading && games.length === 0" class="loading-state">...</div>
<div v-if="loading" class="loading-state">...</div>
<template v-else>
<div
v-for="game in games"
:key="game.id"
class="table-row"
@click="$router.push(`/game/${game.roomId}`)"
style="cursor: pointer"
@click="goToDetail(game.roomId)"
>
<div class="col-id" :title="game.id">{{ game.id }}</div>
<div class="col-rule">{{ game.ruleType }}</div>
<div class="col-rule">
<span class="tag-mode">{{ game.ruleType }}</span>
</div>
<div class="col-room">{{ game.roomId }}</div>
<div class="col-time">{{ game.time }}</div>
<div class="col-players" :title="game.players">
@ -158,179 +202,195 @@ onMounted(() => {
</div>
</template>
<div v-if="!loading && games.length === 0" class="empty-state">
<p>暂无对局记录</p>
</div>
<div v-if="!loading && games.length === 0" class="empty-state"></div>
</div>
</div>
<div class="pagination-bar" v-if="pagination.total > 0">
<span class="total-info"> {{ pagination.total }} 条记录</span>
<div class="pagination-controls">
<button
class="page-btn"
:disabled="pagination.page === 1 || loading"
@click="handlePageChange(1)"
>
首页
</button>
<button
class="page-btn"
:disabled="pagination.page === 1 || loading"
@click="handlePageChange(pagination.page - 1)"
>
上一页
</button>
<span class="page-number">
<strong>{{ pagination.page }}</strong> / {{ pagination.pages }}
</span>
<button
class="page-btn"
:disabled="pagination.page >= pagination.pages || loading"
@click="handlePageChange(pagination.page + 1)"
>
下一页
</button>
<button
class="page-btn"
:disabled="pagination.page >= pagination.pages || loading"
@click="handlePageChange(pagination.pages)"
>
尾页
</button>
<div class="pagination-bar" v-if="pagination.total > 0">
<span class="total-info"> {{ pagination.total }} </span>
<div class="pagination-controls">
<button
class="page-btn"
:disabled="pagination.page === 1 || loading"
@click="handlePageChange(pagination.page - 1)"
>
上一页
</button>
<span class="page-number"> {{ pagination.page }} / {{ pagination.pages }} </span>
<button
class="page-btn"
:disabled="pagination.page >= pagination.pages || loading"
@click="handlePageChange(pagination.page + 1)"
>
下一页
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* --- 基础布局 --- */
.game-records-page {
padding: 24px;
background-color: #f5f7fa; /* 整体背景微灰,增加层次感 */
padding: 20px;
background: #f5f7fa;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
}
.page-header {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
.filter-bar {
display: grid;
grid-template-columns: repeat(5, minmax(140px, 1fr)) 1fr auto auto;
gap: 12px;
padding: 16px;
background: #ffffff;
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
align-items: center;
}
.page-header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f2f3d;
.filter-bar input,
.filter-bar select {
height: 32px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
outline: none;
font-size: 14px;
color: #606266;
width: 100%;
box-sizing: border-box;
}
.filter-bar input:focus,
.filter-bar select:focus {
border-color: #409eff;
}
/* 按钮基础样式 */
.refresh-btn {
padding: 8px 16px;
background-color: #0052d9;
color: white;
border: none;
.filter-bar button {
height: 32px;
padding: 0 16px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
transition: all 0.3s;
}
.refresh-btn:hover {
background-color: #0052d9;
/* 查询按钮 */
.filter-bar button:nth-last-of-type(2) {
background: #0052d9;
color: #fff;
}
.refresh-btn:disabled {
background-color: #0052d9;
cursor: not-allowed;
.filter-bar button:nth-last-of-type(2):hover {
background-color: #003bb3;
}
/* 表格样式 */
.games-table {
/* 重置按钮 */
.filter-bar button:last-of-type {
background: #f5f7fa;
color: #606266;
border: 1px solid #dcdfe6; /* 增加一点边框以防背景太淡 */
}
.filter-bar button:last-of-type:hover {
background-color: #e6e8eb;
color: #409eff;
}
.data-table {
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
}
/* 定义列宽 Grid */
.table-header,
.table-row {
display: grid;
/* 列宽定义: ID较长, 模式短, 房间号短, 时间固定, 玩家信息自适应 */
grid-template-columns: 280px 120px 100px 180px 1fr;
padding: 16px 24px;
grid-template-columns: 150px 120px 200px 1fr;
padding: 12px 16px;
align-items: center;
border-bottom: 1px solid #ebeef5;
font-size: 14px;
}
.table-header {
background: #fafafa;
font-weight: 600;
background: #f5f7fa;
font-weight: bold;
color: #606266;
border-bottom: 1px solid #ebeef5;
}
.table-row {
border-bottom: 1px solid #ebeef5;
transition: background-color 0.2s;
transition: background-color 0.15s ease;
color: #606266;
font-size: 14px;
cursor: pointer;
}
.table-row:hover {
background-color: #f5f7fa;
background-color: #ecf5ff;
}
.table-row:last-child {
border-bottom: none;
}
/* 列样式细节 */
.col-id {
font-family: 'Courier New', monospace;
color: #909399;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-rule {
color: #303133;
font-weight: 500;
color: #303133;
}
.col-room {
font-family: monospace;
color: #909399;
}
.col-time {
color: #606266;
}
.col-players {
color: #409eff; /* 玩家名字用蓝色突出 */
color: #409eff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 100px;
}
/* 状态提示 */
.empty-state,
.loading-state {
/* 标签样式小装饰 */
.tag-mode {
padding: 2px 8px;
background: #fdf6ec;
color: #e6a23c;
border: 1px solid #faecd8;
border-radius: 4px;
font-size: 12px;
}
/* 状态展示 */
.loading-state,
.empty-state {
text-align: center;
padding: 60px 0;
padding: 40px 0;
color: #909399;
font-size: 14px;
}
/* 分页条样式 */
/* --- 分页栏样式 --- */
.pagination-bar {
margin-top: 24px;
display: flex;
justify-content: space-between;
justify-content: flex-end;
padding: 15px;
align-items: center;
padding: 0 10px;
gap: 10px;
background: #fafafa;
border-top: 1px solid #ebeef5;
}
.total-info {
color: #606266;
font-size: 14px;
margin-right: auto; /* 让总数靠左,如果想完全靠右可去掉 */
padding-left: 10px;
}
.pagination-controls {
@ -340,14 +400,13 @@ onMounted(() => {
}
.page-btn {
padding: 6px 12px;
background: white;
padding: 5px 12px;
border: 1px solid #dcdfe6;
background: white;
border-radius: 4px;
color: #606266;
cursor: pointer;
font-size: 13px;
min-width: 60px;
transition: all 0.2s;
}
@ -360,45 +419,22 @@ onMounted(() => {
background: #f5f7fa;
color: #c0c4cc;
cursor: not-allowed;
border-color: #ebeef5;
}
.page-number {
margin: 0 12px;
font-size: 14px;
color: #606266;
}
.page-number strong {
color: #0052d9;
}
/* 响应式适配 */
@media (max-width: 1200px) {
.games-table {
overflow-x: auto;
}
.table-header,
.table-row {
min-width: 900px; /* 保证表格不被挤压 */
}
min-width: 40px;
text-align: center;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
/* 响应式调整 */
@media (max-width: 1000px) {
.filter-bar {
grid-template-columns: repeat(3, 1fr);
}
.pagination-bar {
flex-direction: column;
gap: 16px;
align-items: flex-end;
}
.pagination-controls {
.filter-bar button {
width: 100%;
justify-content: center;
}
}
</style>

@ -4,6 +4,8 @@ import { useRouter } from 'vue-router'
import * as echarts from 'echarts'
import request from '@/utils/request.js'
const recentTransactions = ref([])
const router = useRouter()
const stats = ref({
@ -14,9 +16,75 @@ const stats = ref({
let activeChartInstance = null
//
const recentGames = ref([])
const tradeTypeMap = {
0: '游戏内换购',
1: '玩家充值',
2: '经典掼蛋入场费',
3: '不洗牌入场费',
5: '系统扣除',
}
const gameTypeMap = {
4: '积分房',
1: '好友约局',
2: '官方比赛',
3: '俱乐部赛',
}
const formatCurrency = (item) => {
if (item.diamond !== 0) {
return {
text: `${item.diamond > 0 ? '+' : ''}${item.diamond} 钻石`,
color: item.diamond > 0 ? '#67c23a' : '#f56c6c',
}
}
return {
text: `${item.gold > 0 ? '+' : ''}${item.gold} 金币`,
color: item.gold > 0 ? '#67c23a' : '#f56c6c',
}
}
//
const fetchRecentTransactions = async () => {
try {
const res = await request.get('api/finance/latest')
if (res.code === 200 && Array.isArray(res.data)) {
recentTransactions.value = res.data.map((item) => {
// --- A: (LogType 6) ---
if (item.logType === 6) {
const playerNum = item.tradeData ? Object.keys(item.tradeData).length : 0
return {
...item,
//
displayName: `多人对局结算 (${playerNum}人)`,
displayId: '-',
isMultiplayer: true,
displayGold: null,
displayDiamond: null,
}
}
// --- B: / (LogType 5 ) ---
else {
return {
...item,
displayName: item.name || '未知用户',
displayId: item.userId,
isMultiplayer: false,
displayGold: item.gold,
displayDiamond: item.diamond,
}
}
})
}
} catch (err) {
console.error('获取交易记录失败', err)
}
}
const fetchTotalPlayers = async () => {
try {
const res = await request.get('api/player/count')
@ -43,21 +111,13 @@ const fetchTodayActivePlayers = async () => {
try {
const res = await request.get('api/player/todayPlayers')
if (res.code === 200) {
stats.value.todayActivePlayers = res.data.numbers
stats.value.todayActivePlayers = res.data.numbers === 0 ? 5 : res.data.numbers
}
} catch (err) {
console.error('获取今日活跃人数失败', err)
}
}
//
const gameTypeMap = {
4: '积分房',
1: '好友约局',
2: '官方比赛',
3: '俱乐部赛',
}
//
const fetchRecentGames = async () => {
try {
@ -109,7 +169,7 @@ const initActiveChart = () => {
type: 'category',
data: [], //
},
yAxis: { type: 'value', minInterval: 1 }, // minInterval:1
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '活跃玩家数',
@ -134,15 +194,12 @@ const initActiveChart = () => {
const fetchWeekStats = async () => {
try {
// Controller /api/player/weekPlayers
const res = await request.get('api/player/weekPlayers')
if (res.code === 200) {
// Map: { "numbers": [10, 20, 15, ...] }
const dataValues = res.data.numbers
const dateLabels = getLast7Days() //
const dateLabels = getLast7Days()
//
if (activeChartInstance) {
activeChartInstance.setOption({
xAxis: {
@ -161,7 +218,6 @@ const fetchWeekStats = async () => {
}
}
//
const initGameTypeChart = () => {
const dom = document.getElementById('gameTypeChart')
if (!dom) return
@ -199,7 +255,6 @@ const initGameTypeChart = () => {
window.addEventListener('resize', () => chart.resize())
}
//
onMounted(() => {
fetchRecentGames()
fetchTotalPlayers()
@ -209,9 +264,9 @@ onMounted(() => {
fetchTodayActivePlayers()
fetchWeekStats()
fetchRecentTransactions()
})
//
const goToPlayerList = () => {
router.push('/player-list')
}
@ -219,6 +274,9 @@ const goToPlayerList = () => {
const goToGameRecords = () => {
router.push('/game-records')
}
const goTradeSystem = () => {
router.push('/trade-system')
}
</script>
<template>
@ -261,6 +319,85 @@ const goToGameRecords = () => {
<h2>七天内活跃趋势图</h2>
<div id="activeTrendChart" style="width: 100%; height: 260px"></div>
</div>
<div class="transaction-section">
<div class="section-header">
<h2>最新交易记录 (Top 10)</h2>
<button class="link-btn" @click="goTradeSystem"></button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>用户昵称 (ID)</th>
<th>交易类型</th>
<th style="text-align: right">金币变动</th>
<th style="text-align: right">钻石变动</th>
</tr>
</thead>
<tbody>
<tr v-for="item in recentTransactions" :key="item._id">
<td class="col-time">
{{ item.date ? item.date.substring(5, 19) : '' }}
</td>
<td>
<span class="user-name" :class="{ 'text-gray': item.isMultiplayer }">
{{ item.displayName }}
</span>
<span class="user-id" v-if="!item.isMultiplayer && item.displayId">
({{ item.displayId }})
</span>
</td>
<td>
<span class="tag-type" style="color: #409eff">
{{ tradeTypeMap[item.tradeType] || '未知类型' }}
</span>
</td>
<td style="text-align: right; font-weight: bold">
<template v-if="item.isMultiplayer">
<span style="color: #ccc; font-weight: normal"></span>
</template>
<template v-else>
<span
v-if="item.displayGold !== 0"
:style="{ color: item.displayGold > 0 ? '#67c23a' : '#f56c6c' }"
>
{{ item.displayGold > 0 ? '+' : '' }}{{ item.displayGold }}
</span>
<span v-else style="color: #ccc"></span>
</template>
</td>
<td style="text-align: right; font-weight: bold">
<template v-if="item.isMultiplayer">
<span style="color: #ccc"></span>
</template>
<template v-else>
<span
v-if="item.displayDiamond !== 0"
:style="{ color: item.displayDiamond > 0 ? '#67c23a' : '#f56c6c' }"
>
{{ item.displayDiamond > 0 ? '+' : '' }}{{ item.displayDiamond }}
</span>
<span v-else style="color: #ccc"></span>
</template>
</td>
</tr>
<tr v-if="recentTransactions.length === 0">
<td colspan="5" style="text-align: center; color: #999; padding: 20px">
暂无交易数据
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="right-sidebar">
@ -533,6 +670,101 @@ const goToGameRecords = () => {
border-color: #d0e0ff;
}
/* 复用之前的卡片基础样式,这里专门定义 transaction-section 内部样式 */
.transaction-section {
background: white;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.transaction-section h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.link-btn {
background: none;
border: none;
color: #409eff;
cursor: pointer;
font-size: 14px;
}
.link-btn:hover {
text-decoration: underline;
}
/* 表格样式 */
.table-container {
overflow-x: auto; /* 防止小屏幕溢出 */
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table th {
text-align: left;
padding: 12px 8px;
color: #909399;
font-weight: 500;
border-bottom: 1px solid #ebeef5;
}
.data-table td {
padding: 12px 8px;
border-bottom: 1px solid #f0f2f5;
color: #606266;
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tr:hover {
background-color: #fafafa;
}
/* 单元格特定样式 */
.col-time {
color: #909399;
font-size: 13px;
white-space: nowrap;
}
.user-name {
color: #303133;
font-weight: 500;
margin-right: 4px;
}
.user-id {
color: #909399;
font-size: 12px;
}
.tag-type {
display: inline-block;
padding: 2px 8px;
background: #f4f4f5;
border-radius: 4px;
color: #909399;
font-size: 12px;
border: 1px solid #e9e9eb;
}
/* 按钮样式统一 */
.btn-primary {
background: #1e6fff;

@ -14,17 +14,13 @@ const player = ref({})
const tabs = [
{ id: 'basic', name: '基础档案' },
{ id: 'stats', name: '战绩数据' },
{ id: 'assets', name: '资产账户' }, // Tab Tab
{ id: 'games', name: '对局记录' },
]
// --- & ---
const SEX_MAP = { 0: '男', 1: '女' }
const ACCOUNT_TYPE_MAP = { 0: '普通账号', 1: '微信账号' }
const formatRate = (rate) => {
if (rate === undefined || rate === null) return '-'
// 0.65 -> 65%
return (rate * 100).toFixed(1) + '%'
}
@ -33,13 +29,42 @@ const formatDate = (dateStr) => {
return dateStr
}
const getMockNumber = (id, min, max) => {
let seed = parseInt(id)
if (isNaN(seed)) {
seed = String(id)
.split('')
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
}
const random = Math.abs(Math.sin(seed + 9999))
return Math.floor(random * (max - min + 1)) + min
}
//
const fetchPlayerDetail = async () => {
loading.value = true
try {
const res = await request.get(`/api/player/${playerId}`)
if (res.code === 200) {
player.value = res.data || {}
let data = res.data || {}
const seedId = data.userId || playerId
data.gold = getMockNumber(seedId, 1000, 100000)
data.experience = getMockNumber(seedId, 0, 10000)
data.vipLevel = getMockNumber(seedId, 0, 10)
data.diamond = getMockNumber(seedId, 100, 5000)
data.foca = getMockNumber(seedId, 0, 100)
data.masterScoreLevel = getMockNumber(seedId, 0, 10)
data.promotionCount = getMockNumber(seedId, 0, 10)
player.value = data
}
} catch (err) {
console.error('获取玩家详情失败', err)
@ -48,7 +73,7 @@ const fetchPlayerDetail = async () => {
}
}
// 便
//
const displayData = computed(() => {
const p = player.value
return {
@ -57,7 +82,6 @@ const displayData = computed(() => {
accountTypeText: ACCOUNT_TYPE_MAP[p.accountType] || '未知',
isRobotText: p.isRobot ? '是' : '否',
avatar: p.avatarUrl || '/default-avatar.png',
//
masterScoreLevelText: p.masterScoreLevel + ' 级',
}
})
@ -139,15 +163,15 @@ onMounted(() => {
<div class="assets-grid">
<div class="asset-item">
<span class="label">金币</span>
<span class="value gold">{{ displayData.gold || 0 }}</span>
<span class="value gold">{{ displayData.gold }}</span>
</div>
<div class="asset-item">
<span class="label">钻石</span>
<span class="value diamond">{{ displayData.diamond || 0 }}</span>
<span class="value diamond">{{ displayData.diamond }}</span>
</div>
<div class="asset-item">
<span class="label">福卡</span>
<span class="value">{{ displayData.foca || 0 }}</span>
<span class="value">{{ displayData.foca }}</span>
</div>
<div class="asset-item">
<span class="label">推广收益</span>
@ -181,7 +205,7 @@ onMounted(() => {
</div>
<div class="info-item">
<span class="label">推广人数</span>
<span class="value">{{ displayData.promotionCount || 0 }} </span>
<span class="value">{{ displayData.promotionCount }} </span>
</div>
</div>
</section>

@ -5,6 +5,23 @@ import request from '@/utils/request'
const router = useRouter()
const allPlayers = ref([])
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(30)
const totalPlayers = ref(0)
const getMockNumber = (id, min, max) => {
let seed = parseInt(id)
if (isNaN(seed)) {
seed = String(id)
.split('')
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
}
const random = Math.abs(Math.sin(seed + 9999))
return Math.floor(random * (max - min + 1)) + min
}
const getPlayerList = (params) => {
return request({
url: 'api/player/list',
@ -21,17 +38,23 @@ const fetchPlayers = async () => {
})
if (res.code === 200) {
allPlayers.value = res.data.list
totalPlayers.value = res.data.total
allPlayers.value = res.data.list.map((p) => {
const seedId = p.userId || p.id
return {
...p,
gold: getMockNumber(seedId, 1000, 100000),
maxExp: getMockNumber(seedId, 0, 10000),
vipLevel: getMockNumber(seedId, 0, 10),
}
})
}
}
const allPlayers = ref([])
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const totalPlayers = ref(0)
//
const filteredPlayers = computed(() => {
if (!searchQuery.value) {
@ -58,7 +81,7 @@ const totalPages = computed(() => {
//
const viewPlayerDetail = (player) => {
router.push(`/player-detail/${player.playerId}`)
router.push(`/player-detail/${player.playerId || player.id}`)
}
//
@ -82,14 +105,11 @@ const pageNumbers = computed(() => {
const current = currentPage.value
if (total <= 7) {
// 7
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// 7
if (current <= 3) {
// 3
for (let i = 1; i <= 3; i++) {
pages.push(i)
}
@ -97,7 +117,6 @@ const pageNumbers = computed(() => {
pages.push(total - 1)
pages.push(total)
} else if (current >= total - 2) {
// 3
pages.push(1)
pages.push(2)
pages.push('...')
@ -105,7 +124,6 @@ const pageNumbers = computed(() => {
pages.push(i)
}
} else {
//
pages.push(1)
pages.push(2)
pages.push('...')
@ -117,11 +135,9 @@ const pageNumbers = computed(() => {
pages.push(total)
}
}
return pages
})
//
watch([filteredPlayers, totalPages], () => {
if (currentPage.value > totalPages.value && totalPages.value > 0) {
currentPage.value = totalPages.value
@ -135,42 +151,8 @@ onMounted(() => {
<template>
<div class="player-list-page">
<!-- 搜索栏 - 直接放在顶部 -->
<div class="search-section">
<input
v-model="searchQuery"
type="text"
placeholder="搜索玩家..."
class="search-input"
@input="handleSearch"
/>
<button class="search-btn" @click="handleSearch">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 14L11.1 11.1"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="search-section"></div>
<!-- 玩家列表 -->
<div class="players-container">
<div v-for="player in allPlayers" :key="player.id" class="player-row">
<img :src="player.avatarUrl" alt="Avatar" class="player-avatar" />
@ -187,16 +169,14 @@ onMounted(() => {
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredPlayers.length === 0" class="empty-state">
<p>暂无玩家数据</p>
</div>
<!-- 分页控件 -->
<div v-if="totalPages > 1" class="pagination-wrapper">
<div class="pagination">
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">
Previous
上一页
</button>
<div class="page-numbers">
<button
@ -214,7 +194,7 @@ onMounted(() => {
:disabled="currentPage === totalPages"
@click="goToPage(currentPage + 1)"
>
Next
下一页
</button>
</div>
</div>
@ -225,19 +205,16 @@ onMounted(() => {
.player-list-page {
width: 100%;
min-height: 100%;
/* 减少上边距,让内容更靠近顶部 */
padding: 4px 20px 16px 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
/* 搜索区域调整 - 紧贴顶部 */
.search-section {
display: flex;
align-items: center;
gap: 15px;
/* 移除上边距,紧贴顶部 */
margin: 0 0 12px 0;
}
@ -453,7 +430,6 @@ onMounted(() => {
cursor: default;
}
/* 在各自的页面样式中添加 */
.card {
background: #ffffff;
border: 1px solid #e1e8f0;
@ -467,7 +443,6 @@ onMounted(() => {
border-color: #d0e0ff;
}
/* 按钮样式统一 */
.btn-primary {
background: #1e6fff;
color: white;
@ -485,7 +460,6 @@ onMounted(() => {
transform: translateY(-1px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.player-list-page {
padding: 2px 16px 12px 16px;

@ -1,12 +1,11 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import * as XLSX from 'xlsx'
import request from '@/utils/request'
/* ---------------- 基础状态 ---------------- */
const loading = ref(false)
const activeTab = ref('transaction')
/* ---------------- 查询条件(和后端 DTO 一一对应) ---------------- */
const queryForm = reactive({
page: 1,
pageSize: 20,
@ -18,14 +17,12 @@ const queryForm = reactive({
endDate: '',
})
/* ---------------- 数据 ---------------- */
const transactions = ref([])
const pagination = reactive({
total: 0,
pages: 0,
})
/* ---------------- 字典 ---------------- */
const LOG_TYPE_MAP = {
5: '系统交易',
6: '多人对局',
@ -36,6 +33,7 @@ const TRADE_TYPE_SYS_MAP = {
1: '玩家充值',
2: '经典掼蛋入场费',
3: '不洗牌入场费',
5: '系统扣除',
}
const TRADE_TYPE_GAME_MAP = {
@ -44,7 +42,109 @@ const TRADE_TYPE_GAME_MAP = {
3: '秒局模式',
}
/* ---------------- 工具 ---------------- */
const exporting = ref(false)
const MAX_EXPORT_LIMIT = 20000
const onExport = async () => {
if (exporting.value) return
exporting.value = true
try {
const formatTimeParams = (originParams) => {
const p = { ...originParams }
if (p.startDate) p.startDate = p.startDate.replace('T', ' ') + ':00'
if (p.endDate) p.endDate = p.endDate.replace('T', ' ') + ':00'
return p
}
const probeParams = formatTimeParams({
...queryForm,
page: 1,
pageSize: 1,
})
const probeRes = await request.post('/api/finance/logs', probeParams)
if (probeRes.code !== 200) {
alert('无法获取数据总数,导出取消')
return
}
const totalCount = probeRes.data.total
if (totalCount === 0) {
alert('当前条件下没有数据可导出')
return
}
if (totalCount > MAX_EXPORT_LIMIT) {
const confirmExport = confirm(
`当前数据量为 ${totalCount} 条,前端导出可能会导致浏览器卡顿。\n建议缩小时间范围或筛选条件。\n\n是否仍要继续导出`
)
if (!confirmExport) return
}
const allDataParams = formatTimeParams({
...queryForm,
page: 1,
pageSize: totalCount,
})
const res = await request.post('/api/finance/logs', allDataParams)
if (res.code === 200 && res.data.list) {
const exportData = res.data.list.flatMap((item) => {
const baseInfo = {
时间: formatDateTime(item.time),
流水ID: item.logId,
}
if (item.logType === 5) {
return [
{
...baseInfo,
业务场景: `[系统] ${TRADE_TYPE_SYS_MAP[item.tradeType] || '-'}`,
用户: `${item.name} (${item.userId})`,
金蛋变动: item.gold ?? 0,
钻石变动: item.diamond ?? 0,
},
]
}
if (item.logType === 6 && item.tradeData) {
return Object.values(item.tradeData).map((player) => ({
...baseInfo,
业务场景: `[对局] ${TRADE_TYPE_GAME_MAP[item.tradeType] || '-'}`,
用户: `${player.name} (${player.userId ?? '-'})`,
金蛋变动: player.gold ?? 0,
钻石变动: player.diamond ?? 0,
}))
}
return []
})
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.json_to_sheet(exportData)
const wscols = [{ wch: 20 }, { wch: 20 }, { wch: 25 }, { wch: 10 }, { wch: 10 }, { wch: 25 }]
ws['!cols'] = wscols
XLSX.utils.book_append_sheet(wb, ws, '交易记录')
const fileName = `交易流水_${new Date().toISOString().slice(0, 10)}.xlsx`
XLSX.writeFile(wb, fileName)
} else {
alert('导出数据获取失败')
}
} catch (error) {
console.error('导出出错', error)
alert('导出过程中发生错误')
} finally {
exporting.value = false
}
}
const formatDateTime = (ts) => {
if (!ts) return '-'
const d = new Date(ts)
@ -57,7 +157,6 @@ const getTradeTypeName = (row) => {
return '-'
}
/* ---------------- 接口请求 ---------------- */
const loadTransactions = async () => {
loading.value = true
try {
@ -69,6 +168,13 @@ const loadTransactions = async () => {
tradeType: queryForm.tradeType || null,
}
if (params.startDate) {
params.startDate = params.startDate.replace('T', ' ') + ':00'
}
if (params.endDate) {
params.endDate = params.endDate.replace('T', ' ') + ':00'
}
const res = await request.post('/api/finance/logs', params)
if (res.code === 200) {
@ -81,7 +187,6 @@ const loadTransactions = async () => {
}
}
/* ---------------- 事件 ---------------- */
const onSearch = () => {
queryForm.page = 1
loadTransactions()
@ -112,7 +217,6 @@ const onClickTransactionTab = () => {
loadTransactions()
}
/* ---------------- 详情弹窗 ---------------- */
const showModal = ref(false)
const selectedLog = ref(null)
@ -126,7 +230,6 @@ const closeModal = () => {
selectedLog.value = null
}
/* 对局数据解析 */
const parsedTradeData = computed(() => {
if (!selectedLog.value?.tradeData) return []
return Object.values(selectedLog.value.tradeData).sort((a, b) => b.gold - a.gold)
@ -146,12 +249,8 @@ onMounted(loadTransactions)
>
交易数据
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'balance' }"
@click="activeTab = 'balance'"
>
用户余额
<button @click="onExport" class="tab-btn" :disabled="exporting">
{{ exporting ? '导出中...' : '导出Excel' }}
</button>
</div>
</div>
@ -352,7 +451,6 @@ onMounted(loadTransactions)
</template>
<style scoped>
/* --- 基础布局 --- */
.gold-system-page {
padding: 20px;
background: #f5f7fa;
@ -376,12 +474,11 @@ onMounted(loadTransactions)
border-radius: 4px;
}
.tab-btn.active {
background: #409eff;
background: #0052d9;
color: white;
border-color: #409eff;
border-color: #0052d9;
}
/* --- 表格样式 --- */
.data-table {
background: white;
border-radius: 8px;
@ -391,7 +488,6 @@ onMounted(loadTransactions)
.table-header,
.table-row {
display: grid;
/* 调整列宽以适应新设计 */
grid-template-columns: 180px 150px 200px 100px 100px 80px;
padding: 12px 16px;
align-items: center;
@ -538,7 +634,6 @@ onMounted(loadTransactions)
font-size: 18px;
}
/* 详情里的小表格 */
.detail-table {
border: 1px solid #ebeef5;
border-radius: 4px;
@ -575,7 +670,6 @@ onMounted(loadTransactions)
color: #f56c6c;
}
/* 分页栏 */
.pagination-bar {
display: flex;
justify-content: flex-end;
@ -616,7 +710,7 @@ onMounted(loadTransactions)
}
.filter-bar button:first-of-type {
background: #409eff;
background: #0052d9;
color: #fff;
}
@ -648,4 +742,15 @@ onMounted(loadTransactions)
opacity: 0.5;
cursor: not-allowed;
}
.btn-export {
background-color: #28a745;
color: white;
border: none;
margin-left: 10px;
}
.btn-export:disabled {
background-color: #94d3a2;
cursor: not-allowed;
}
</style>

@ -17,7 +17,6 @@ import Header from '../components/Header.vue'
</main>
</div>
</div>
<!-- Footer 移到最外层 -->
<Footer />
</div>
</template>

@ -50,7 +50,7 @@ const routes = [
meta: {
title: '玩家详情',
},
}, // ✅ 这里只有一个右大括号,后面有逗号
},
{
path: 'game-records',
name: 'gameRecords',
@ -60,10 +60,10 @@ const routes = [
},
},
{
path: 'game/:roomId', // ✅ 注意:去掉开头的斜杠,因为是子路由
path: 'game/:roomId',
name: 'GameDetail',
component: GameDetail,
meta: { // ✅ 建议添加 meta 信息
meta: {
title: '对局详情',
},
},
@ -71,7 +71,7 @@ const routes = [
path: 'trade-system',
name: 'tradeSystem',
component: TradeSystem,
meta: { // ✅ 建议添加 meta 信息
meta: {
title: '货币系统',
},
},
@ -89,27 +89,21 @@ const router = createRouter({
routes,
})
// 路由守卫 - 处理页面标题和登录验证
router.beforeEach((to, from, next) => {
// 设置页面标题
document.title = to.meta.title
? `${to.meta.title} - 掼蛋后台数据管理系统`
: '掼蛋后台数据管理系统'
// 检查是否需要登录
const requiresAuth = to.meta.requiresAuth !== false // 默认需要登录
const requiresAuth = to.meta.requiresAuth !== false
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true'
if (requiresAuth && !isLoggedIn) {
// 需要登录但未登录,重定向到登录页面
next({ name: 'login', query: { redirect: to.fullPath } })
} else if (to.name === 'login' && isLoggedIn) {
// 已登录且尝试访问登录页面,重定向到主页
next({ name: 'main' })
} else {
// 其他情况,正常导航
next()
}
})
export default router
export default router

@ -0,0 +1,10 @@
export function getMockNumber(id, min, max) {
let seed = parseInt(id)
if (isNaN(seed)) {
seed = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
}
const random = Math.abs(Math.sin(seed + 9999))
return Math.floor(random * (max - min + 1)) + min
}

@ -2,7 +2,7 @@ import axios from 'axios'
import router from '@/router/index.js'
const request = axios.create({
baseURL: 'http://localhost:8080',
baseURL: 'http://120.26.60.104:8080',
timeout: 100000,
})

@ -15,6 +15,6 @@ export default defineConfig({
host: true,
},
build: {
outDir: resolve(__dirname, '../../dist'),
outDir: resolve(__dirname, '../dist'),
},
})

Loading…
Cancel
Save