Compare commits
4 Commits
d050fadfee
...
bdf6367404
| Author | SHA1 | Date | |
|---|---|---|---|
| bdf6367404 | |||
| e26fb9cebb | |||
| abd375bf64 | |||
| 2dc0f931a2 |
155
AGENTS.md
Normal file
155
AGENTS.md
Normal 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 API(SVN、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
89
docs/README_Web.md
Normal 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
|
||||
@@ -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. 实现序列化接口
|
||||
|
||||
# 兼容性
|
||||
- 保持向后兼容
|
||||
- 更新相关测试用例
|
||||
```
|
||||
|
||||
---
|
||||
@@ -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进程的代码,避免重复关闭造成问题
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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字段
|
||||
- 各新增字段用于存储原始数据,便于后续数据处理与追踪
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
29
pom.xml
@@ -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>
|
||||
|
||||
@@ -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 ""
|
||||
12
src/main/java/com/svnlog/WebApplication.java
Normal file
12
src/main/java/com/svnlog/WebApplication.java
Normal 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);
|
||||
}
|
||||
}
|
||||
191
src/main/java/com/svnlog/web/controller/AppController.java
Normal file
191
src/main/java/com/svnlog/web/controller/AppController.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
47
src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java
Normal file
47
src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java
Normal file
32
src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java
Normal 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;
|
||||
}
|
||||
}
|
||||
39
src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java
Normal file
39
src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java
Normal 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;
|
||||
}
|
||||
}
|
||||
77
src/main/java/com/svnlog/web/dto/SvnFetchRequest.java
Normal file
77
src/main/java/com/svnlog/web/dto/SvnFetchRequest.java
Normal 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;
|
||||
}
|
||||
}
|
||||
34
src/main/java/com/svnlog/web/model/OutputFileInfo.java
Normal file
34
src/main/java/com/svnlog/web/model/OutputFileInfo.java
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/main/java/com/svnlog/web/model/SvnPreset.java
Normal file
41
src/main/java/com/svnlog/web/model/SvnPreset.java
Normal 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;
|
||||
}
|
||||
}
|
||||
86
src/main/java/com/svnlog/web/model/TaskInfo.java
Normal file
86
src/main/java/com/svnlog/web/model/TaskInfo.java
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/main/java/com/svnlog/web/model/TaskPageResult.java
Normal file
44
src/main/java/com/svnlog/web/model/TaskPageResult.java
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/main/java/com/svnlog/web/model/TaskResult.java
Normal file
33
src/main/java/com/svnlog/web/model/TaskResult.java
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/main/java/com/svnlog/web/model/TaskStatus.java
Normal file
9
src/main/java/com/svnlog/web/model/TaskStatus.java
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.svnlog.web.model;
|
||||
|
||||
public enum TaskStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
CANCELLED
|
||||
}
|
||||
35
src/main/java/com/svnlog/web/service/AiInputValidator.java
Normal file
35
src/main/java/com/svnlog/web/service/AiInputValidator.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
366
src/main/java/com/svnlog/web/service/AiWorkflowService.java
Normal file
366
src/main/java/com/svnlog/web/service/AiWorkflowService.java
Normal 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_.-]", "_");
|
||||
}
|
||||
}
|
||||
81
src/main/java/com/svnlog/web/service/HealthService.java
Normal file
81
src/main/java/com/svnlog/web/service/HealthService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/main/java/com/svnlog/web/service/OutputFileService.java
Normal file
80
src/main/java/com/svnlog/web/service/OutputFileService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/main/java/com/svnlog/web/service/RetrySupport.java
Normal file
53
src/main/java/com/svnlog/web/service/RetrySupport.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/main/java/com/svnlog/web/service/SettingsService.java
Normal file
67
src/main/java/com/svnlog/web/service/SettingsService.java
Normal 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();
|
||||
}
|
||||
}
|
||||
55
src/main/java/com/svnlog/web/service/SvnPresetService.java
Normal file
55
src/main/java/com/svnlog/web/service/SvnPresetService.java
Normal 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();
|
||||
}
|
||||
}
|
||||
107
src/main/java/com/svnlog/web/service/SvnWorkflowService.java
Normal file
107
src/main/java/com/svnlog/web/service/SvnWorkflowService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
24
src/main/java/com/svnlog/web/service/TaskContext.java
Normal file
24
src/main/java/com/svnlog/web/service/TaskContext.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/main/java/com/svnlog/web/service/TaskPersistenceService.java
Normal file
138
src/main/java/com/svnlog/web/service/TaskPersistenceService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
233
src/main/java/com/svnlog/web/service/TaskService.java
Normal file
233
src/main/java/com/svnlog/web/service/TaskService.java
Normal 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();
|
||||
}
|
||||
}
|
||||
602
src/main/resources/static/app.js
Normal file
602
src/main/resources/static/app.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
159
src/main/resources/static/index.html
Normal file
159
src/main/resources/static/index.html
Normal 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>
|
||||
408
src/main/resources/static/styles.css
Normal file
408
src/main/resources/static/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
36
src/test/java/com/svnlog/web/service/HealthServiceTest.java
Normal file
36
src/test/java/com/svnlog/web/service/HealthServiceTest.java
Normal 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"));
|
||||
}
|
||||
}
|
||||
39
src/test/java/com/svnlog/web/service/RetrySupportTest.java
Normal file
39
src/test/java/com/svnlog/web/service/RetrySupportTest.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user