Compare commits

..

4 Commits

Author SHA1 Message Date
bdf6367404 feat(web): 增强任务治理与系统诊断能力
新增任务持久化、筛选分页、取消任务、健康检查与 AI 输入校验,并完善前端历史管理交互与容错重试机制。补充对应单元测试,提升系统稳定性和可运维性。
2026-03-08 23:35:36 +08:00
e26fb9cebb feat(web): 新增可视化工作台并支持预置SVN项目
新增 Spring Boot Web 后端与前端页面,打通 SVN 抓取、AI 分析、任务管理、文件下载与系统设置全流程。增加 3 个默认 SVN 预置项目下拉与默认项配置,提升日常使用效率与可维护性。
2026-03-08 23:14:55 +08:00
abd375bf64 chore: 调整参考数据目录结构
将参考 Excel 文件移动到 docs 目录,统一文档与示例数据的存放位置,保持仓库根目录简洁。
2026-03-08 22:47:06 +08:00
2dc0f931a2 chore: 清理测试数据并补充代理规范
移除仓库中的测试脚本和示例日志,减少无关噪音并保持目录整洁。新增 AGENTS.md 统一构建、测试与代码规范,便于后续自动化代理稳定协作。
2026-03-08 22:44:27 +08:00
42 changed files with 3620 additions and 1353 deletions

155
AGENTS.md Normal file
View File

@@ -0,0 +1,155 @@
# AGENTS 指南svn-log-tool
本文件提供给自动化编码代理Agent使用。
目标:在不破坏现有行为的前提下,安全、可复现地修改本仓库。
## 1. 项目概览
- 语言与构建Java 8 + Maven`pom.xml`)。
- 打包产物:可执行 fat jar`jar-with-dependencies`)。
- 主入口:`com.svnlog.Main`CLI
- Web 入口:`com.svnlog.WebApplication`(前后端一体,静态页面 + REST API
- 其他入口:`com.svnlog.DeepSeekLogProcessor``com.svnlog.ExcelAnalyzer`
- 核心目录:
- `src/main/java/com/svnlog/`
- `docs/`
## 2. 常用命令Build / Lint / Test / Run
以下命令默认在仓库根目录执行。
### 2.1 Build
- 仅编译(推荐快速检查):`mvn clean compile`
- 打包(跳过测试):`mvn clean package -DskipTests`
- 打包(执行测试):`mvn clean package`
- 产物(通常):`target/svn-log-tool-1.0.0-jar-with-dependencies.jar`
### 2.2 Lint / 静态检查
- 当前仓库未配置 Checkstyle / SpotBugs / PMD。
-`mvn clean compile` 作为基础语法与依赖检查。
- 若需更严格检查,可使用 `mvn -DskipTests verify`
### 2.3 Test
- 运行全部测试:`mvn test`
- 运行单个测试类(重点):`mvn -Dtest=ClassName test`
- 单测类示例:`mvn -Dtest=SVNLogFetcherTest test`
- 运行单个测试方法(重点):`mvn -Dtest=ClassName#methodName test`
- 单测方法示例:`mvn -Dtest=SVNLogFetcherTest#shouldFilterByUser test`
- 调试失败测试(输出更完整):`mvn -Dtest=ClassName test -e`
- 说明:当前 `src/test/java` 为空;新增测试时采用 Surefire 默认约定。
### 2.4 Run
- 运行主程序SVN 日志抓取):
- `java -jar target/svn-log-tool-1.0.0-jar-with-dependencies.jar`
- 运行 Web 工作台(推荐):
- `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.WebApplication`
- 启动后访问:`http://localhost:8080`
- 运行 DeepSeek 处理器:
- `java -cp target/svn-log-tool-1.0.0-jar-with-dependencies.jar com.svnlog.DeepSeekLogProcessor`
- Maven 方式运行 DeepSeek
- `mvn exec:java -Dexec.mainClass="com.svnlog.DeepSeekLogProcessor"`
## 3. 代码结构与职责边界
- `Main.java`CLI 交互、读取输入、调用 `SVNLogFetcher`、输出 Markdown。
- `SVNLogFetcher.java`SVN 连接、版本区间处理、日志抓取、用户过滤。
- `LogEntry.java`日志数据模型POJO
- `DeepSeekLogProcessor.java`:读取 Markdown、调用 DeepSeek API、生成 Excel。
- `ExcelAnalyzer.java`:本地临时分析工具,偏实验性质。
- `web/controller/*`REST APISVN、AI、任务、文件、设置
- `web/service/*`异步任务与业务编排SVN 抓取、AI 分析、输出目录管理)。
- `src/main/resources/static/*`Web 前端页面与交互脚本。
- 变更原则:
- 抓取逻辑改在 `SVNLogFetcher`
- 交互逻辑改在 `Main`
- AI/Excel 逻辑改在 `DeepSeekLogProcessor`
- 不把多种职责混入同一方法。
## 4. 代码风格规范(必读)
### 4.1 Java 与兼容性
- 严格保持 Java 8 兼容(`source/target=1.8`)。
- 避免引入仅 Java 9+ 可用 API。
### 4.2 Imports
- 不新增通配符导入(`*`),使用明确导入。
- 按三组排序并空行分隔:
1) `java.*` / `javax.*`
2) 第三方库
3) 本项目包(`com.svnlog.*`
- 删除未使用 import。
### 4.3 格式与排版
- 缩进使用 4 个空格,不用 Tab。
- 单行长度建议 <= 120。
- `if/for/while` 必须使用大括号(即使单行语句)。
- 方法间保留一个空行;逻辑块间适度空行。
### 4.4 类型与数据结构
- 优先使用接口类型声明:`List` / `Map`,实现类写在右侧。
- 泛型必须写完整,避免原生类型。
- 可变共享状态最小化;能用 `final` 的局部变量尽量用 `final`
- 使用 `SimpleDateFormat` 等对象时,注意线程安全作用域。
### 4.5 命名规范
- 类名:`UpperCamelCase`(示例:`SVNLogFetcher`)。
- 方法/变量:`lowerCamelCase`(示例:`fetchLogs`)。
- 常量:`UPPER_SNAKE_CASE`(示例:`DEEPSEEK_API_URL`)。
- 名称应表达业务意图,避免无语义缩写(如 `tmp``data1`)。
### 4.6 异常与错误处理
- 不吞异常;至少记录错误上下文。
- CLI 提示要清晰,并给出可执行下一步。
- 能在边界处校验输入时,尽早校验并快速失败。
- 不要仅 `printStackTrace`;优先输出结构化错误信息。
- 外部调用SVN/API/文件)必须处理失败分支与空响应。
### 4.7 I/O 与资源管理
- 文件与网络资源统一使用 try-with-resources。
- 路径与编码显式声明,默认 UTF-8。
- 大文本拼接优先 `StringBuilder`
### 4.8 注释与文档
- 注释解释“为什么”,避免重复“做了什么”。
- 对外可见方法或复杂逻辑可补充简短 Javadoc。
- 修改行为时同步更新 `docs/` 下文档。
## 5. 安全与敏感信息
- 严禁提交真实密钥、口令、Token、内网敏感地址。
- `DeepSeekLogProcessor` 存在硬编码 API Key 风险;新增改动时应:
- 优先从环境变量读取(如 `DEEPSEEK_API_KEY`)。
- 回退到交互输入。
- 不把真实值写入源码或日志。
- 日志中避免打印完整凭据与隐私信息。
## 6. 测试与验收建议
- 功能变更后至少执行:
- `mvn clean compile`
- `mvn test`(若已有测试)
- 新增测试建议目录:`src/test/java/com/svnlog/`
- 测试命名建议:
- 类名:`<被测类名>Test`
- 方法名:`should<行为>When<条件>`
## 7. Git 与提交建议(给 Agent
- 小步提交,标题建议使用动词前缀:`fix:``feat:``refactor:``docs:`
- 一次提交只做一类改动(功能/重构/文档分离)。
- 不顺手修改无关文件。
- 提交前确认构建产物(`target/`)不入库。
## 8. Cursor / Copilot 规则检查结果
- 未发现 `.cursorrules`
- 未发现 `.cursor/rules/` 目录。
- 未发现 `.github/copilot-instructions.md`
- 若后续新增这些规则文件,应同步更新本 AGENTS并以更具体规则优先。
## 9. 给自动化代理的执行清单
- 先读 `pom.xml` 与目标类,再动代码。
- 先最小改动实现需求,再补测试与文档。
- 变更命令、入口、参数时必须更新本文档。
- 无测试时至少确保 `mvn clean compile` 成功。
- 输出结论时写明:改了什么、为什么、如何验证。
## 10. 最小验证流程(建议)
- 仅修改文档:至少检查 Markdown 渲染与命令可复制执行。
- 修改 Java 代码:执行 `mvn clean compile`
- 涉及测试逻辑:执行 `mvn test` 或目标单测命令。
- 涉及打包/入口:执行 `mvn clean package -DskipTests` 并验证产物。
- 涉及 DeepSeek 调用:避免在 CI/自动化中使用真实密钥做在线调用。
- 最终在变更说明中记录验证命令与结果。

89
docs/README_Web.md Normal file
View File

@@ -0,0 +1,89 @@
# SVN 日志 Web 工作台
## 功能概览
Web 工作台将现有 CLI 能力封装为可视化页面与 REST API支持以下流程
1. SVN 参数录入与连接测试
2. 异步抓取日志并导出 Markdown
3. 使用 DeepSeek 分析 Markdown 并生成 Excel
4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
5. 下载输出文件、配置 API Key 与输出目录
6. 工作台展示系统健康状态输出目录可写性、API Key 配置、任务统计)
## 启动方式
在仓库根目录执行:
```bash
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.WebApplication
```
启动后访问:
```text
http://localhost:8080
```
## 页面说明
- 工作台:最近任务统计与最近产物
- SVN 日志抓取SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址)
- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
- 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务
- 系统设置DeepSeek API Key、输出目录、默认 SVN 预置项目
## 输出目录
- 默认输出目录:`outputs/`
- Markdown 输出:`outputs/md/*.md`
- Excel 输出:`outputs/excel/*.xlsx`
- 任务持久化:`outputs/task-history.json`(重启后可恢复历史)
## API Key 读取优先级
1. AI 分析请求中的临时 `apiKey`
2. 设置页保存的运行时 `apiKey`
3. 环境变量 `DEEPSEEK_API_KEY`
建议在生产环境优先使用环境变量,避免敏感信息暴露。
## 主要 API
- `POST /api/svn/test-connection`
- `POST /api/svn/fetch`
- `GET /api/svn/presets`
- `POST /api/ai/analyze`
- `GET /api/tasks`
- `GET /api/tasks/query?status=&type=&keyword=&page=1&size=10`
- `GET /api/tasks/{taskId}`
- `POST /api/tasks/{taskId}/cancel`
- `GET /api/health`
- `GET /api/health/details`
- `GET /api/files`
- `GET /api/files/download?path=...`
- `GET /api/settings`
- `PUT /api/settings`
## 验证建议
至少执行:
```bash
mvn clean compile
```
如需验证完整流程:
1. 启动 Web 服务
2. 在「SVN 日志抓取」创建任务并生成 `.md`
3. 在「AI 工作量分析」选择 `.md` 并生成 `.xlsx`
4. 在「任务历史」中下载产物并核验内容
## AI 输入校验
为避免误操作和资源滥用AI 分析接口增加输入约束:
- 一次最多分析 20 个文件
- 仅允许 `.md` 文件
- 单文件大小不超过 2MB

View File

@@ -1,164 +0,0 @@
# SVN 日志报告
## 查询条件
- **SVN地址**: `https://svn.example.com/project`
- **账号**: `testuser`
- **版本范围**: r1000 - r1050
- **生成时间**: 2025-01-30 10:00:00
## 统计信息
- **总记录数**: 5 条
### 按作者统计
| 作者 | 提交次数 |
|------|----------|
| `zhangsan` | 3 |
| `lisi` | 2 |
## 日志详情
### r1050
**作者**: `zhangsan`
**时间**: 2025-01-30 09:30:00
**版本**: r1050
**变更文件**:
```
/src/main/java/com/example/Service.java
/src/test/java/com/example/ServiceTest.java
```
**提交信息**:
```
修复用户登录时的空指针异常问题
1. 修复用户服务中的空指针检查
2. 添加单元测试验证修复
3. 更新相关文档
```
---
### r1049
**作者**: `zhangsan`
**时间**: 2025-01-29 16:45:00
**版本**: r1049
**变更文件**:
```
/src/main/java/com/example/Controller.java
```
**提交信息**:
```
优化API接口响应速度
# 性能优化
1. 添加数据库查询缓存
2. 优化SQL查询语句
3. 减少不必要的对象创建
# 测试验证
- 响应时间从500ms降低到200ms
- 通过所有单元测试
```
---
### r1048
**作者**: `lisi`
**时间**: 2025-01-29 14:20:00
**版本**: r1048
**变更文件**:
```
/src/main/java/com/example/Dao.java
/src/main/resources/mapper/UserMapper.xml
```
**提交信息**:
```
实现用户数据批量导入功能
# 核心功能
1. 支持Excel文件上传
2. 数据验证和错误处理
3. 批量插入数据库
# 配置变更
- 添加文件上传大小限制
- 配置批量插入批次大小
```
---
### r1047
**作者**: `zhangsan`
**时间**: 2025-01-28 11:00:00
**版本**: r1047
**变更文件**:
```
/src/main/java/com/example/Util.java
/src/main/resources/application.yml
```
**提交信息**:
```
添加日志记录功能
# 新增功能
1. 集成Log4j2日志框架
2. 配置日志输出格式
3. 添加关键操作日志记录
# 配置更新
- 设置日志级别为INFO
- 配置日志文件滚动策略
```
---
### r1046
**作者**: `lisi`
**时间**: 2025-01-27 15:30:00
**版本**: r1046
**变更文件**:
```
/src/main/java/com/example/Model.java
```
**提交信息**:
```
重构数据模型类
# 重构内容
1. 优化字段命名规范
2. 添加数据验证注解
3. 实现序列化接口
# 兼容性
- 保持向后兼容
- 更新相关测试用例
```
---

View File

@@ -1,467 +0,0 @@
# SVN 日志报告
## 查询条件
- **SVN地址**: `https://10.6.220.216:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00/src_java`
- **版本范围**: r249400 - r249935
- **过滤用户**: `liujing2`
## 日志详情
### r249935
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-27 17:33:43
**版本**: r249935
**提交信息**:
```
refactor(utils): 修复导入到处相机用途异常问题
```
---
### r249934
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-27 17:17:22
**版本**: r249934
**提交信息**:
```
refactor(initStatus): 优化设备状态检测定时任务实现,修复启动mould启动慢的问题
```
---
### r249929
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-27 16:27:10
**版本**: r249929
**提交信息**:
```
feat(pipeline): 修复点位导入超时问题
```
---
### r249843
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-23 17:08:29
**版本**: r249843
**提交信息**:
```
fix(pipeline): 修复设备点流水线中的依赖注入问题
```
---
### r249841
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-23 15:19:46
**版本**: r249841
**提交信息**:
```
refactor(pipeline): 导入更新不处理syscode
```
---
### r249830
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-22 15:56:11
**版本**: r249830
**提交信息**:
```
refactor(silent-helper): 过滤掉空名称条目避免空指针异常
- 对站点名称列表增加非空过滤确保stationName不为null
- 对组件名称列表增加非空过滤确保componentName不为null
- 对监控类型列表增加非空过滤确保sceneType不为null
- 对缺陷类型列表增加非空过滤确保defectDesc不为null
- 对设备类型字典增加非空过滤确保dictDesc不为null
- 优化各类映射生成的稳定性和正确性
```
---
### r249811
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-21 15:02:55
**版本**: r249811
**提交信息**:
```
feat(monitor): 遥控配置获取结合平台接口,修复控制服务pom.xml依赖异常问题
- 添加 YKOperateService 依赖注入到 FullViewMonitorController
- 集成 YKDictateOperateVo 响应对象用于遥控信息查询
- 在获取控制类信号配置时调用遥控服务验证并设置操作类型
- 添加 sunri-service-control-spi 依赖到 cygbusiness-model 模块
- 添加 sunri-service-control-yk 依赖到 control-starter 模块
```
---
### r249802
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-20 16:40:39
**版本**: r249802
**提交信息**:
```
同步2024版本244507 fix(linkage-task):手动处理瀚高数据库更新后顺序改变的问题
```
---
### r249797
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-20 16:06:19
**版本**: r249797
**提交信息**:
```
refactor(pom): 调整模块顺序并更新模块名称
- 将 sunri-mybatisplus-starter 模块重命名为 04_sunri-mybatisplus-starter
- 将 sunri-rds-migration 模块重命名为 11_sunri-rds-migration
```
---
### r249795
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-20 14:45:27
**版本**: r249795
**提交信息**:
```
同步2024的的244546版本
优化 MyBatis 映射文件中的 SQL 语句
- 移除了多处 SQL 语句中不必要的反引号
- 统一了表名和字段名的命名风格
- 提高了 SQL语句的可读性和一致性
```
---
### r249794
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-20 14:40:14
**版本**: r249794
**提交信息**:
```
同步2024的的244398版本 处理动态SQL包含问号的问题
```
---
### r249793
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-20 14:22:25
**版本**: r249793
**提交信息**:
```
同步2024的的244359版本处理mysql函数last_insert_id()在瀚高报错的问题
```
---
### r249764
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-19 17:59:22
**版本**: r249764
**提交信息**:
```
refactor(silent): 同步2024的243464
```
---
### r249763
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-19 17:51:03
**版本**: r249763
**提交信息**:
```
refactor(silent): 同步2024的243573
```
---
### r249756
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-19 16:22:57
**版本**: r249756
**提交信息**:
```
默认取二次设备协议
```
---
### r249754
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-19 15:38:01
**版本**: r249754
**提交信息**:
```
依赖异常修复
```
---
### r249752
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-19 14:52:04
**版本**: r249752
**提交信息**:
```
移除单体项目
```
---
### r249751
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-19 14:51:04
**版本**: r249751
**提交信息**:
```
删除私有仓库10.1.105.49:8081
```
---
### r249748
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-19 14:36:25
**版本**: r249748
**提交信息**:
```
删除私有仓库10.1.105.49:8081
```
---
### r249654
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-14 16:25:54
**版本**: r249654
**提交信息**:
```
refactor(silent): 简化设备类型处理逻辑
```
---
### r249652
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-14 16:22:33
**版本**: r249652
**提交信息**:
```
refactor(service): 静默支持机器人和无人机设备类型
```
---
### r249632
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-13 11:47:19
**版本**: r249632
**提交信息**:
```
feat(scheduler): 现场新需求:不同的设备类型任务可以同时执行巡视任务核心类提交
```
---
### r249631
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-13 11:28:10
**版本**: r249631
**提交信息**:
```
feat(scheduler): 现场新需求:不同的设备类型任务可以同时执行巡视任务配置提交
```
---
### r249628
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-13 10:30:56
**版本**: r249628
**提交信息**:
```
refactor(patrol): 现场需求需要下发重复任务名不同编码任务 删除名称重复校验
```
---
### r249626
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-13 09:54:49
**版本**: r249626
**提交信息**:
```
fix(client): 修复 ThriftProxyClient 连接协议初始化问题
```
---
### r249541
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-09 16:42:19
**版本**: r249541
**提交信息**:
```
fix(devops-patrol): 优化超期逻辑
```
---
### r249539
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-09 15:55:13
**版本**: r249539
**提交信息**:
```
fix(devops-patrol): 调整任务超期时间
```
---
### r249412
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-05 17:08:07
**版本**: r249412
**提交信息**:
```
feat(websocket): 根据连接协议支持ws和wss启动UE进程
- 在MultiPlayerWebSocket中传递连接是否安全的标志给UEProcessManager
- 修改UEProcessManager的startUEProcess方法新增isSecure参数
- 根据isSecure参数选择使用ws或wss协议构造启动命令
- 记录并执行包含正确协议的启动命令,确保连接协议匹配
```
---
### r249407
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-05 16:03:12
**版本**: r249407
**提交信息**:
```
fix(multiprocess): 延长UE进程启动成功轮询时间
- 将轮询时间从15秒增加到20秒
- 提高获取engineConnections中连接的成功率
- 优化了UE进程启动判断逻辑
- 减少启动失败的误判情况
```
---
### r249400
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-05 13:56:54
**版本**: r249400
**提交信息**:
```
fix(UEProcessManager): 防止ShenFei进程泄漏增加残留进程清理
- 在removeUEProcess方法中添加killShenFeiProcess调用确保ShenFei进程被终止
- 使用@PostConstruct注解新增init方法服务启动时清理所有残留ShenFei进程
- 实现cleanupAllShenFeiProcesses方法通过bash命令杀死所有匹配engineWebSocket的进程
- 增加异常捕获,确保清理进程时日志正确记录错误信息
- 注释掉MultiPlayerWebSocket中关闭UE进程的代码避免重复关闭造成问题
```
---

View File

@@ -1,259 +0,0 @@
# SVN 日志报告
## 查询条件
- **SVN地址**: `https://10.6.220.216:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00/src_java`
- **版本范围**: r249404 - r250002
- **过滤用户**: `liujing2`
## 日志详情
### r250002
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-29 16:29:08
**版本**: r250002
**提交信息**:
```
chore(pom): 更新项目依赖配置
```
---
### r250001
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-29 16:14:54
**版本**: r250001
**提交信息**:
```
feat(twins): 数字孪生相关功能迁移至svn巡视代码下
```
---
### r249995
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-29 13:58:44
**版本**: r249995
**提交信息**:
```
chore(deps): 更新 pagehelper 版本至 5.3.2
```
---
### r249977
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 17:00:49
**版本**: r249977
**提交信息**:
```
同步2024版本244507 fix(linkage-task):手动处理瀚高数据库更新后顺序改变的问题
```
---
### r249976
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 16:59:11
**版本**: r249976
**提交信息**:
```
同步2024的的244546版本
优化 MyBatis 映射文件中的 SQL 语句
- 移除了多处 SQL 语句中不必要的反引号
- 统一了表名和字段名的命名风格
- 提高了 SQL语句的可读性和一致性
```
---
### r249975
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 16:57:44
**版本**: r249975
**提交信息**:
```
同步2024的的244398版本 处理动态SQL包含问号的问题
```
---
### r249974
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 16:56:37
**版本**: r249974
**提交信息**:
```
同步2024的的244359版本处理mysql函数last_insert_id()在瀚高报错的问题
```
---
### r249973
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 16:50:28
**版本**: r249973
**提交信息**:
```
refactor(silent): 同步2024的243464
```
---
### r249972
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 16:48:17
**版本**: r249972
**提交信息**:
```
refactor(silent): 同步2024的243573
```
---
### r249938
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-27 17:55:15
**版本**: r249938
**提交信息**:
```
feat(export): 添加相机用途字段及其映射支持
```
---
### r249715
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-17 00:26:56
**版本**: r249715
**提交信息**:
```
fix(devops): 修复巡视时间未更新的问题
```
---
### r249677
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-15 16:45:46
**版本**: r249677
**提交信息**:
```
fix(analyse): 单独适配2.00设备外观查看显示
```
---
### r249666
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-15 11:58:58
**版本**: r249666
**提交信息**:
```
feat(analyse-patrol): 优化设备外观查看巡视结果展示效果
```
---
### r249446
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-06 13:59:09
**版本**: r249446
**提交信息**:
```
feat(patrol): 添加巡检联动任务更新状态字段
- 在PatrolLinkageTask实体中添加isUpdate字段
- 更新PatrolLinkageTaskMapper.xml映射文件添加isUpdate字段映射
- 在insert语句中添加isUpdate字段的条件更新逻辑
- 在查询语句中添加isUpdate字段到返回结果中
- 添加按isUpdate降序和ID排序的排序规则
- 修改queryLinkageConfigChange查询条件使用isUpdate = 1替代原条件
- 在PatrolLinkageConfigDto和PatrolLinkageVo中同步添加isUpdate字段
```
---
### r249419
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-05 19:19:50
**版本**: r249419
**提交信息**:
```
feat(linkage): 添加联动配置变更查询功能
- 在 PatrolLinkageConfigController 中新增 queryChange 接口用于查询信号配置变更
- 在 PatrolLinkageTaskMapper 中添加 queryLinkageConfigChange 方法和 old_source_name 字段映射
- 在 PatrolLinkageTaskMapper.xml 中更新 resultMap 和 insert/update 语句以支持 old_source_name
- 在 PatrolLinkageConfiguration 中实现 queryLinkageConfigChange 业务逻辑
- 在 PatrolLinkageService 和 PatrolLinkageServiceImpl 中添加 queryChange 方法
- 在 PatrolLinkageVo 中添加 oldSourceName 字段用于存储原始信号名称
```
---
### r249404
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-05 15:07:32
**版本**: r249404
**提交信息**:
```
feat(linkage): 新增oldSourceName字段以保存原始数据
- 在PatrolLinkageTask类中添加oldSourceName字段
- 在PatrolLinkageConfigDto类中添加oldSourceName字段
- 在PatrolLinkageVo类相关位置添加oldSourceName字段
- 各新增字段用于存储原始数据,便于后续数据处理与追踪
```
---

View File

@@ -1,356 +0,0 @@
# SVN 日志报告
## 查询条件
- **SVN地址**: `https://10.6.220.216:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024/src_java`
- **版本范围**: r249390 - r249969
- **过滤用户**: `liujing2`
## 日志详情
### r249969
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 15:11:59
**版本**: r249969
**提交信息**:
```
fix(patrol): 不添加value到redis
```
---
### r249965
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 14:53:41
**版本**: r249965
**提交信息**:
```
feat(patrol): value值也存redis,上报主站用
```
---
### r249958
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 13:29:37
**版本**: r249958
**提交信息**:
```
fix(patrol): 数据存redis 上报主站用
```
---
### r249950
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 12:03:06
**版本**: r249950
**提交信息**:
```
fix(rtdb): 修正hash键的命名错误
- 将MeterOverlimit中获取hash的key由"meter_overlimit_"改为"jufang_overlimit_"
- 将PatrolTaskManager中添加hash的key改为"jufang_overlimit_"加任务记录ID
- 修复因hash键错误导致的数据读取和写入问题
- 确保hash操作统一使用正确的前缀标识符提高数据一致性和可维护性
```
---
### r249949
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-28 11:56:39
**版本**: r249949
**提交信息**:
```
feat(patrol): 新增redis信息设置方法优化分析结果存储
- 引入RtdbBasicService依赖用于redis操作
- 在识别类型处理逻辑中调用setRedisInfo方法
- 实现setRedisInfo方法校验参数并拆分valueType和value
- 将value拆分后的键值对存入redis hash结构
- 添加异常情况日志打印,确保数据一致性和错误排查
- 保持原有分析结果发送逻辑不变
```
---
### r249909
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-26 20:52:35
**版本**: r249909
**提交信息**:
```
refactor(core): 图片尺寸前端传
```
---
### r249905
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-26 20:10:57
**版本**: r249905
**提交信息**:
```
fix(PatrolTaskService): 处理点线矿返回到前端的图片路径为相对路径
```
---
### r249890
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-26 15:29:31
**版本**: r249890
**提交信息**:
```
fix(devops-patrol): 修复PatrolTaskServiceImpl中request设置异常
```
---
### r249879
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-26 11:20:39
**版本**: r249879
**提交信息**:
```
feat(task): 人工巡视红外测温图异常,新增红外测温功能以获取正常图. 新增点/线/框
```
---
### r249824
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-22 14:22:36
**版本**: r249824
**提交信息**:
```
fix(patrol): 修复最后一个点位结果未统计导致前端状态显示异常问题
```
---
### r249653
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-14 16:25:23
**版本**: r249653
**提交信息**:
```
feat(patrol): 静默分析支持机器人和无人机设备类型处理
```
---
### r249651
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-14 15:54:02
**版本**: r249651
**提交信息**:
```
refactor(sunri-service-alarm-patrol): 优化静默任务图片路径处理逻辑
```
---
### r249642
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-13 15:32:23
**版本**: r249642
**提交信息**:
```
fix(AnalyseResultConvert): 部件表面油污和表计外壳破损 特殊适配后调整点位状态值更新
```
---
### r249588
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-11 13:46:28
**版本**: r249588
**提交信息**:
```
feat(patrol): 支持仪表读数类型使用外观查看类标签.目前仅支持配一种算法
```
---
### r249576
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-10 17:12:47
**版本**: r249576
**提交信息**:
```
fix(patrol): 上送联动配置时过滤掉未启用的配置
```
---
### r249575
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-10 16:48:27
**版本**: r249575
**提交信息**:
```
fix(patrol): 过滤掉未启用的阈值告警配置
```
---
### r249574
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-10 16:31:17
**版本**: r249574
**提交信息**:
```
refactor(patrol): 修复巡视结果上送valueunit如果是红外测试带单位可能异常问题
```
---
### r249538
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-09 15:12:29
**版本**: r249538
**提交信息**:
```
refactor(patrol): 告警阈值模型关联点位未配置算法直接过滤掉
```
---
### r249535
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-09 13:47:49
**版本**: r249535
**提交信息**:
```
feat(patrol): 调整联动配置关联的巡视任务删除逻辑
```
---
### r249530
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-09 10:27:28
**版本**: r249530
**提交信息**:
```
refactor(linkage): 修复删除联动配置没有删除任务,导致下发的时候判断没有联动配置,新增任务插入失败问题
- 统一updatePatrolPlan方法处理新增与更新任务逻辑避免重复代码
- 修正方法参数前后空格格式,提升代码一致性
- 删除冗余注释明确使用updatePatrolPlan替代addLinkagePlan调用
- 调整方法调用处括号与逗号间的空格,规范代码格式
- 增加对sourceCode空值的参数校验保障代码健壮性
```
---
### r249506
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-08 13:51:05
**版本**: r249506
**提交信息**:
```
refactor(linkage): 下发联动时支持所有设备不在进行机器人-无人机-摄像机过滤
- 移除对数据类型的过滤条件检查
- 直接使用原始设备点列表替代过滤后的列表
- 简化集合判空逻辑并统一处理流程
- 删除冗余的设备类型判断相关代码
- 优化流式处理的链式调用结构
```
---
### r249488
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-07 16:24:30
**版本**: r249488
**提交信息**:
```
refactor(core): 修复区域边缘存在时差且刚好区域不再时间执行范围内 边缘刚好在执行时间时间范围内的定时任务执行无视频问题
```
---
### r249390
**作者**: `liujing2@SZNARI`
**时间**: 2026-01-05 11:12:19
**版本**: r249390
**提交信息**:
```
feat(patrol): 修复批量下发异常问题.调整为根据变电站ID进行下发
```
---

29
pom.xml
View File

@@ -17,6 +17,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<spring-boot.version>2.7.18</spring-boot.version>
</properties>
<dependencies>
@@ -52,6 +53,26 @@
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<!-- Web backend -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -104,6 +125,12 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</build>
</project>
</project>

View File

@@ -1,106 +0,0 @@
#!/bin/bash
# 测试多文件处理功能
# 注意:此脚本需要有效的 DeepSeek API Key 才能完成测试
cd /home/liumangmang/opencode/日志
echo "==========================================="
echo " 测试 DeepSeek 日志分析工具"
echo "==========================================="
echo ""
# 检查是否有日志文件
md_files=$(find . -maxdepth 1 -name "svn_log_*.md" | wc -l)
if [ "$md_files" -eq 0 ]; then
echo "警告: 当前目录没有找到 svn_log_*.md 文件"
echo "请先使用 Main.java 生成日志文件"
echo ""
echo "或者手动创建测试文件..."
# 创建测试文件
cat > test_project1.md << 'EOF'
# SVN 日志报告
## 查询条件
- **SVN地址**: `https://test.svn.com/project1`
- **账号**: `testuser`
- **版本范围**: r1 - r10
- **生成时间**: 2026-01-30
## 统计信息
- **总记录数**: 2 条
## 日志详情
### r10
**作者**: `liujing@SZNARI`
**时间**: 2026-01-27 10:00:00
**版本**: r10
**提交信息**:
feat: 添加用户登录功能
### r9
**作者**: `liujing@SZNARI`
**时间**: 2026-01-26 15:00:00
**版本**: r9
**提交信息**:
fix: 修复登录页面样式问题
EOF
cat > test_project2.md << 'EOF'
# SVN 日志报告
## 查询条件
- **SVN地址**: `https://test.svn.com/project2`
- **账号**: `testuser`
- **版本范围**: r1 - r10
- **生成时间**: 2026-01-30
## 统计信息
- **总记录数**: 1 条
## 日志详情
### r8
**作者**: `liujing@SZNARI`
**时间**: 2026-01-25 14:00:00
**版本**: r8
**提交信息**:
refactor: 优化数据库查询性能
EOF
echo "已创建测试文件: test_project1.md, test_project2.md"
fi
echo ""
echo "当前目录下的日志文件:"
ls -lh svn_log_*.md test_*.md 2>/dev/null || echo " (无文件)"
echo ""
echo "==========================================="
echo " 程序使用说明"
echo "==========================================="
echo ""
echo "要运行 DeepSeek 日志分析工具,请执行:"
echo ""
echo " cd /home/liumangmang/opencode/日志"
echo " java -jar svn-log-tool/target/svn-log-tool-1.0.0-jar-with-dependencies.jar"
echo ""
echo "然后按提示输入:"
echo " 1. 日志文件所在目录路径(回车使用当前目录)"
echo " 2. 工作周期例如2025年12月"
echo " 3. DeepSeek API Key"
echo " 4. 输出 Excel 文件名(回车使用默认)"
echo ""
echo "程序将自动读取目录中的所有 .md 文件,合并后发送给 DeepSeek API 分析,"
echo "并生成包含多项目工作内容的 Excel 文件。"
echo ""
echo "Excel 输出格式(与参考文件一致):"
echo " - 7列序号、所属班组、技术对接、开发人员、工作周期、开发项目名称、具体工作内容"
echo " - 项目名称用 / 分隔PRS7050/PRS7950"
echo " - 工作内容用 # 标识不同项目"
echo ""

View File

@@ -0,0 +1,12 @@
package com.svnlog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class WebApplication {
public static void main(String[] args) {
SpringApplication.run(WebApplication.class, args);
}
}

View File

@@ -0,0 +1,191 @@
package com.svnlog.web.controller;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.validation.Valid;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.svnlog.web.dto.AiAnalyzeRequest;
import com.svnlog.web.dto.SettingsUpdateRequest;
import com.svnlog.web.dto.SvnConnectionRequest;
import com.svnlog.web.dto.SvnFetchRequest;
import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskPageResult;
import com.svnlog.web.service.AiWorkflowService;
import com.svnlog.web.service.HealthService;
import com.svnlog.web.service.OutputFileService;
import com.svnlog.web.service.SettingsService;
import com.svnlog.web.service.SvnPresetService;
import com.svnlog.web.service.SvnWorkflowService;
import com.svnlog.web.service.TaskService;
@RestController
@RequestMapping("/api")
public class AppController {
private final SvnWorkflowService svnWorkflowService;
private final AiWorkflowService aiWorkflowService;
private final TaskService taskService;
private final OutputFileService outputFileService;
private final SettingsService settingsService;
private final SvnPresetService svnPresetService;
private final HealthService healthService;
public AppController(SvnWorkflowService svnWorkflowService,
AiWorkflowService aiWorkflowService,
TaskService taskService,
OutputFileService outputFileService,
SettingsService settingsService,
SvnPresetService svnPresetService,
HealthService healthService) {
this.svnWorkflowService = svnWorkflowService;
this.aiWorkflowService = aiWorkflowService;
this.taskService = taskService;
this.outputFileService = outputFileService;
this.settingsService = settingsService;
this.svnPresetService = svnPresetService;
this.healthService = healthService;
}
@GetMapping("/health")
public Map<String, Object> health() {
return healthService.basicHealth();
}
@GetMapping("/health/details")
public Map<String, Object> healthDetails() throws IOException {
return healthService.detailedHealth();
}
@PostMapping("/svn/test-connection")
public Map<String, Object> testSvnConnection(@Valid @RequestBody SvnConnectionRequest request) throws Exception {
svnWorkflowService.testConnection(request);
final Map<String, Object> response = new HashMap<String, Object>();
response.put("success", true);
response.put("message", "SVN 连接成功");
return response;
}
@PostMapping("/svn/fetch")
public Map<String, String> fetchSvnLogs(@Valid @RequestBody SvnFetchRequest request) {
final String taskId = taskService.submit("SVN_FETCH", context -> svnWorkflowService.fetchToMarkdown(request, context));
final Map<String, String> response = new HashMap<String, String>();
response.put("taskId", taskId);
return response;
}
@GetMapping("/svn/presets")
public Map<String, Object> listSvnPresets() {
final Map<String, Object> response = new HashMap<String, Object>();
final List<SvnPreset> presets = svnPresetService.listPresets();
response.put("presets", presets);
response.put("defaultPresetId", settingsService.getDefaultSvnPresetId());
return response;
}
@PostMapping("/ai/analyze")
public Map<String, String> analyzeLogs(@Valid @RequestBody AiAnalyzeRequest request) {
final String taskId = taskService.submit("AI_ANALYZE", context -> aiWorkflowService.analyzeAndExport(request, context));
final Map<String, String> response = new HashMap<String, String>();
response.put("taskId", taskId);
return response;
}
@GetMapping("/tasks")
public List<TaskInfo> listTasks() {
return taskService.getTasks();
}
@GetMapping("/tasks/query")
public TaskPageResult queryTasks(
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "10") int size
) {
return taskService.queryTasks(status, type, keyword, page, size);
}
@GetMapping("/tasks/{taskId}")
public TaskInfo getTask(@PathVariable("taskId") String taskId) {
final TaskInfo task = taskService.getTask(taskId);
if (task == null) {
throw new IllegalArgumentException("任务不存在: " + taskId);
}
return task;
}
@PostMapping("/tasks/{taskId}/cancel")
public Map<String, Object> cancelTask(@PathVariable("taskId") String taskId) {
final boolean cancelled = taskService.cancelTask(taskId);
final Map<String, Object> response = new HashMap<String, Object>();
response.put("taskId", taskId);
response.put("cancelled", cancelled);
if (!cancelled) {
response.put("message", "任务已结束或不存在,无法取消");
} else {
response.put("message", "任务取消成功");
}
return response;
}
@GetMapping("/files")
public Map<String, Object> listFiles() throws IOException {
final Map<String, Object> response = new HashMap<String, Object>();
response.put("files", outputFileService.listOutputFiles());
response.put("outputDir", outputFileService.getOutputRoot().toString());
return response;
}
@GetMapping("/files/download")
public ResponseEntity<InputStreamResource> downloadFile(@RequestParam("path") String relativePath) throws IOException {
final Path file = outputFileService.resolveInOutput(relativePath);
if (!Files.exists(file) || !Files.isRegularFile(file)) {
throw new IllegalArgumentException("文件不存在: " + relativePath);
}
final HttpHeaders headers = new HttpHeaders();
headers.setContentDispositionFormData("attachment", file.getFileName().toString());
final MediaType mediaType = relativePath.toLowerCase().endsWith(".md")
? MediaType.parseMediaType("text/markdown")
: MediaType.APPLICATION_OCTET_STREAM;
return ResponseEntity.ok()
.headers(headers)
.contentLength(Files.size(file))
.contentType(mediaType)
.body(new InputStreamResource(Files.newInputStream(file)));
}
@GetMapping("/settings")
public Map<String, Object> getSettings() throws IOException {
return settingsService.getSettings();
}
@PutMapping("/settings")
public Map<String, Object> updateSettings(@RequestBody SettingsUpdateRequest request) throws IOException {
settingsService.updateSettings(request.getApiKey(), request.getOutputDir(), request.getDefaultSvnPresetId());
return settingsService.getSettings();
}
}

View File

@@ -0,0 +1,38 @@
package com.svnlog.web.controller;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException ex) {
return build(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
return build(HttpStatus.BAD_REQUEST, "请求参数校验失败");
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleAny(Exception ex) {
return build(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage() == null ? "系统异常" : ex.getMessage());
}
private ResponseEntity<Map<String, Object>> build(HttpStatus status, String message) {
final Map<String, Object> response = new HashMap<String, Object>();
response.put("status", status.value());
response.put("error", message);
response.put("timestamp", Instant.now().toString());
return ResponseEntity.status(status).body(response);
}
}

View File

@@ -0,0 +1,47 @@
package com.svnlog.web.dto;
import java.util.List;
import javax.validation.constraints.NotEmpty;
public class AiAnalyzeRequest {
@NotEmpty
private List<String> filePaths;
private String period;
private String apiKey;
private String outputFileName;
public List<String> getFilePaths() {
return filePaths;
}
public void setFilePaths(List<String> filePaths) {
this.filePaths = filePaths;
}
public String getPeriod() {
return period;
}
public void setPeriod(String period) {
this.period = period;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getOutputFileName() {
return outputFileName;
}
public void setOutputFileName(String outputFileName) {
this.outputFileName = outputFileName;
}
}

View File

@@ -0,0 +1,32 @@
package com.svnlog.web.dto;
public class SettingsUpdateRequest {
private String apiKey;
private String outputDir;
private String defaultSvnPresetId;
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getOutputDir() {
return outputDir;
}
public void setOutputDir(String outputDir) {
this.outputDir = outputDir;
}
public String getDefaultSvnPresetId() {
return defaultSvnPresetId;
}
public void setDefaultSvnPresetId(String defaultSvnPresetId) {
this.defaultSvnPresetId = defaultSvnPresetId;
}
}

View File

@@ -0,0 +1,39 @@
package com.svnlog.web.dto;
import javax.validation.constraints.NotBlank;
public class SvnConnectionRequest {
@NotBlank
private String url;
@NotBlank
private String username;
@NotBlank
private String password;
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,77 @@
package com.svnlog.web.dto;
import javax.validation.constraints.NotBlank;
public class SvnFetchRequest {
private String projectName;
@NotBlank
private String url;
@NotBlank
private String username;
@NotBlank
private String password;
private Long startRevision;
private Long endRevision;
private String filterUser;
public String getProjectName() {
return projectName;
}
public void setProjectName(String projectName) {
this.projectName = projectName;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public Long getStartRevision() {
return startRevision;
}
public void setStartRevision(Long startRevision) {
this.startRevision = startRevision;
}
public Long getEndRevision() {
return endRevision;
}
public void setEndRevision(Long endRevision) {
this.endRevision = endRevision;
}
public String getFilterUser() {
return filterUser;
}
public void setFilterUser(String filterUser) {
this.filterUser = filterUser;
}
}

View File

@@ -0,0 +1,34 @@
package com.svnlog.web.model;
import java.time.Instant;
public class OutputFileInfo {
private String path;
private long size;
private Instant modifiedAt;
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
public Instant getModifiedAt() {
return modifiedAt;
}
public void setModifiedAt(Instant modifiedAt) {
this.modifiedAt = modifiedAt;
}
}

View File

@@ -0,0 +1,41 @@
package com.svnlog.web.model;
public class SvnPreset {
private String id;
private String name;
private String url;
public SvnPreset() {
}
public SvnPreset(String id, String name, String url) {
this.id = id;
this.name = name;
this.url = url;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
}

View File

@@ -0,0 +1,86 @@
package com.svnlog.web.model;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public class TaskInfo {
private String taskId;
private String type;
private TaskStatus status;
private int progress;
private String message;
private String error;
private Instant createdAt;
private Instant updatedAt;
private final List<String> files = new ArrayList<String>();
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public TaskStatus getStatus() {
return status;
}
public void setStatus(TaskStatus status) {
this.status = status;
}
public int getProgress() {
return progress;
}
public void setProgress(int progress) {
this.progress = progress;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
public List<String> getFiles() {
return files;
}
}

View File

@@ -0,0 +1,44 @@
package com.svnlog.web.model;
import java.util.ArrayList;
import java.util.List;
public class TaskPageResult {
private int page;
private int size;
private long total;
private List<TaskInfo> items = new ArrayList<TaskInfo>();
public int getPage() {
return page;
}
public void setPage(int page) {
this.page = page;
}
public int getSize() {
return size;
}
public void setSize(int size) {
this.size = size;
}
public long getTotal() {
return total;
}
public void setTotal(long total) {
this.total = total;
}
public List<TaskInfo> getItems() {
return items;
}
public void setItems(List<TaskInfo> items) {
this.items = items;
}
}

View File

@@ -0,0 +1,33 @@
package com.svnlog.web.model;
import java.util.ArrayList;
import java.util.List;
public class TaskResult {
private String message;
private final List<String> files = new ArrayList<String>();
public TaskResult() {
}
public TaskResult(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public List<String> getFiles() {
return files;
}
public void addFile(String file) {
this.files.add(file);
}
}

View File

@@ -0,0 +1,9 @@
package com.svnlog.web.model;
public enum TaskStatus {
PENDING,
RUNNING,
SUCCESS,
FAILED,
CANCELLED
}

View File

@@ -0,0 +1,35 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class AiInputValidator {
private static final int MAX_FILES = 20;
private static final long MAX_FILE_SIZE = 2L * 1024L * 1024L;
public void validate(List<Path> markdownPaths) throws IOException {
if (markdownPaths == null || markdownPaths.isEmpty()) {
throw new IllegalArgumentException("至少需要选择 1 个 Markdown 文件");
}
if (markdownPaths.size() > MAX_FILES) {
throw new IllegalArgumentException("一次最多分析 " + MAX_FILES + " 个文件");
}
for (Path path : markdownPaths) {
final String fileName = path.getFileName() == null ? "" : path.getFileName().toString().toLowerCase();
if (!fileName.endsWith(".md")) {
throw new IllegalArgumentException("仅支持 .md 文件: " + path.toString());
}
final long fileSize = Files.size(path);
if (fileSize > MAX_FILE_SIZE) {
throw new IllegalArgumentException("文件过大(>2MB: " + path.getFileName());
}
}
}
}

View File

@@ -0,0 +1,366 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.svnlog.web.dto.AiAnalyzeRequest;
import com.svnlog.web.model.TaskResult;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
@Service
public class AiWorkflowService {
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.readTimeout(180, TimeUnit.SECONDS)
.build();
private final OutputFileService outputFileService;
private final SettingsService settingsService;
private final AiInputValidator aiInputValidator;
private final RetrySupport retrySupport = new RetrySupport();
public AiWorkflowService(OutputFileService outputFileService,
SettingsService settingsService,
AiInputValidator aiInputValidator) {
this.outputFileService = outputFileService;
this.settingsService = settingsService;
this.aiInputValidator = aiInputValidator;
}
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
context.setProgress(10, "正在读取 Markdown 文件");
final List<Path> markdownFiles = resolveUserFiles(request.getFilePaths());
aiInputValidator.validate(markdownFiles);
final String content = readMarkdownFiles(markdownFiles);
context.setProgress(35, "正在请求 DeepSeek 分析");
final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
? request.getPeriod().trim()
: new SimpleDateFormat("yyyy年MM月").format(new Date());
final String apiKey = settingsService.pickActiveKey(request.getApiKey());
if (apiKey == null || apiKey.trim().isEmpty()) {
throw new IllegalStateException("未配置 DeepSeek API Key可在设置页配置或请求中传入");
}
final String prompt = buildPrompt(content, period);
final String aiResponse = callDeepSeek(apiKey, prompt);
final JsonObject payload = extractJson(aiResponse);
context.setProgress(75, "正在生成 Excel 文件");
final String filename = buildOutputFilename(request.getOutputFileName());
final String relative = "excel/" + filename;
final Path outputFile = outputFileService.resolveInOutput(relative);
Files.createDirectories(outputFile.getParent());
writeExcel(outputFile, payload, period);
context.setProgress(100, "AI 分析已完成");
final TaskResult result = new TaskResult("工作量统计已生成");
result.addFile(relative);
return result;
}
private String readMarkdownFiles(List<Path> filePaths) throws IOException {
final StringBuilder builder = new StringBuilder();
for (Path path : filePaths) {
final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
builder.append("\n\n=== 文件: ").append(path.getFileName().toString()).append(" ===\n");
builder.append(content);
}
return builder.toString();
}
private List<Path> resolveUserFiles(List<String> userPaths) throws IOException {
java.util.ArrayList<Path> files = new java.util.ArrayList<Path>();
if (userPaths == null) {
return files;
}
for (String userPath : userPaths) {
files.add(resolveUserFile(userPath));
}
return files;
}
private Path resolveUserFile(String userPath) throws IOException {
if (userPath == null || userPath.trim().isEmpty()) {
throw new IllegalArgumentException("文件路径不能为空");
}
final Path outputRoot = outputFileService.getOutputRoot();
final Path rootPath = Paths.get("").toAbsolutePath().normalize();
final Path candidate = rootPath.resolve(userPath).normalize();
if (candidate.startsWith(outputRoot) || candidate.startsWith(rootPath.resolve("docs").normalize())) {
if (Files.exists(candidate) && Files.isRegularFile(candidate)) {
return candidate;
}
}
throw new IllegalArgumentException("文件不存在或不在允许目录:" + userPath);
}
private String buildPrompt(String markdownContent, String period) {
return "你是项目管理助手,请根据以下 SVN Markdown 日志生成工作量统计 JSON。\n"
+ "工作周期: " + period + "\n"
+ "要求:仅输出 JSON不要输出额外文字。\n"
+ "JSON结构:\n"
+ "{\n"
+ " \"team\": \"所属班组\",\n"
+ " \"contact\": \"技术对接人\",\n"
+ " \"developer\": \"开发人员\",\n"
+ " \"period\": \"" + period + "\",\n"
+ " \"records\": [\n"
+ " {\"sequence\":1,\"project\":\"项目A/项目B\",\"content\":\"# 项目A\\n1.xxx\\n2.xxx\"}\n"
+ " ]\n"
+ "}\n\n"
+ "日志内容:\n" + markdownContent;
}
private String callDeepSeek(String apiKey, String prompt) throws IOException {
try {
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt), 3, 1000L);
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException(e.getMessage(), e);
}
}
private String callDeepSeekOnce(String apiKey, String prompt) throws Exception {
final JsonObject message = new JsonObject();
message.addProperty("role", "user");
message.addProperty("content", prompt);
final JsonArray messages = new JsonArray();
messages.add(message);
final JsonObject body = new JsonObject();
body.addProperty("model", "deepseek-reasoner");
body.add("messages", messages);
body.addProperty("max_tokens", 3500);
body.addProperty("stream", false);
final Request request = new Request.Builder()
.url(DEEPSEEK_API_URL)
.addHeader("Authorization", "Bearer " + apiKey)
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
String errorBody = "";
if (response.body() != null) {
errorBody = response.body().string();
}
String detail = "DeepSeek API 调用失败: " + response.code() + " " + errorBody;
if (response.code() == 429 || response.code() >= 500) {
throw new RetrySupport.RetryableException(detail);
}
throw new IllegalStateException(detail);
}
if (response.body() == null) {
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
}
final String raw = response.body().string();
final JsonObject data = JsonParser.parseString(raw).getAsJsonObject();
final JsonArray choices = data.getAsJsonArray("choices");
if (choices == null || choices.size() == 0) {
throw new IllegalStateException("DeepSeek API 未返回可用结果");
}
final JsonObject first = choices.get(0).getAsJsonObject();
final JsonObject messageObj = first.getAsJsonObject("message");
if (messageObj == null || !messageObj.has("content")) {
throw new IllegalStateException("DeepSeek API 响应缺少 content 字段");
}
return messageObj.get("content").getAsString();
}
}
private JsonObject extractJson(String rawResponse) {
String trimmed = rawResponse == null ? "" : rawResponse.trim();
if (trimmed.startsWith("```json")) {
trimmed = trimmed.substring(7).trim();
} else if (trimmed.startsWith("```")) {
trimmed = trimmed.substring(3).trim();
}
if (trimmed.endsWith("```")) {
trimmed = trimmed.substring(0, trimmed.length() - 3).trim();
}
return JsonParser.parseString(trimmed).getAsJsonObject();
}
private String buildOutputFilename(String outputFileName) {
if (outputFileName != null && !outputFileName.trim().isEmpty()) {
String name = outputFileName.trim();
if (!name.toLowerCase().endsWith(".xlsx")) {
name = name + ".xlsx";
}
return sanitize(name);
}
return new SimpleDateFormat("yyyyMM").format(new Date()) + "工作量统计.xlsx";
}
private void writeExcel(Path outputFile, JsonObject payload, String defaultPeriod) throws IOException {
final String team = optString(payload, "team");
final String contact = optString(payload, "contact");
final String developer = optString(payload, "developer");
final String period = payload.has("period") ? optString(payload, "period") : defaultPeriod;
try (Workbook workbook = new XSSFWorkbook()) {
final Sheet sheet = workbook.createSheet("工作量统计");
final CellStyle headerStyle = createHeaderStyle(workbook);
final CellStyle textStyle = createTextStyle(workbook);
final CellStyle contentStyle = createContentStyle(workbook);
final String[] headers = {"序号", "所属班组", "技术对接", "开发人员", "工作周期", "开发项目名称", "具体工作内容"};
final Row header = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
final Cell cell = header.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
for (int i = 0; i < records.size(); i++) {
final JsonObject record = records.get(i).getAsJsonObject();
final Row row = sheet.createRow(i + 1);
createCell(row, 0, getAsInt(record.get("sequence"), i + 1), textStyle);
createCell(row, 1, team, textStyle);
createCell(row, 2, contact, textStyle);
createCell(row, 3, developer, textStyle);
createCell(row, 4, period, textStyle);
createCell(row, 5, optString(record, "project"), textStyle);
createCell(row, 6, optString(record, "content"), contentStyle);
}
sheet.setColumnWidth(0, 2200);
sheet.setColumnWidth(1, 4200);
sheet.setColumnWidth(2, 5200);
sheet.setColumnWidth(3, 4200);
sheet.setColumnWidth(4, 4600);
sheet.setColumnWidth(5, 12000);
sheet.setColumnWidth(6, 26000);
Files.createDirectories(outputFile.getParent());
try (OutputStream out = Files.newOutputStream(outputFile)) {
workbook.write(out);
}
}
}
private CellStyle createHeaderStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setBold(true);
font.setFontName("SimSun");
font.setColor(IndexedColors.BLACK.getIndex());
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
return style;
}
private CellStyle createTextStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("SimSun");
style.setFont(font);
style.setAlignment(HorizontalAlignment.LEFT);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setBorderBottom(BorderStyle.THIN);
style.setWrapText(false);
return style;
}
private CellStyle createContentStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("SimSun");
style.setFont(font);
style.setAlignment(HorizontalAlignment.LEFT);
style.setVerticalAlignment(VerticalAlignment.TOP);
style.setWrapText(true);
style.setBorderBottom(BorderStyle.THIN);
return style;
}
private void createCell(Row row, int idx, String value, CellStyle style) {
final Cell cell = row.createCell(idx);
cell.setCellValue(value == null ? "" : value);
cell.setCellStyle(style);
}
private void createCell(Row row, int idx, int value, CellStyle style) {
final Cell cell = row.createCell(idx);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private int getAsInt(JsonElement element, int defaultValue) {
if (element == null) {
return defaultValue;
}
try {
return element.getAsInt();
} catch (Exception e) {
return defaultValue;
}
}
private String optString(JsonObject object, String key) {
if (object == null || !object.has(key) || object.get(key).isJsonNull()) {
return "";
}
return object.get(key).getAsString();
}
private String sanitize(String value) {
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_");
}
}

View File

@@ -0,0 +1,81 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskStatus;
@Service
public class HealthService {
private final OutputFileService outputFileService;
private final SettingsService settingsService;
private final TaskService taskService;
public HealthService(OutputFileService outputFileService,
SettingsService settingsService,
TaskService taskService) {
this.outputFileService = outputFileService;
this.settingsService = settingsService;
this.taskService = taskService;
}
public Map<String, Object> basicHealth() {
final Map<String, Object> response = new HashMap<String, Object>();
response.put("status", "ok");
response.put("timestamp", Instant.now().toString());
return response;
}
public Map<String, Object> detailedHealth() throws IOException {
final Map<String, Object> result = new HashMap<String, Object>();
final Map<String, Object> settings = settingsService.getSettings();
final Path outputRoot = outputFileService.getOutputRoot();
final boolean outputDirWritable = ensureWritable(outputRoot);
int running = 0;
int failed = 0;
int cancelled = 0;
for (TaskInfo task : taskService.getTasks()) {
if (task.getStatus() == TaskStatus.RUNNING || task.getStatus() == TaskStatus.PENDING) {
running++;
}
if (task.getStatus() == TaskStatus.FAILED) {
failed++;
}
if (task.getStatus() == TaskStatus.CANCELLED) {
cancelled++;
}
}
result.put("status", "ok");
result.put("timestamp", Instant.now().toString());
result.put("outputDir", outputRoot.toString());
result.put("outputDirWritable", outputDirWritable);
result.put("apiKeyConfigured", Boolean.TRUE.equals(settings.get("apiKeyConfigured")));
result.put("taskTotal", taskService.getTasks().size());
result.put("taskRunning", running);
result.put("taskFailed", failed);
result.put("taskCancelled", cancelled);
return result;
}
private boolean ensureWritable(Path outputRoot) {
try {
Files.createDirectories(outputRoot);
final Path probe = outputRoot.resolve(".health-probe");
Files.write(probe, "ok".getBytes("UTF-8"));
Files.deleteIfExists(probe);
return true;
} catch (Exception e) {
return false;
}
}
}

View File

@@ -0,0 +1,80 @@
package com.svnlog.web.service;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.OutputFileInfo;
@Service
public class OutputFileService {
private Path outputRoot = Paths.get("outputs").toAbsolutePath().normalize();
public synchronized void setOutputRoot(String outputDir) {
if (outputDir != null && !outputDir.trim().isEmpty()) {
this.outputRoot = Paths.get(outputDir.trim()).toAbsolutePath().normalize();
}
}
public synchronized Path getOutputRoot() throws IOException {
Files.createDirectories(outputRoot);
return outputRoot;
}
public Path resolveInOutput(String relative) throws IOException {
final Path root = getOutputRoot();
final Path resolved = root.resolve(relative).normalize();
if (!resolved.startsWith(root)) {
throw new IllegalArgumentException("非法文件路径");
}
return resolved;
}
public List<OutputFileInfo> listOutputFiles() throws IOException {
final Path root = getOutputRoot();
if (!Files.exists(root)) {
return new ArrayList<OutputFileInfo>();
}
final List<Path> filePaths = new ArrayList<Path>();
Files.walk(root)
.filter(Files::isRegularFile)
.forEach(filePaths::add);
filePaths.sort(Comparator.comparingLong(this::lastModified).reversed());
final List<OutputFileInfo> result = new ArrayList<OutputFileInfo>();
for (Path path : filePaths) {
final OutputFileInfo info = new OutputFileInfo();
info.setPath(root.relativize(path).toString().replace(File.separatorChar, '/'));
info.setSize(Files.size(path));
info.setModifiedAt(Instant.ofEpochMilli(lastModified(path)));
result.add(info);
}
return result;
}
public Path copyIntoOutput(Path source, String outputName) throws IOException {
final Path target = resolveInOutput(outputName);
Files.createDirectories(target.getParent());
return Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
}
private long lastModified(Path path) {
try {
return Files.getLastModifiedTime(path).toMillis();
} catch (IOException e) {
return 0L;
}
}
}

View File

@@ -0,0 +1,53 @@
package com.svnlog.web.service;
public class RetrySupport {
@FunctionalInterface
public interface RetryableSupplier<T> {
T get() throws Exception;
}
public <T> T execute(RetryableSupplier<T> supplier, int maxAttempts, long initialDelayMillis) throws Exception {
if (maxAttempts <= 0) {
throw new IllegalArgumentException("maxAttempts 必须大于 0");
}
Exception lastException = null;
long delay = Math.max(0L, initialDelayMillis);
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return supplier.get();
} catch (Exception ex) {
lastException = ex;
if (attempt == maxAttempts || !isRetryable(ex)) {
throw ex;
}
if (delay > 0L) {
try {
Thread.sleep(delay);
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
throw ex;
}
delay = delay * 2L;
}
}
}
throw lastException;
}
private boolean isRetryable(Exception ex) {
if (ex instanceof RetryableException) {
return true;
}
return ex instanceof java.io.IOException;
}
public static class RetryableException extends Exception {
public RetryableException(String message) {
super(message);
}
}
}

View File

@@ -0,0 +1,67 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
@Service
public class SettingsService {
private final OutputFileService outputFileService;
private final SvnPresetService svnPresetService;
private volatile String runtimeApiKey;
private volatile String defaultSvnPresetId;
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
this.outputFileService = outputFileService;
this.svnPresetService = svnPresetService;
this.defaultSvnPresetId = svnPresetService.firstPresetId();
}
public Map<String, Object> getSettings() throws IOException {
final Map<String, Object> result = new HashMap<String, Object>();
final String envKey = System.getenv("DEEPSEEK_API_KEY");
final String activeKey = pickActiveKey(null);
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
result.put("apiKeySource", runtimeApiKey != null ? "runtime" : (envKey != null ? "env" : "none"));
result.put("outputDir", outputFileService.getOutputRoot().toString());
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
return result;
}
public void updateSettings(String apiKey, String outputDir, String newDefaultSvnPresetId) {
if (apiKey != null && !apiKey.trim().isEmpty()) {
this.runtimeApiKey = apiKey.trim();
}
if (outputDir != null && !outputDir.trim().isEmpty()) {
outputFileService.setOutputRoot(outputDir);
}
if (svnPresetService.containsPresetId(newDefaultSvnPresetId)) {
this.defaultSvnPresetId = newDefaultSvnPresetId;
}
}
public String pickActiveKey(String requestKey) {
if (requestKey != null && !requestKey.trim().isEmpty()) {
return requestKey.trim();
}
if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) {
return runtimeApiKey;
}
final String envKey = System.getenv("DEEPSEEK_API_KEY");
if (envKey != null && !envKey.trim().isEmpty()) {
return envKey.trim();
}
return null;
}
public String getDefaultSvnPresetId() {
if (svnPresetService.containsPresetId(defaultSvnPresetId)) {
return defaultSvnPresetId;
}
return svnPresetService.firstPresetId();
}
}

View File

@@ -0,0 +1,55 @@
package com.svnlog.web.service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.SvnPreset;
@Service
public class SvnPresetService {
private final List<SvnPreset> presets;
public SvnPresetService() {
List<SvnPreset> list = new ArrayList<SvnPreset>();
list.add(new SvnPreset(
"preset-1",
"PRS-7050场站智慧管控",
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00/src_java"
));
list.add(new SvnPreset(
"preset-2",
"PRS-7950在线巡视",
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00/src_java"
));
list.add(new SvnPreset(
"preset-3",
"PRS-7950在线巡视电科院测试版",
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024/src_java"
));
this.presets = Collections.unmodifiableList(list);
}
public List<SvnPreset> listPresets() {
return presets;
}
public boolean containsPresetId(String presetId) {
if (presetId == null || presetId.trim().isEmpty()) {
return false;
}
for (SvnPreset preset : presets) {
if (presetId.equals(preset.getId())) {
return true;
}
}
return false;
}
public String firstPresetId() {
return presets.isEmpty() ? "" : presets.get(0).getId();
}
}

View File

@@ -0,0 +1,107 @@
package com.svnlog.web.service;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
import com.svnlog.LogEntry;
import com.svnlog.SVNLogFetcher;
import com.svnlog.web.dto.SvnConnectionRequest;
import com.svnlog.web.dto.SvnFetchRequest;
import com.svnlog.web.model.TaskResult;
@Service
public class SvnWorkflowService {
private final OutputFileService outputFileService;
public SvnWorkflowService(OutputFileService outputFileService) {
this.outputFileService = outputFileService;
}
public void testConnection(SvnConnectionRequest request) throws SVNException {
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
fetcher.testConnection();
}
public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception {
context.setProgress(10, "正在连接 SVN 仓库");
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
fetcher.testConnection();
context.setProgress(30, "正在拉取 SVN 日志");
final long latest = fetcher.getLatestRevision();
final long start = request.getStartRevision() != null ? request.getStartRevision().longValue() : latest;
final long end = request.getEndRevision() != null ? request.getEndRevision().longValue() : latest;
final List<LogEntry> logs = fetcher.fetchLogs(start, end, safe(request.getFilterUser()));
if (logs.isEmpty()) {
throw new IllegalStateException("未查询到符合条件的日志");
}
context.setProgress(70, "正在生成 Markdown 文件");
final String projectName = request.getProjectName() != null && !request.getProjectName().trim().isEmpty()
? request.getProjectName().trim()
: "custom";
final String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
final String fileName = "md/svn_log_" + sanitize(projectName) + "_" + timestamp + ".md";
final Path outputPath = outputFileService.resolveInOutput(fileName);
Files.createDirectories(outputPath.getParent());
writeMarkdown(outputPath, request, start, end, logs, fetcher);
context.setProgress(100, "SVN 日志导出完成");
final TaskResult result = new TaskResult("成功导出 " + logs.size() + " 条日志");
result.addFile(fileName);
return result;
}
private void writeMarkdown(Path path, SvnFetchRequest request, long startRevision, long endRevision,
List<LogEntry> logs, SVNLogFetcher fetcher) throws IOException {
final StringBuilder markdown = new StringBuilder();
markdown.append("# SVN 日志报告\n\n");
markdown.append("## 查询条件\n\n");
markdown.append("- **SVN地址**: `").append(request.getUrl()).append("`\n");
markdown.append("- **账号**: `").append(request.getUsername()).append("`\n");
markdown.append("- **版本范围**: r").append(startRevision).append(" - r").append(endRevision).append("\n");
if (!safe(request.getFilterUser()).isEmpty()) {
markdown.append("- **过滤用户**: `").append(request.getFilterUser()).append("`\n");
}
markdown.append("- **生成时间**: ").append(fetcher.formatDate(new Date())).append("\n\n");
markdown.append("## 统计信息\n\n");
markdown.append("- **总记录数**: ").append(logs.size()).append("\n\n");
markdown.append("## 日志详情\n\n");
for (LogEntry entry : logs) {
markdown.append("### r").append(entry.getRevision()).append("\n\n");
markdown.append("**作者**: `").append(entry.getAuthor()).append("` \n");
markdown.append("**时间**: ").append(fetcher.formatDate(entry.getDate())).append(" \n");
markdown.append("**版本**: r").append(entry.getRevision()).append("\n\n");
markdown.append("**提交信息**:\n\n");
markdown.append("```\n").append(safe(entry.getMessage())).append("\n```\n\n");
markdown.append("---\n\n");
}
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
writer.write(markdown.toString());
}
}
private String sanitize(String value) {
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_");
}
private String safe(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,24 @@
package com.svnlog.web.service;
import com.svnlog.web.model.TaskInfo;
public class TaskContext {
private final TaskInfo taskInfo;
private final Runnable onUpdate;
public TaskContext(TaskInfo taskInfo, Runnable onUpdate) {
this.taskInfo = taskInfo;
this.onUpdate = onUpdate;
}
public void setProgress(int progress, String message) {
final int bounded = Math.max(0, Math.min(100, progress));
taskInfo.setProgress(bounded);
taskInfo.setMessage(message);
taskInfo.setUpdatedAt(java.time.Instant.now());
if (onUpdate != null) {
onUpdate.run();
}
}
}

View File

@@ -0,0 +1,138 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.stereotype.Service;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskStatus;
@Service
public class TaskPersistenceService {
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
public List<TaskInfo> load(Path storePath) throws IOException {
if (storePath == null || !Files.exists(storePath) || !Files.isRegularFile(storePath)) {
return new ArrayList<TaskInfo>();
}
try (Reader reader = Files.newBufferedReader(storePath, StandardCharsets.UTF_8)) {
List<PersistedTaskInfo> persisted = gson.fromJson(
reader,
new TypeToken<List<PersistedTaskInfo>>() {
}.getType()
);
if (persisted == null) {
return new ArrayList<TaskInfo>();
}
List<TaskInfo> result = new ArrayList<TaskInfo>();
for (PersistedTaskInfo item : persisted) {
result.add(toTaskInfo(item));
}
return result;
}
}
public void save(Path storePath, Collection<TaskInfo> tasks) throws IOException {
if (storePath == null) {
return;
}
if (storePath.getParent() != null) {
Files.createDirectories(storePath.getParent());
}
List<PersistedTaskInfo> persisted = new ArrayList<PersistedTaskInfo>();
if (tasks != null) {
for (TaskInfo task : tasks) {
persisted.add(fromTaskInfo(task));
}
}
try (Writer writer = Files.newBufferedWriter(storePath, StandardCharsets.UTF_8)) {
gson.toJson(persisted, writer);
}
}
private PersistedTaskInfo fromTaskInfo(TaskInfo task) {
PersistedTaskInfo info = new PersistedTaskInfo();
info.taskId = task.getTaskId();
info.type = task.getType();
info.status = task.getStatus() == null ? null : task.getStatus().name();
info.progress = task.getProgress();
info.message = task.getMessage();
info.error = task.getError();
info.createdAt = toString(task.getCreatedAt());
info.updatedAt = toString(task.getUpdatedAt());
info.files = new ArrayList<String>(task.getFiles());
return info;
}
private TaskInfo toTaskInfo(PersistedTaskInfo persisted) {
TaskInfo task = new TaskInfo();
task.setTaskId(persisted.taskId);
task.setType(persisted.type);
task.setStatus(parseStatus(persisted.status));
task.setProgress(persisted.progress);
task.setMessage(persisted.message);
task.setError(persisted.error);
task.setCreatedAt(parseInstant(persisted.createdAt));
task.setUpdatedAt(parseInstant(persisted.updatedAt));
if (persisted.files != null) {
task.getFiles().addAll(persisted.files);
}
return task;
}
private Instant parseInstant(String value) {
if (value == null || value.trim().isEmpty()) {
return Instant.now();
}
try {
return Instant.parse(value);
} catch (Exception e) {
return Instant.now();
}
}
private String toString(Instant value) {
return value == null ? Instant.now().toString() : value.toString();
}
private TaskStatus parseStatus(String value) {
if (value == null || value.trim().isEmpty()) {
return TaskStatus.FAILED;
}
try {
return TaskStatus.valueOf(value);
} catch (Exception e) {
return TaskStatus.FAILED;
}
}
private static class PersistedTaskInfo {
private String taskId;
private String type;
private String status;
private int progress;
private String message;
private String error;
private String createdAt;
private String updatedAt;
private List<String> files;
}
}

View File

@@ -0,0 +1,233 @@
package com.svnlog.web.service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import javax.annotation.PreDestroy;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskPageResult;
import com.svnlog.web.model.TaskResult;
import com.svnlog.web.model.TaskStatus;
@Service
public class TaskService {
public interface TaskRunner {
TaskResult run(TaskContext context) throws Exception;
}
private final ExecutorService executor = Executors.newFixedThreadPool(4);
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
private final Map<String, Future<?>> futures = new ConcurrentHashMap<String, Future<?>>();
private final TaskPersistenceService persistenceService;
private final OutputFileService outputFileService;
public TaskService(TaskPersistenceService persistenceService, OutputFileService outputFileService) {
this.persistenceService = persistenceService;
this.outputFileService = outputFileService;
loadPersistedTasks();
}
public String submit(String type, TaskRunner runner) {
final String taskId = UUID.randomUUID().toString();
final TaskInfo taskInfo = new TaskInfo();
final Instant now = Instant.now();
taskInfo.setTaskId(taskId);
taskInfo.setType(type);
taskInfo.setStatus(TaskStatus.PENDING);
taskInfo.setProgress(0);
taskInfo.setMessage("任务已创建");
taskInfo.setCreatedAt(now);
taskInfo.setUpdatedAt(now);
tasks.put(taskId, taskInfo);
persistSafely();
Future<?> future = executor.submit(new Callable<Void>() {
@Override
public Void call() {
runTaskInternal(taskInfo, runner);
return null;
}
});
futures.put(taskId, future);
return taskId;
}
public TaskInfo getTask(String taskId) {
return tasks.get(taskId);
}
public List<TaskInfo> getTasks() {
return new ArrayList<TaskInfo>(tasks.values()).stream()
.sorted(Comparator.comparing(TaskInfo::getCreatedAt).reversed())
.collect(Collectors.toList());
}
public TaskPageResult queryTasks(String status, String type, String keyword, int page, int size) {
final int safePage = Math.max(page, 1);
final int safeSize = Math.max(1, Math.min(size, 200));
final List<TaskInfo> filtered = getTasks().stream()
.filter(task -> matchStatus(task, status))
.filter(task -> matchType(task, type))
.filter(task -> matchKeyword(task, keyword))
.collect(Collectors.toList());
int fromIndex = (safePage - 1) * safeSize;
if (fromIndex > filtered.size()) {
fromIndex = filtered.size();
}
final int toIndex = Math.min(fromIndex + safeSize, filtered.size());
TaskPageResult result = new TaskPageResult();
result.setPage(safePage);
result.setSize(safeSize);
result.setTotal(filtered.size());
result.setItems(new ArrayList<TaskInfo>(filtered.subList(fromIndex, toIndex)));
return result;
}
public boolean cancelTask(String taskId) {
final TaskInfo task = tasks.get(taskId);
if (task == null) {
return false;
}
final TaskStatus status = task.getStatus();
if (status == TaskStatus.SUCCESS || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED) {
return false;
}
final Future<?> future = futures.get(taskId);
if (future != null) {
future.cancel(true);
}
task.setStatus(TaskStatus.CANCELLED);
task.setMessage("任务已取消");
task.setUpdatedAt(Instant.now());
persistSafely();
return true;
}
private void runTaskInternal(TaskInfo taskInfo, TaskRunner runner) {
try {
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
return;
}
taskInfo.setStatus(TaskStatus.RUNNING);
taskInfo.setMessage("任务执行中");
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
final TaskContext context = new TaskContext(taskInfo, this::persistSafely);
final TaskResult result = runner.run(context);
taskInfo.setStatus(TaskStatus.SUCCESS);
taskInfo.setProgress(100);
taskInfo.setMessage(result != null ? result.getMessage() : "执行完成");
taskInfo.getFiles().clear();
if (result != null && result.getFiles() != null) {
taskInfo.getFiles().addAll(result.getFiles());
}
} catch (Exception e) {
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
return;
}
taskInfo.setStatus(TaskStatus.FAILED);
taskInfo.setError(e.getMessage());
taskInfo.setMessage("执行失败");
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
return;
} finally {
futures.remove(taskInfo.getTaskId());
}
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
}
private void loadPersistedTasks() {
try {
final List<TaskInfo> loaded = persistenceService.load(buildStorePath());
for (TaskInfo task : loaded) {
if (task.getStatus() == TaskStatus.RUNNING || task.getStatus() == TaskStatus.PENDING) {
task.setStatus(TaskStatus.FAILED);
task.setMessage("任务因服务重启中断");
task.setUpdatedAt(Instant.now());
}
tasks.put(task.getTaskId(), task);
}
if (!loaded.isEmpty()) {
persistSafely();
}
} catch (Exception ignored) {
// ignore persistence loading failures to keep service available
}
}
private synchronized void persistSafely() {
try {
persistenceService.save(buildStorePath(), tasks.values());
} catch (Exception ignored) {
// ignore persistence saving failures to avoid interrupting running tasks
}
}
private java.nio.file.Path buildStorePath() throws java.io.IOException {
return outputFileService.getOutputRoot().resolve("task-history.json");
}
private boolean matchStatus(TaskInfo task, String status) {
if (status == null || status.trim().isEmpty()) {
return true;
}
return task.getStatus() != null && task.getStatus().name().equalsIgnoreCase(status.trim());
}
private boolean matchType(TaskInfo task, String type) {
if (type == null || type.trim().isEmpty()) {
return true;
}
return task.getType() != null && task.getType().equalsIgnoreCase(type.trim());
}
private boolean matchKeyword(TaskInfo task, String keyword) {
if (keyword == null || keyword.trim().isEmpty()) {
return true;
}
final String lowered = keyword.trim().toLowerCase(Locale.ROOT);
return contains(task.getTaskId(), lowered)
|| contains(task.getMessage(), lowered)
|| contains(task.getError(), lowered)
|| contains(task.getType(), lowered);
}
private boolean contains(String value, String keyword) {
return value != null && value.toLowerCase(Locale.ROOT).contains(keyword);
}
@PreDestroy
public void destroy() {
executor.shutdownNow();
}
}

View File

@@ -0,0 +1,602 @@
const state = {
tasks: [],
taskPage: { items: [], page: 1, size: 10, total: 0 },
taskQuery: { status: "", type: "", keyword: "", page: 1, size: 10 },
files: [],
health: null,
presets: [],
defaultPresetId: "",
activeView: "dashboard",
polling: null,
};
const CUSTOM_PRESET_ID = "custom";
const viewMeta = {
dashboard: { title: "工作台", desc: "查看系统状态与最近产物" },
svn: { title: "SVN 日志抓取", desc: "配置 SVN 参数并生成 Markdown" },
ai: { title: "AI 工作量分析", desc: "选择 Markdown 后生成工作量 Excel" },
history: { title: "任务历史", desc: "查看任务执行状态、日志与产物" },
settings: { title: "系统设置", desc: "配置 API Key 与输出目录" },
};
document.addEventListener("DOMContentLoaded", async () => {
bindNav();
bindForms();
await loadPresets();
await refreshAll();
await loadSettings();
state.polling = setInterval(refreshAll, 5000);
});
function bindNav() {
document.querySelectorAll(".nav-item").forEach((btn) => {
btn.addEventListener("click", () => {
const view = btn.dataset.view;
switchView(view);
});
});
}
function bindForms() {
const testBtn = document.querySelector("#btn-test-connection");
testBtn.addEventListener("click", onTestConnection);
const svnForm = document.querySelector("#svn-form");
svnForm.addEventListener("submit", onRunSvn);
const aiForm = document.querySelector("#ai-form");
aiForm.addEventListener("submit", onRunAi);
const settingsForm = document.querySelector("#settings-form");
settingsForm.addEventListener("submit", onSaveSettings);
const svnPresetSelect = document.querySelector("#svn-preset-select");
svnPresetSelect.addEventListener("change", onSvnPresetChange);
const taskFilterBtn = document.querySelector("#btn-task-filter");
if (taskFilterBtn) {
taskFilterBtn.addEventListener("click", onTaskFilterSubmit);
}
}
function switchView(view) {
state.activeView = view;
document.querySelectorAll(".nav-item").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.view === view);
});
document.querySelectorAll(".view").forEach((v) => {
v.classList.toggle("active", v.id === `view-${view}`);
});
document.querySelector("#view-title").textContent = viewMeta[view].title;
document.querySelector("#view-desc").textContent = viewMeta[view].desc;
if (view === "history") {
loadTaskPage();
renderFileTable();
}
if (view === "ai") {
renderMdFilePicker();
}
}
async function apiFetch(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `请求失败: ${response.status}`);
}
const text = await response.text();
return text ? JSON.parse(text) : {};
}
async function refreshAll() {
try {
const [tasksResp, filesResp, healthResp] = await Promise.all([
apiFetch("/api/tasks"),
apiFetch("/api/files"),
apiFetch("/api/health/details"),
]);
state.tasks = (tasksResp || []).slice().sort((a, b) => sortByTimeDesc(a.createdAt, b.createdAt));
state.files = (filesResp.files || []).slice().sort((a, b) => sortByTimeDesc(a.modifiedAt, b.modifiedAt));
state.health = healthResp || null;
renderDashboard();
if (state.activeView === "history") {
loadTaskPage();
renderFileTable();
}
if (state.activeView === "ai") {
renderMdFilePicker();
}
} catch (err) {
toast(err.message, true);
}
}
async function loadPresets() {
try {
const data = await apiFetch("/api/svn/presets");
state.presets = data.presets || [];
state.defaultPresetId = data.defaultPresetId || "";
renderPresetSelects();
applyPresetToSvnForm(state.defaultPresetId);
} catch (err) {
toast(err.message, true);
}
}
function renderPresetSelects() {
const svnSelect = document.querySelector("#svn-preset-select");
const settingsSelect = document.querySelector("#settings-default-preset");
svnSelect.innerHTML = "";
settingsSelect.innerHTML = "";
state.presets.forEach((preset) => {
const option1 = document.createElement("option");
option1.value = preset.id;
option1.textContent = `${preset.name}`;
svnSelect.appendChild(option1);
const option2 = document.createElement("option");
option2.value = preset.id;
option2.textContent = `${preset.name}`;
settingsSelect.appendChild(option2);
});
const customOption = document.createElement("option");
customOption.value = CUSTOM_PRESET_ID;
customOption.textContent = "自定义 SVN 地址";
svnSelect.appendChild(customOption);
const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : CUSTOM_PRESET_ID);
svnSelect.value = selected;
settingsSelect.value = selected;
}
function onSvnPresetChange(event) {
applyPresetToSvnForm(event.target.value);
}
function applyPresetToSvnForm(presetId) {
const form = document.querySelector("#svn-form");
const select = document.querySelector("#svn-preset-select");
const projectInput = form.querySelector("[name='projectName']");
const urlInput = form.querySelector("[name='url']");
if (presetId === CUSTOM_PRESET_ID) {
select.value = CUSTOM_PRESET_ID;
projectInput.readOnly = false;
urlInput.readOnly = false;
return;
}
const preset = state.presets.find((item) => item.id === presetId);
if (!preset) {
return;
}
select.value = preset.id;
projectInput.value = preset.name;
urlInput.value = preset.url;
projectInput.readOnly = true;
urlInput.readOnly = true;
}
function renderDashboard() {
const total = state.tasks.length;
const running = state.tasks.filter((t) => t.status === "RUNNING" || t.status === "PENDING").length;
const failed = state.tasks.filter((t) => t.status === "FAILED").length;
const health = state.health;
document.querySelector("#stat-total").textContent = `${total}`;
document.querySelector("#stat-running").textContent = `${running}`;
document.querySelector("#stat-failed").textContent = `${failed}`;
document.querySelector("#stat-health").textContent = health && health.outputDirWritable ? "正常" : "异常";
const healthDetails = document.querySelector("#health-details");
if (health) {
healthDetails.textContent = `输出目录: ${health.outputDir} | 可写: ${health.outputDirWritable ? "是" : "否"} | API Key: ${health.apiKeyConfigured ? "已配置" : "未配置"}`;
} else {
healthDetails.textContent = "健康状态暂不可用";
}
const taskList = document.querySelector("#recent-tasks");
taskList.innerHTML = "";
state.tasks.slice(0, 6).forEach((task) => {
const li = document.createElement("li");
li.innerHTML = `<strong>${task.type}</strong> · <span class="tag ${task.status}">${task.status}</span><br><span class="muted">${task.message || ""}</span>`;
taskList.appendChild(li);
});
if (taskList.children.length === 0) {
taskList.innerHTML = "<li class='muted'>暂无任务记录</li>";
}
const fileList = document.querySelector("#recent-files");
fileList.innerHTML = "";
state.files.slice(0, 6).forEach((file) => {
const path = file.path;
const li = document.createElement("li");
li.innerHTML = `<a href="/api/files/download?path=${encodeURIComponent(path)}">${escapeHtml(path)}</a><br><span class='muted'>${formatBytes(file.size)}</span>`;
fileList.appendChild(li);
});
if (fileList.children.length === 0) {
fileList.innerHTML = "<li class='muted'>暂无输出文件</li>";
}
}
async function onTestConnection() {
const form = document.querySelector("#svn-form");
const payload = readForm(form);
if (!payload.url || !payload.username || !payload.password) {
toast("请先填写 SVN 地址、账号和密码", true);
return;
}
const btn = document.querySelector("#btn-test-connection");
setLoading(btn, true);
try {
await apiFetch("/api/svn/test-connection", {
method: "POST",
body: JSON.stringify({
url: payload.url,
username: payload.username,
password: payload.password,
}),
});
toast("SVN 连接成功");
} catch (err) {
toast(err.message, true);
} finally {
setLoading(btn, false);
}
}
async function onRunSvn(event) {
event.preventDefault();
const form = event.target;
const payload = readForm(form);
const btn = document.querySelector("#btn-svn-run");
setLoading(btn, true);
try {
const data = await apiFetch("/api/svn/fetch", {
method: "POST",
body: JSON.stringify({
projectName: payload.projectName || "",
url: payload.url,
username: payload.username,
password: payload.password,
startRevision: toNumberOrNull(payload.startRevision),
endRevision: toNumberOrNull(payload.endRevision),
filterUser: payload.filterUser || "",
}),
});
toast(`SVN 抓取任务已创建:${data.taskId}`);
switchView("history");
refreshAll();
} catch (err) {
toast(err.message, true);
} finally {
setLoading(btn, false);
}
}
function renderMdFilePicker() {
const box = document.querySelector("#md-file-picker");
const mdFiles = state.files.filter((f) => f.path.toLowerCase().endsWith(".md"));
box.innerHTML = "";
if (mdFiles.length === 0) {
box.innerHTML = "<p class='muted'>暂无 Markdown 文件,请先执行 SVN 抓取。</p>";
return;
}
mdFiles.forEach((file, idx) => {
const path = file.path;
const id = `md-file-${idx}`;
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.id = id;
input.value = path;
label.setAttribute("for", id);
const span = document.createElement("span");
span.textContent = `${path} (${formatBytes(file.size)})`;
label.appendChild(input);
label.appendChild(span);
box.appendChild(label);
});
}
async function onRunAi(event) {
event.preventDefault();
const form = event.target;
const payload = readForm(form);
const checked = [...document.querySelectorAll("#md-file-picker input[type='checkbox']:checked")]
.map((input) => input.value);
if (!checked.length) {
toast("请至少选择一个 Markdown 文件", true);
return;
}
const btn = document.querySelector("#btn-ai-run");
setLoading(btn, true);
try {
const data = await apiFetch("/api/ai/analyze", {
method: "POST",
body: JSON.stringify({
filePaths: checked,
period: payload.period || "",
apiKey: payload.apiKey || "",
outputFileName: payload.outputFileName || "",
}),
});
toast(`AI 分析任务已创建:${data.taskId}`);
switchView("history");
refreshAll();
} catch (err) {
toast(err.message, true);
} finally {
setLoading(btn, false);
}
}
function renderTaskTable() {
const container = document.querySelector("#task-table");
if (!state.taskPage.items.length) {
container.innerHTML = "<p class='muted'>暂无任务记录</p>";
renderTaskPager();
return;
}
const rows = state.taskPage.items.map((task) => {
const files = (task.files || []).map((f) => `<a href="/api/files/download?path=${encodeURIComponent(f)}">${escapeHtml(f)}</a>`).join("<br>");
const canCancel = task.status === "RUNNING" || task.status === "PENDING";
return `<tr>
<td>${escapeHtml(task.taskId.slice(0, 8))}</td>
<td>${escapeHtml(task.type)}</td>
<td><span class="tag ${task.status}">${task.status}</span></td>
<td>${task.progress || 0}%</td>
<td>${escapeHtml(task.message || "")}${task.error ? `<br><span class='muted'>${escapeHtml(task.error)}</span>` : ""}</td>
<td>${files || "-"}</td>
<td>${canCancel ? `<button type="button" class="btn-cancel-task" data-task-id="${escapeHtml(task.taskId)}">取消</button>` : "-"}</td>
</tr>`;
}).join("");
container.innerHTML = `<table>
<thead><tr><th>任务ID</th><th>类型</th><th>状态</th><th>进度</th><th>说明</th><th>产物</th><th>操作</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
document.querySelectorAll(".btn-cancel-task").forEach((btn) => {
btn.addEventListener("click", async () => {
const taskId = btn.dataset.taskId;
if (!taskId) {
return;
}
setLoading(btn, true);
try {
const result = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: "POST" });
toast(result.message || "任务取消请求已处理");
await loadTaskPage();
await refreshAll();
} catch (err) {
toast(err.message, true);
} finally {
setLoading(btn, false);
}
});
});
renderTaskPager();
}
async function loadTaskPage() {
const params = new URLSearchParams();
if (state.taskQuery.status) {
params.set("status", state.taskQuery.status);
}
if (state.taskQuery.type) {
params.set("type", state.taskQuery.type);
}
if (state.taskQuery.keyword) {
params.set("keyword", state.taskQuery.keyword);
}
params.set("page", String(state.taskQuery.page));
params.set("size", String(state.taskQuery.size));
try {
const data = await apiFetch(`/api/tasks/query?${params.toString()}`);
state.taskPage = {
items: data.items || [],
page: data.page || 1,
size: data.size || state.taskQuery.size,
total: data.total || 0,
};
renderTaskTable();
} catch (err) {
toast(err.message, true);
}
}
function onTaskFilterSubmit() {
state.taskQuery.status = document.querySelector("#task-filter-status").value || "";
state.taskQuery.type = document.querySelector("#task-filter-type").value || "";
state.taskQuery.keyword = (document.querySelector("#task-filter-keyword").value || "").trim();
state.taskQuery.page = 1;
loadTaskPage();
}
function renderTaskPager() {
const pager = document.querySelector("#task-pager");
if (!pager) {
return;
}
const totalPages = Math.max(1, Math.ceil((state.taskPage.total || 0) / state.taskQuery.size));
const current = state.taskPage.page || 1;
pager.innerHTML = `
<span>共 ${state.taskPage.total || 0} 条,第 ${current}/${totalPages} 页</span>
<div class="pager-actions">
<button type="button" ${current <= 1 ? "disabled" : ""} id="btn-page-prev">上一页</button>
<button type="button" ${current >= totalPages ? "disabled" : ""} id="btn-page-next">下一页</button>
</div>
`;
const prev = document.querySelector("#btn-page-prev");
const next = document.querySelector("#btn-page-next");
if (prev) {
prev.addEventListener("click", () => {
if (state.taskQuery.page > 1) {
state.taskQuery.page -= 1;
loadTaskPage();
}
});
}
if (next) {
next.addEventListener("click", () => {
if (state.taskQuery.page < totalPages) {
state.taskQuery.page += 1;
loadTaskPage();
}
});
}
}
function renderFileTable() {
const container = document.querySelector("#file-table");
if (!state.files.length) {
container.innerHTML = "<p class='muted'>暂无输出文件</p>";
return;
}
const rows = state.files.map((file) => {
const path = file.path;
return `
<tr>
<td>${escapeHtml(path)}</td>
<td>${formatBytes(file.size)}</td>
<td>${formatTime(file.modifiedAt)}</td>
<td><a href="/api/files/download?path=${encodeURIComponent(path)}">下载</a></td>
</tr>
`;
}).join("");
container.innerHTML = `<table>
<thead><tr><th>文件路径</th><th>大小</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
async function loadSettings() {
try {
const data = await apiFetch("/api/settings");
document.querySelector("#settings-form [name='outputDir']").value = data.outputDir || "";
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
const settingsPreset = document.querySelector("#settings-default-preset");
if (settingsPreset && state.defaultPresetId) {
settingsPreset.value = state.defaultPresetId;
}
applyPresetToSvnForm(state.defaultPresetId);
document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource}`;
} catch (err) {
toast(err.message, true);
}
}
async function onSaveSettings(event) {
event.preventDefault();
const payload = readForm(event.target);
const btn = event.target.querySelector("button[type='submit']");
setLoading(btn, true);
try {
const data = await apiFetch("/api/settings", {
method: "PUT",
body: JSON.stringify(payload),
});
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
applyPresetToSvnForm(state.defaultPresetId);
document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource}`;
toast("设置保存成功");
} catch (err) {
toast(err.message, true);
} finally {
setLoading(btn, false);
}
}
function readForm(form) {
const data = new FormData(form);
return Object.fromEntries(data.entries());
}
function setLoading(button, loading) {
if (!button) {
return;
}
button.disabled = loading;
if (loading) {
button.dataset.originalText = button.textContent;
button.textContent = "处理中...";
} else {
button.textContent = button.dataset.originalText || button.textContent;
}
}
function toNumberOrNull(value) {
if (value === null || value === undefined || String(value).trim() === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function toast(message, isError = false) {
const el = document.querySelector("#toast");
el.textContent = message;
el.classList.add("show");
el.style.background = isError ? "#7a271a" : "#11343b";
setTimeout(() => {
el.classList.remove("show");
}, 2800);
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatBytes(bytes) {
if (bytes === null || bytes === undefined) {
return "-";
}
const units = ["B", "KB", "MB", "GB"];
let value = Number(bytes);
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value = value / 1024;
idx += 1;
}
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
}
function formatTime(value) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
return date.toLocaleString("zh-CN", { hour12: false });
}
function sortByTimeDesc(left, right) {
const l = left ? new Date(left).getTime() : 0;
const r = right ? new Date(right).getTime() : 0;
return r - l;
}

View File

@@ -0,0 +1,159 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SVN 日志工作台</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar" aria-label="主导航">
<h1>SVN 工作台</h1>
<nav>
<button class="nav-item active" data-view="dashboard">工作台</button>
<button class="nav-item" data-view="svn">SVN 日志抓取</button>
<button class="nav-item" data-view="ai">AI 工作量分析</button>
<button class="nav-item" data-view="history">任务历史</button>
<button class="nav-item" data-view="settings">系统设置</button>
</nav>
</aside>
<main class="main" id="main">
<header class="main-header">
<h2 id="view-title">工作台</h2>
<p id="view-desc">查看系统状态与最近产物</p>
</header>
<section class="view active" id="view-dashboard" aria-live="polite">
<div class="grid cols-4" id="stats-cards">
<article class="card stat">
<h3>任务总数</h3>
<p id="stat-total">0</p>
</article>
<article class="card stat">
<h3>执行中</h3>
<p id="stat-running">0</p>
</article>
<article class="card stat">
<h3>失败任务</h3>
<p id="stat-failed">0</p>
</article>
<article class="card stat">
<h3>系统状态</h3>
<p id="stat-health">-</p>
</article>
</div>
<article class="card" id="health-card">
<h3>健康检查</h3>
<p class="muted" id="health-details">加载中...</p>
</article>
<div class="grid cols-2">
<article class="card">
<h3>最近任务</h3>
<ul id="recent-tasks" class="list"></ul>
</article>
<article class="card">
<h3>最近文件</h3>
<ul id="recent-files" class="list"></ul>
</article>
</div>
</section>
<section class="view" id="view-svn">
<article class="card form-card">
<h3>SVN 抓取参数</h3>
<form id="svn-form" class="form-grid">
<label>预置项目
<select name="presetId" id="svn-preset-select" aria-label="预置 SVN 项目"></select>
</label>
<label>项目名<input name="projectName" placeholder="如PRS-7050"></label>
<label>SVN 地址<input required name="url" placeholder="https://..." aria-label="SVN 地址"></label>
<label>账号<input required name="username" placeholder="请输入账号"></label>
<label>密码<input required type="password" name="password" placeholder="请输入密码"></label>
<label>开始版本号<input name="startRevision" inputmode="numeric" placeholder="默认最新"></label>
<label>结束版本号<input name="endRevision" inputmode="numeric" placeholder="默认最新"></label>
<label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤"></label>
<div class="actions span-2">
<button type="button" id="btn-test-connection">测试连接</button>
<button type="submit" id="btn-svn-run" class="primary">开始抓取并导出</button>
</div>
</form>
</article>
</section>
<section class="view" id="view-ai">
<article class="card form-card">
<h3>AI 分析参数</h3>
<form id="ai-form" class="form-grid">
<label class="span-2">选择 Markdown 输入文件</label>
<div class="span-2 file-picker" id="md-file-picker" role="group" aria-label="Markdown 文件选择"></div>
<label>工作周期<input name="period" placeholder="例如 2026年03月"></label>
<label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label>
<label class="span-2">临时 API Key可选<input type="password" name="apiKey" placeholder="优先使用设置页或环境变量"></label>
<div class="actions span-2">
<button type="submit" id="btn-ai-run" class="primary">开始 AI 分析并导出 Excel</button>
</div>
</form>
</article>
</section>
<section class="view" id="view-history">
<article class="card">
<h3>任务列表</h3>
<div class="history-toolbar" id="history-toolbar">
<select id="task-filter-status" aria-label="状态筛选">
<option value="">全部状态</option>
<option value="PENDING">PENDING</option>
<option value="RUNNING">RUNNING</option>
<option value="SUCCESS">SUCCESS</option>
<option value="FAILED">FAILED</option>
<option value="CANCELLED">CANCELLED</option>
</select>
<select id="task-filter-type" aria-label="类型筛选">
<option value="">全部类型</option>
<option value="SVN_FETCH">SVN_FETCH</option>
<option value="AI_ANALYZE">AI_ANALYZE</option>
</select>
<input id="task-filter-keyword" placeholder="搜索任务ID/信息" aria-label="关键词搜索">
<button id="btn-task-filter" type="button">查询</button>
</div>
<div id="task-table" class="table-wrap"></div>
<div class="pager" id="task-pager"></div>
</article>
<article class="card">
<h3>输出文件</h3>
<div id="file-table" class="table-wrap"></div>
</article>
</section>
<section class="view" id="view-settings">
<article class="card form-card">
<h3>系统设置</h3>
<form id="settings-form" class="form-grid">
<label class="span-2">DeepSeek API Key
<input type="password" name="apiKey" placeholder="设置后将保存在当前进程内存">
</label>
<label class="span-2">默认 SVN 项目
<select name="defaultSvnPresetId" id="settings-default-preset"></select>
</label>
<label class="span-2">输出目录
<input name="outputDir" placeholder="默认 outputs">
</label>
<div class="actions span-2">
<button type="submit" class="primary">保存设置</button>
</div>
</form>
<p id="settings-state" class="muted"></p>
</article>
</section>
<section class="toast" id="toast" aria-live="assertive"></section>
</main>
</div>
<script src="/app.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,408 @@
:root {
--bg: #eef2f5;
--panel: #ffffff;
--text: #122126;
--muted: #4b5f66;
--primary: #0f766e;
--primary-soft: #d1f0eb;
--danger: #b42318;
--warning: #b54708;
--success: #067647;
--border: #d6e0e4;
--shadow: 0 10px 24px rgba(12, 41, 49, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: radial-gradient(circle at top right, #dff4ef 0%, var(--bg) 42%, #edf1f7 100%);
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 260px 1fr;
}
.sidebar {
border-right: 1px solid var(--border);
background: linear-gradient(180deg, #0d645d 0%, #13454f 100%);
color: #f8fffd;
padding: 24px 18px;
position: sticky;
top: 0;
height: 100vh;
}
.sidebar h1 {
font-size: 22px;
margin: 0 0 20px;
letter-spacing: 0.4px;
}
.sidebar nav {
display: flex;
flex-direction: column;
gap: 8px;
}
.nav-item {
border: 1px solid transparent;
background: transparent;
color: #ecf8f5;
text-align: left;
font-size: 16px;
line-height: 1.5;
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.nav-item:hover,
.nav-item:focus-visible {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.25);
outline: 2px solid rgba(255, 255, 255, 0.4);
outline-offset: 2px;
}
.nav-item.active {
background: #effaf7;
border-color: #bbebe2;
color: #114549;
font-weight: 700;
}
.main {
padding: 24px;
}
.main-header {
margin-bottom: 18px;
}
.main-header h2 {
margin: 0;
font-size: 28px;
}
.main-header p {
margin: 6px 0 0;
color: var(--muted);
}
.view {
display: none;
}
.view.active {
display: block;
}
.grid {
display: grid;
gap: 16px;
}
.grid.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 16px;
}
.grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 16px;
}
.grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
padding: 16px;
}
.card h3 {
margin-top: 0;
margin-bottom: 12px;
}
.stat p {
font-size: 40px;
margin: 0;
font-weight: 700;
color: var(--primary);
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.list li {
border-bottom: 1px solid #edf2f4;
padding: 10px 0;
font-size: 15px;
line-height: 1.5;
}
.list li:last-child {
border-bottom: none;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
label {
display: block;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
}
input,
select,
button {
font: inherit;
}
input {
width: 100%;
margin-top: 6px;
border: 1px solid #b6c5ca;
border-radius: 10px;
padding: 10px 12px;
min-height: 44px;
background: #fff;
}
select {
width: 100%;
margin-top: 6px;
border: 1px solid #b6c5ca;
border-radius: 10px;
padding: 10px 12px;
min-height: 44px;
background: #fff;
}
input:focus-visible {
outline: 2px solid #76b8ad;
outline-offset: 1px;
border-color: #4fa494;
}
select:focus-visible {
outline: 2px solid #76b8ad;
outline-offset: 1px;
border-color: #4fa494;
}
.span-2 {
grid-column: span 2;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
min-height: 44px;
border-radius: 10px;
border: 1px solid #a9bbc1;
background: #f4f8fa;
padding: 0 16px;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover,
button:focus-visible {
background: #e4edf1;
outline: 2px solid #b7cad2;
outline-offset: 2px;
}
button.primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
button.primary:hover,
button.primary:focus-visible {
background: #0c5f59;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.file-picker {
border: 1px solid #c7d6db;
border-radius: 10px;
background: #f9fbfc;
padding: 8px;
max-height: 220px;
overflow: auto;
}
.file-picker label {
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
border-radius: 8px;
font-weight: 500;
}
.file-picker label:hover {
background: #ecf4f7;
}
.table-wrap {
overflow-x: auto;
}
.history-toolbar {
display: grid;
grid-template-columns: 180px 180px minmax(220px, 1fr) 120px;
gap: 10px;
margin-bottom: 12px;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
color: var(--muted);
font-size: 14px;
}
.pager .pager-actions {
display: flex;
gap: 8px;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
th,
td {
padding: 10px 8px;
border-bottom: 1px solid #e8eef0;
text-align: left;
font-size: 14px;
vertical-align: top;
}
.tag {
display: inline-block;
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
font-weight: 700;
}
.tag.SUCCESS {
background: #d1fadf;
color: var(--success);
}
.tag.RUNNING,
.tag.PENDING {
background: #fef0c7;
color: var(--warning);
}
.tag.FAILED {
background: #fee4e2;
color: var(--danger);
}
.tag.CANCELLED {
background: #e4e7ec;
color: #344054;
}
.muted {
color: var(--muted);
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
border-radius: 10px;
padding: 12px 14px;
background: #11343b;
color: #fff;
min-width: 240px;
max-width: 380px;
display: none;
box-shadow: var(--shadow);
}
.toast.show {
display: block;
}
@media (max-width: 1024px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
.sidebar nav {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.cols-3,
.grid.cols-4,
.grid.cols-2,
.form-grid {
grid-template-columns: 1fr;
}
.history-toolbar {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: span 1;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,36 @@
package com.svnlog.web.service;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class AiInputValidatorTest {
@Test
public void shouldRejectEmptyListWhenValidatingInputFiles() {
AiInputValidator validator = new AiInputValidator();
Assertions.assertThrows(IllegalArgumentException.class, () -> validator.validate(Collections.<Path>emptyList()));
}
@Test
public void shouldRejectNonMarkdownFileWhenValidatingInputFiles() throws Exception {
AiInputValidator validator = new AiInputValidator();
Path temp = Files.createTempFile("ai-input", ".txt");
Files.write(temp, "abc".getBytes(StandardCharsets.UTF_8));
Assertions.assertThrows(IllegalArgumentException.class, () -> validator.validate(Arrays.asList(temp)));
}
@Test
public void shouldAcceptSmallMarkdownFilesWhenValidatingInputFiles() throws Exception {
AiInputValidator validator = new AiInputValidator();
Path temp = Files.createTempFile("ai-input", ".md");
Files.write(temp, "# title".getBytes(StandardCharsets.UTF_8));
validator.validate(Arrays.asList(temp));
Assertions.assertTrue(true);
}
}

View File

@@ -0,0 +1,36 @@
package com.svnlog.web.service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
public class HealthServiceTest {
@Test
public void shouldReturnDetailedHealthWhenDependenciesAvailable() throws Exception {
OutputFileService outputFileService = Mockito.mock(OutputFileService.class);
SettingsService settingsService = Mockito.mock(SettingsService.class);
TaskService taskService = Mockito.mock(TaskService.class);
Path outputDir = Files.createTempDirectory("health-service-test");
Mockito.when(outputFileService.getOutputRoot()).thenReturn(outputDir);
Map<String, Object> settings = new HashMap<String, Object>();
settings.put("apiKeyConfigured", true);
Mockito.when(settingsService.getSettings()).thenReturn(settings);
Mockito.when(taskService.getTasks()).thenReturn(new java.util.ArrayList<>());
HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
Map<String, Object> details = healthService.detailedHealth();
Assertions.assertEquals("ok", details.get("status"));
Assertions.assertEquals(true, details.get("outputDirWritable"));
Assertions.assertEquals(true, details.get("apiKeyConfigured"));
Assertions.assertEquals(0, details.get("taskTotal"));
}
}

View File

@@ -0,0 +1,39 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class RetrySupportTest {
@Test
public void shouldRetryAndSucceedWhenExceptionIsRetryable() throws Exception {
RetrySupport retrySupport = new RetrySupport();
AtomicInteger attempts = new AtomicInteger(0);
String result = retrySupport.execute(() -> {
if (attempts.incrementAndGet() < 3) {
throw new IOException("temporary error");
}
return "ok";
}, 3, 1L);
Assertions.assertEquals("ok", result);
Assertions.assertEquals(3, attempts.get());
}
@Test
public void shouldFailImmediatelyWhenExceptionIsNotRetryable() {
RetrySupport retrySupport = new RetrySupport();
AtomicInteger attempts = new AtomicInteger(0);
Assertions.assertThrows(IllegalArgumentException.class, () -> retrySupport.execute(() -> {
attempts.incrementAndGet();
throw new IllegalArgumentException("bad request");
}, 3, 1L));
Assertions.assertEquals(1, attempts.get());
}
}

View File

@@ -0,0 +1,43 @@
package com.svnlog.web.service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskStatus;
public class TaskPersistenceServiceTest {
@Test
public void shouldSaveAndLoadTaskHistoryWhenStoreFileExists() throws Exception {
TaskPersistenceService service = new TaskPersistenceService();
Path tempDir = Files.createTempDirectory("task-persistence-test");
Path storePath = tempDir.resolve("task-history.json");
TaskInfo task = new TaskInfo();
task.setTaskId("task-1");
task.setType("SVN_FETCH");
task.setStatus(TaskStatus.SUCCESS);
task.setProgress(100);
task.setMessage("ok");
task.setError("");
task.setCreatedAt(Instant.parse("2026-03-01T10:00:00Z"));
task.setUpdatedAt(Instant.parse("2026-03-01T10:05:00Z"));
task.getFiles().add("md/a.md");
service.save(storePath, Arrays.asList(task));
List<TaskInfo> loaded = service.load(storePath);
Assertions.assertEquals(1, loaded.size());
Assertions.assertEquals("task-1", loaded.get(0).getTaskId());
Assertions.assertEquals(TaskStatus.SUCCESS, loaded.get(0).getStatus());
Assertions.assertEquals(1, loaded.get(0).getFiles().size());
Assertions.assertEquals("md/a.md", loaded.get(0).getFiles().get(0));
}
}

View File

@@ -0,0 +1,43 @@
package com.svnlog.web.service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskStatus;
public class TaskServiceCancelTest {
@Test
public void shouldCancelRunningTaskWhenCancelEndpointInvoked() throws Exception {
TaskPersistenceService persistenceService = Mockito.mock(TaskPersistenceService.class);
OutputFileService outputFileService = Mockito.mock(OutputFileService.class);
Path tempDir = Files.createTempDirectory("task-cancel-test");
Mockito.when(outputFileService.getOutputRoot()).thenReturn(tempDir);
Mockito.when(persistenceService.load(tempDir.resolve("task-history.json"))).thenReturn(new ArrayList<TaskInfo>());
TaskService taskService = new TaskService(persistenceService, outputFileService);
String taskId = taskService.submit("SVN_FETCH", context -> {
for (int i = 0; i < 50; i++) {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException("cancelled");
}
Thread.sleep(20);
}
return null;
});
boolean cancelled = taskService.cancelTask(taskId);
Assertions.assertTrue(cancelled);
TaskInfo task = taskService.getTask(taskId);
Assertions.assertNotNull(task);
Assertions.assertEquals(TaskStatus.CANCELLED, task.getStatus());
}
}

View File

@@ -0,0 +1,60 @@
package com.svnlog.web.service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Arrays;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskPageResult;
import com.svnlog.web.model.TaskStatus;
public class TaskServiceQueryTest {
@Test
public void shouldFilterAndPaginateTasksWhenQuerying() throws Exception {
TaskPersistenceService persistenceService = Mockito.mock(TaskPersistenceService.class);
OutputFileService outputFileService = Mockito.mock(OutputFileService.class);
Path tempDir = Files.createTempDirectory("task-query-test");
Mockito.when(outputFileService.getOutputRoot()).thenReturn(tempDir);
TaskInfo t1 = buildTask("1", "SVN_FETCH", TaskStatus.SUCCESS, "抓取完成", Instant.parse("2026-03-01T10:00:00Z"));
TaskInfo t2 = buildTask("2", "AI_ANALYZE", TaskStatus.FAILED, "AI失败", Instant.parse("2026-03-01T10:10:00Z"));
TaskInfo t3 = buildTask("3", "SVN_FETCH", TaskStatus.SUCCESS, "导出成功", Instant.parse("2026-03-01T10:20:00Z"));
Mockito.when(persistenceService.load(tempDir.resolve("task-history.json")))
.thenReturn(Arrays.asList(t1, t2, t3));
TaskService taskService = new TaskService(persistenceService, outputFileService);
TaskPageResult page1 = taskService.queryTasks("SUCCESS", "SVN_FETCH", "", 1, 1);
Assertions.assertEquals(2, page1.getTotal());
Assertions.assertEquals(1, page1.getItems().size());
Assertions.assertEquals("3", page1.getItems().get(0).getTaskId());
TaskPageResult page2 = taskService.queryTasks("SUCCESS", "SVN_FETCH", "", 2, 1);
Assertions.assertEquals(1, page2.getItems().size());
Assertions.assertEquals("1", page2.getItems().get(0).getTaskId());
TaskPageResult keyword = taskService.queryTasks("", "", "ai", 1, 10);
Assertions.assertEquals(1, keyword.getTotal());
Assertions.assertEquals("2", keyword.getItems().get(0).getTaskId());
}
private TaskInfo buildTask(String id, String type, TaskStatus status, String message, Instant createdAt) {
TaskInfo task = new TaskInfo();
task.setTaskId(id);
task.setType(type);
task.setStatus(status);
task.setMessage(message);
task.setProgress(status == TaskStatus.SUCCESS ? 100 : 0);
task.setCreatedAt(createdAt);
task.setUpdatedAt(createdAt);
return task;
}
}