feat(web): unify web entry, preset config, SSE streaming and dual-pane live logs
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -16,6 +16,10 @@ buildNumber.properties
|
|||||||
*.iml
|
*.iml
|
||||||
*.ipr
|
*.ipr
|
||||||
|
|
||||||
|
# Agent / local assistant artifacts
|
||||||
|
.claude/
|
||||||
|
.codex
|
||||||
|
|
||||||
# Compiled class files
|
# Compiled class files
|
||||||
*.class
|
*.class
|
||||||
|
|
||||||
@@ -34,6 +38,7 @@ buildNumber.properties
|
|||||||
# Generated files
|
# Generated files
|
||||||
md/
|
md/
|
||||||
*.xlsx
|
*.xlsx
|
||||||
|
outputs/
|
||||||
|
|
||||||
# OS generated files
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@@ -1,8 +0,0 @@
|
|||||||
# 默认忽略的文件
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# 基于编辑器的 HTTP 客户端请求
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
13
.idea/compiler.xml
generated
13
.idea/compiler.xml
generated
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="CompilerConfiguration">
|
|
||||||
<annotationProcessing>
|
|
||||||
<profile name="Maven default annotation processors profile" enabled="true">
|
|
||||||
<sourceOutputDir name="target/generated-sources/annotations" />
|
|
||||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
|
||||||
<outputRelativeToContentRoot value="true" />
|
|
||||||
<module name="svn-log-tool" />
|
|
||||||
</profile>
|
|
||||||
</annotationProcessing>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
9
.idea/encodings.xml
generated
9
.idea/encodings.xml
generated
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding">
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/svn-log-tool/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/svn-log-tool/src/main/resources" charset="UTF-8" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
25
.idea/jarRepositories.xml
generated
25
.idea/jarRepositories.xml
generated
@@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="RemoteRepositoriesConfiguration">
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="central" />
|
|
||||||
<option name="name" value="Central Repository" />
|
|
||||||
<option name="url" value="https://repo.maven.apache.org/maven2" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="central" />
|
|
||||||
<option name="name" value="Maven Central repository" />
|
|
||||||
<option name="url" value="https://repo1.maven.org/maven2" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="jboss.community" />
|
|
||||||
<option name="name" value="JBoss Community repository" />
|
|
||||||
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="central" />
|
|
||||||
<option name="name" value="Central Repository" />
|
|
||||||
<option name="url" value="https://maven.aliyun.com/repository/central" />
|
|
||||||
</remote-repository>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
27
.idea/misc.xml
generated
27
.idea/misc.xml
generated
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
|
||||||
<component name="KubernetesApiProvider"><![CDATA[{}]]></component>
|
|
||||||
<component name="MavenProjectsManager">
|
|
||||||
<option name="originalFiles">
|
|
||||||
<list>
|
|
||||||
<option value="$PROJECT_DIR$/svn-log-tool/pom.xml" />
|
|
||||||
<option value="$PROJECT_DIR$/pom.xml" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="ProjectInspectionProfilesVisibleTreeState">
|
|
||||||
<entry key="Project Default">
|
|
||||||
<profile-state>
|
|
||||||
<selected-state>
|
|
||||||
<State>
|
|
||||||
<id>用户定义</id>
|
|
||||||
</State>
|
|
||||||
</selected-state>
|
|
||||||
</profile-state>
|
|
||||||
</entry>
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="temurin-1.8" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
9
.idea/日志.iml
generated
9
.idea/日志.iml
generated
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
21
AGENTS.md
21
AGENTS.md
@@ -5,12 +5,11 @@
|
|||||||
## 1. 项目概览
|
## 1. 项目概览
|
||||||
- 语言与构建:Java 8 + Maven(`pom.xml`)。
|
- 语言与构建:Java 8 + Maven(`pom.xml`)。
|
||||||
- 打包产物:可执行 fat jar(`jar-with-dependencies`)。
|
- 打包产物:可执行 fat jar(`jar-with-dependencies`)。
|
||||||
- 主入口:`com.svnlog.Main`(CLI)。
|
- 统一入口:`com.svnlog.web.WebApplication`(前后端一体,静态页面 + REST API)。
|
||||||
- Web 入口:`com.svnlog.WebApplication`(前后端一体,静态页面 + REST API)。
|
|
||||||
- 其他入口:`com.svnlog.DeepSeekLogProcessor`、`com.svnlog.ExcelAnalyzer`。
|
|
||||||
- 核心目录:
|
- 核心目录:
|
||||||
- `src/main/java/com/svnlog/`
|
- `src/main/java/com/svnlog/`
|
||||||
- `docs/`
|
- `docs/`
|
||||||
|
- SVN 预设地址:`src/main/resources/application.properties`(`svn.presets[*]`)
|
||||||
|
|
||||||
## 2. 常用命令(Build / Lint / Test / Run)
|
## 2. 常用命令(Build / Lint / Test / Run)
|
||||||
以下命令默认在仓库根目录执行。
|
以下命令默认在仓库根目录执行。
|
||||||
@@ -36,29 +35,19 @@
|
|||||||
- 说明:当前 `src/test/java` 为空;新增测试时采用 Surefire 默认约定。
|
- 说明:当前 `src/test/java` 为空;新增测试时采用 Surefire 默认约定。
|
||||||
|
|
||||||
### 2.4 Run
|
### 2.4 Run
|
||||||
- 运行主程序(SVN 日志抓取):
|
|
||||||
- `java -jar target/svn-log-tool-1.0.0-jar-with-dependencies.jar`
|
|
||||||
- 运行 Web 工作台(推荐):
|
- 运行 Web 工作台(推荐):
|
||||||
- `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.WebApplication`
|
- `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication`
|
||||||
- 启动后访问:`http://localhost:8080`
|
- 启动后访问:`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. 代码结构与职责边界
|
## 3. 代码结构与职责边界
|
||||||
- `Main.java`:CLI 交互、读取输入、调用 `SVNLogFetcher`、输出 Markdown。
|
|
||||||
- `SVNLogFetcher.java`:SVN 连接、版本区间处理、日志抓取、用户过滤。
|
- `SVNLogFetcher.java`:SVN 连接、版本区间处理、日志抓取、用户过滤。
|
||||||
- `LogEntry.java`:日志数据模型(POJO)。
|
- `LogEntry.java`:日志数据模型(POJO)。
|
||||||
- `DeepSeekLogProcessor.java`:读取 Markdown、调用 DeepSeek API、生成 Excel。
|
|
||||||
- `ExcelAnalyzer.java`:本地临时分析工具,偏实验性质。
|
|
||||||
- `web/controller/*`:REST API(SVN、AI、任务、文件、设置)。
|
- `web/controller/*`:REST API(SVN、AI、任务、文件、设置)。
|
||||||
- `web/service/*`:异步任务与业务编排(SVN 抓取、AI 分析、输出目录管理)。
|
- `web/service/*`:异步任务与业务编排(SVN 抓取、AI 分析、输出目录管理)。
|
||||||
- `src/main/resources/static/*`:Web 前端页面与交互脚本。
|
- `src/main/resources/static/*`:Web 前端页面与交互脚本。
|
||||||
- 变更原则:
|
- 变更原则:
|
||||||
- 抓取逻辑改在 `SVNLogFetcher`。
|
- 抓取逻辑改在 `SVNLogFetcher`。
|
||||||
- 交互逻辑改在 `Main`。
|
- AI/Excel 逻辑改在 `web/service/AiWorkflowService`。
|
||||||
- AI/Excel 逻辑改在 `DeepSeekLogProcessor`。
|
|
||||||
- 不把多种职责混入同一方法。
|
- 不把多种职责混入同一方法。
|
||||||
|
|
||||||
## 4. 代码风格规范(必读)
|
## 4. 代码风格规范(必读)
|
||||||
@@ -112,7 +101,7 @@
|
|||||||
|
|
||||||
## 5. 安全与敏感信息
|
## 5. 安全与敏感信息
|
||||||
- 严禁提交真实密钥、口令、Token、内网敏感地址。
|
- 严禁提交真实密钥、口令、Token、内网敏感地址。
|
||||||
- `DeepSeekLogProcessor` 存在硬编码 API Key 风险;新增改动时应:
|
- Web 端 AI 分析涉及 API Key;新增改动时应:
|
||||||
- 优先从环境变量读取(如 `DEEPSEEK_API_KEY`)。
|
- 优先从环境变量读取(如 `DEEPSEEK_API_KEY`)。
|
||||||
- 回退到交互输入。
|
- 回退到交互输入。
|
||||||
- 不把真实值写入源码或日志。
|
- 不把真实值写入源码或日志。
|
||||||
|
|||||||
28
README.md
Normal file
28
README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# svn-log-tool
|
||||||
|
|
||||||
|
SVN 日志抓取与 AI 工作量分析工具,统一使用 Web 工作台入口。
|
||||||
|
|
||||||
|
## 入口
|
||||||
|
|
||||||
|
- `com.svnlog.web.WebApplication`
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编译
|
||||||
|
mvn clean compile
|
||||||
|
|
||||||
|
# 打包
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
|
# 启动 Web
|
||||||
|
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码结构
|
||||||
|
|
||||||
|
- `com.svnlog.web`:Web 入口、控制器、DTO、服务
|
||||||
|
- `com.svnlog.core.svn`:SVN 连接、日志抓取模型
|
||||||
|
- `com.svnlog.core.report`:Markdown 报告输出能力
|
||||||
|
|
||||||
|
更多运行和功能说明见 `docs/`。
|
||||||
Binary file not shown.
@@ -1,121 +1,32 @@
|
|||||||
# SVN日志工作量统计工具(DeepSeek版)
|
# SVN 日志 AI 分析(Web)
|
||||||
|
|
||||||
## 功能说明
|
## 功能说明
|
||||||
|
|
||||||
这个工具可以根据SVN日志的markdown文件,调用DeepSeek API分析日志内容,并生成符合格式要求的工作量统计Excel文件。
|
通过 Web 工作台上传/选择 Markdown 日志,调用 DeepSeek API 分析并生成 Excel 工作量统计文件。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:`http://localhost:8080`
|
||||||
|
|
||||||
## 使用步骤
|
## 使用步骤
|
||||||
|
|
||||||
### 1. 准备SVN日志markdown文件
|
1. 在「SVN 日志抓取」先生成 `.md` 文件
|
||||||
|
2. 在「系统设置」配置 DeepSeek API Key(或使用环境变量 `DEEPSEEK_API_KEY`)
|
||||||
|
3. 在「AI 工作量分析」选择 `.md` 文件并发起分析
|
||||||
|
4. 在「任务历史」或「产物列表」下载 `.xlsx`
|
||||||
|
|
||||||
使用原有的SVN日志工具生成markdown文件:
|
## API Key 读取优先级
|
||||||
```bash
|
|
||||||
java -jar svn-log-tool-1.0.0-jar-with-dependencies.jar
|
|
||||||
```
|
|
||||||
|
|
||||||
按照提示输入SVN仓库地址、账号、密码等信息,生成markdown格式的日志文件。
|
1. 请求中的 `apiKey`
|
||||||
|
2. 设置页保存的运行时 `apiKey`
|
||||||
### 2. 运行DeepSeek日志处理工具
|
3. 环境变量 `DEEPSEEK_API_KEY`
|
||||||
|
|
||||||
```bash
|
|
||||||
java -cp target/svn-log-tool-1.0.0-jar-with-dependencies.jar com.svnlog.DeepSeekLogProcessor
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用Maven运行:
|
|
||||||
```bash
|
|
||||||
mvn exec:java -Dexec.mainClass="com.svnlog.DeepSeekLogProcessor"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 按照提示输入信息
|
|
||||||
|
|
||||||
程序会依次提示输入:
|
|
||||||
- **markdown日志文件路径**:可以直接回车使用当前目录下最新的`svn_log_*.md`文件
|
|
||||||
- **DeepSeek API Key**:请提供有效的DeepSeek API Key(也可以直接在代码中修改`API_KEY`常量)
|
|
||||||
- **输出Excel文件名**:可以直接回车使用默认文件名(格式:`YYYYMM工作量统计.xlsx`)
|
|
||||||
|
|
||||||
### 4. 等待处理完成
|
|
||||||
|
|
||||||
程序会自动:
|
|
||||||
1. 读取markdown日志文件
|
|
||||||
2. 调用DeepSeek API分析日志内容
|
|
||||||
3. 根据分析结果生成Excel文件
|
|
||||||
|
|
||||||
生成的Excel文件格式与`202512工作量统计_刘靖.xlsx`保持一致。
|
|
||||||
|
|
||||||
## Excel文件格式说明
|
|
||||||
|
|
||||||
生成的Excel文件包含以下列:
|
|
||||||
- 序号
|
|
||||||
- 所属班组
|
|
||||||
- 技术对接
|
|
||||||
- 开发人员
|
|
||||||
- 工作周期
|
|
||||||
- 开发项目名称
|
|
||||||
- 具体工作内容
|
|
||||||
- 空列(4个)
|
|
||||||
|
|
||||||
## DeepSeek API配置
|
|
||||||
|
|
||||||
在`DeepSeekLogProcessor.java`中,可以修改以下配置:
|
|
||||||
|
|
||||||
```java
|
|
||||||
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
|
|
||||||
private static final String API_KEY = "YOUR_DEEPSEEK_API_KEY"; // 请替换为实际的API Key
|
|
||||||
```
|
|
||||||
|
|
||||||
## 提示词说明
|
|
||||||
|
|
||||||
工具会向DeepSeek发送以下提示词,要求AI以JSON格式返回工作量统计:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"team": "所属班组",
|
|
||||||
"contact": "技术对接人",
|
|
||||||
"developer": "开发人员",
|
|
||||||
"period": "工作周期 (例如: 2025年12月)",
|
|
||||||
"records": [
|
|
||||||
{
|
|
||||||
"sequence": 1,
|
|
||||||
"project": "项目名称",
|
|
||||||
"content": "具体工作内容"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
1. **API Key安全**:请妥善保管您的DeepSeek API Key,不要将其提交到代码仓库中
|
- 不要在源码和日志中写入真实密钥
|
||||||
2. **网络连接**:需要能够访问DeepSeek API服务器
|
- 需要可访问 DeepSeek API 的网络环境
|
||||||
3. **日志格式**:markdown文件需要由SVN日志工具生成,包含完整的日志信息
|
- 接口调用可能产生费用,建议控制调用频率
|
||||||
4. **成本控制**:DeepSeek API可能产生费用,请注意控制使用频率
|
|
||||||
|
|
||||||
## 故障排查
|
|
||||||
|
|
||||||
### 编译错误
|
|
||||||
```bash
|
|
||||||
mvn clean package -DskipTests
|
|
||||||
```
|
|
||||||
|
|
||||||
### 运行时找不到主类
|
|
||||||
确保使用正确的jar文件:
|
|
||||||
```bash
|
|
||||||
java -cp target/svn-log-tool-1.0.0-jar-with-dependencies.jar com.svnlog.DeepSeekLogProcessor
|
|
||||||
```
|
|
||||||
|
|
||||||
### API调用失败
|
|
||||||
- 检查API Key是否正确
|
|
||||||
- 检查网络连接是否正常
|
|
||||||
- 检查DeepSeek API服务是否可用
|
|
||||||
|
|
||||||
## 依赖说明
|
|
||||||
|
|
||||||
项目使用以下主要依赖:
|
|
||||||
- SVNKit 1.10.11:SVN操作
|
|
||||||
- Apache POI 5.2.5:Excel文件读写
|
|
||||||
- OkHttp 4.12.0:HTTP客户端
|
|
||||||
- Gson 2.10.1:JSON处理
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
本工具仅供内部使用。
|
|
||||||
|
|||||||
16
docs/README_Migration.md
Normal file
16
docs/README_Migration.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 包结构迁移说明
|
||||||
|
|
||||||
|
## 入口调整
|
||||||
|
|
||||||
|
- 当前只保留 `com.svnlog.web.WebApplication` 作为唯一入口。
|
||||||
|
- 已移除 CLI/AI 独立入口类。
|
||||||
|
|
||||||
|
## 核心类迁移
|
||||||
|
|
||||||
|
- `com.svnlog.SVNLogFetcher` -> `com.svnlog.core.svn.SVNLogFetcher`
|
||||||
|
- `com.svnlog.LogEntry` -> `com.svnlog.core.svn.LogEntry`
|
||||||
|
- `com.svnlog.TrustAllSSLContext` -> `com.svnlog.core.svn.TrustAllSSLContext`
|
||||||
|
|
||||||
|
## 公共能力
|
||||||
|
|
||||||
|
- `com.svnlog.core.report.MarkdownReportWriter`:统一 Markdown 输出逻辑。
|
||||||
@@ -11,12 +11,14 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持
|
|||||||
5. 下载输出文件、配置 API Key 与输出目录
|
5. 下载输出文件、配置 API Key 与输出目录
|
||||||
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
|
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
|
||||||
|
|
||||||
|
批量抓取策略:多个项目按顺序执行(前一个项目完成后才开始下一个)。
|
||||||
|
|
||||||
## 启动方式
|
## 启动方式
|
||||||
|
|
||||||
在仓库根目录执行:
|
在仓库根目录执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.WebApplication
|
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问:
|
启动后访问:
|
||||||
@@ -48,6 +50,12 @@ http://localhost:8080
|
|||||||
|
|
||||||
建议在生产环境优先使用环境变量,避免敏感信息暴露。
|
建议在生产环境优先使用环境变量,避免敏感信息暴露。
|
||||||
|
|
||||||
|
## SVN 预设来源与调用方式
|
||||||
|
|
||||||
|
- SVN 地址统一维护在 `application.properties` 的 `svn.presets[*]` 中。
|
||||||
|
- 前端不再传 SVN URL,业务接口统一传 `presetId`,后端按 `presetId` 解析地址。
|
||||||
|
- `GET /api/svn/presets` 仅返回 `id` 与 `name`(不返回 `url`)。
|
||||||
|
|
||||||
## 主要 API
|
## 主要 API
|
||||||
|
|
||||||
- `POST /api/svn/test-connection`
|
- `POST /api/svn/test-connection`
|
||||||
@@ -57,6 +65,7 @@ http://localhost:8080
|
|||||||
- `GET /api/tasks`
|
- `GET /api/tasks`
|
||||||
- `GET /api/tasks/query?status=&type=&keyword=&page=1&size=10`
|
- `GET /api/tasks/query?status=&type=&keyword=&page=1&size=10`
|
||||||
- `GET /api/tasks/{taskId}`
|
- `GET /api/tasks/{taskId}`
|
||||||
|
- `GET /api/tasks/{taskId}/stream`(SSE 实时输出)
|
||||||
- `POST /api/tasks/{taskId}/cancel`
|
- `POST /api/tasks/{taskId}/cancel`
|
||||||
- `GET /api/health`
|
- `GET /api/health`
|
||||||
- `GET /api/health/details`
|
- `GET /api/health/details`
|
||||||
|
|||||||
4
pom.xml
4
pom.xml
@@ -95,7 +95,7 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
<archive>
|
<archive>
|
||||||
<manifest>
|
<manifest>
|
||||||
<mainClass>com.svnlog.Main</mainClass>
|
<mainClass>com.svnlog.web.WebApplication</mainClass>
|
||||||
</manifest>
|
</manifest>
|
||||||
</archive>
|
</archive>
|
||||||
</configuration>
|
</configuration>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
</descriptorRefs>
|
</descriptorRefs>
|
||||||
<archive>
|
<archive>
|
||||||
<manifest>
|
<manifest>
|
||||||
<mainClass>com.svnlog.Main</mainClass>
|
<mainClass>com.svnlog.web.WebApplication</mainClass>
|
||||||
</manifest>
|
</manifest>
|
||||||
</archive>
|
</archive>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
@@ -1,605 +0,0 @@
|
|||||||
package com.svnlog;
|
|
||||||
|
|
||||||
import okhttp3.*;
|
|
||||||
import org.apache.poi.ss.usermodel.*;
|
|
||||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
|
||||||
|
|
||||||
import java.io.*;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.text.SimpleDateFormat;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用DeepSeek API处理SVN日志并生成工作量统计Excel
|
|
||||||
*/
|
|
||||||
public class DeepSeekLogProcessor {
|
|
||||||
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
|
|
||||||
private static final String API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7"; // 用户需要替换为实际的API Key
|
|
||||||
private static final OkHttpClient client = new OkHttpClient.Builder()
|
|
||||||
.connectTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.readTimeout(300, java.util.concurrent.TimeUnit.SECONDS) // 5分钟读取超时
|
|
||||||
.writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
try {
|
|
||||||
Scanner scanner = new Scanner(System.in);
|
|
||||||
System.out.println("===========================================");
|
|
||||||
System.out.println(" SVN日志工作量统计工具(DeepSeek版)");
|
|
||||||
System.out.println(" 支持多项目汇总分析");
|
|
||||||
System.out.println("===========================================");
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
// 读取markdown日志文件目录
|
|
||||||
System.out.print("请输入markdown日志文件所在目录路径 (回车使用当前目录): ");
|
|
||||||
String dirPath = scanner.nextLine().trim();
|
|
||||||
|
|
||||||
File dir;
|
|
||||||
if (dirPath.isEmpty()) {
|
|
||||||
dir = new File(".");
|
|
||||||
} else {
|
|
||||||
dir = new File(dirPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dir.exists() || !dir.isDirectory()) {
|
|
||||||
System.err.println("错误: 目录不存在或不是有效目录!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 扫描目录中的所有 .md 文件
|
|
||||||
File[] mdFiles = dir.listFiles((d, name) -> name.endsWith(".md"));
|
|
||||||
if (mdFiles == null || mdFiles.length == 0) {
|
|
||||||
System.err.println("错误: 目录中未找到任何 .md 文件!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("找到 " + mdFiles.length + " 个日志文件:");
|
|
||||||
for (File file : mdFiles) {
|
|
||||||
System.out.println(" - " + file.getName());
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
// 输入工作周期
|
|
||||||
SimpleDateFormat periodSdf = new SimpleDateFormat("yyyy年MM月");
|
|
||||||
String defaultPeriod = periodSdf.format(new Date());
|
|
||||||
System.out.print("请输入工作周期 (例如: 2025年12月,回车使用默认: " + defaultPeriod + "): ");
|
|
||||||
String period = scanner.nextLine().trim();
|
|
||||||
|
|
||||||
if (period.isEmpty()) {
|
|
||||||
period = defaultPeriod;
|
|
||||||
System.out.println("使用默认工作周期: " + period);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取并合并所有markdown文件
|
|
||||||
String combinedContent = readAndCombineMarkdownFiles(mdFiles);
|
|
||||||
System.out.println("成功读取并合并 " + mdFiles.length + " 个日志文件,总长度: " + combinedContent.length() + " 字符");
|
|
||||||
|
|
||||||
// 提示API Key
|
|
||||||
System.out.print("请输入DeepSeek API Key (留空使用代码中预设的): ");
|
|
||||||
String inputApiKey = scanner.nextLine().trim();
|
|
||||||
String apiKey = inputApiKey.isEmpty() ? API_KEY : inputApiKey;
|
|
||||||
|
|
||||||
if (apiKey.equals("YOUR_DEEPSEEK_API_KEY")) {
|
|
||||||
System.err.println("错误: 请提供有效的DeepSeek API Key!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 询问输出文件名
|
|
||||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMM");
|
|
||||||
String defaultOutput = sdf.format(new Date()) + "工作量统计.xlsx";
|
|
||||||
System.out.print("请输入输出Excel文件名 (回车使用默认: " + defaultOutput + "): ");
|
|
||||||
String outputPath = scanner.nextLine().trim();
|
|
||||||
if (outputPath.isEmpty()) {
|
|
||||||
outputPath = defaultOutput;
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println();
|
|
||||||
System.out.println("正在调用DeepSeek API分析日志...");
|
|
||||||
System.out.println("(使用 deepseek-reasoner 模型,推理阶段可能需要数十秒,请耐心等待)");
|
|
||||||
System.out.println("--- 推理过程 ---");
|
|
||||||
|
|
||||||
// 调用DeepSeek API处理日志
|
|
||||||
String prompt = buildPrompt(combinedContent, period);
|
|
||||||
String aiResponse = callDeepSeekAPI(apiKey, prompt);
|
|
||||||
|
|
||||||
if (aiResponse == null) {
|
|
||||||
System.err.println("DeepSeek API调用失败!请检查网络连接和API Key。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aiResponse.isEmpty()) {
|
|
||||||
System.err.println("DeepSeek API返回空响应!请重试或联系技术支持。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("DeepSeek分析完成,正在生成Excel...");
|
|
||||||
|
|
||||||
// 生成Excel
|
|
||||||
generateExcel(outputPath, aiResponse);
|
|
||||||
|
|
||||||
System.out.println();
|
|
||||||
System.out.println("Excel文件生成成功: " + outputPath);
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("发生错误: " + e.getMessage());
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取文件内容
|
|
||||||
*/
|
|
||||||
private static String readFile(String path) throws IOException {
|
|
||||||
return new String(Files.readAllBytes(new File(path).toPath()), "UTF-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取并合并多个markdown文件的内容
|
|
||||||
*/
|
|
||||||
private static String readAndCombineMarkdownFiles(File[] mdFiles) throws IOException {
|
|
||||||
StringBuilder combinedContent = new StringBuilder();
|
|
||||||
|
|
||||||
for (File file : mdFiles) {
|
|
||||||
String projectName = extractProjectName(file.getName());
|
|
||||||
String content = readFile(file.getAbsolutePath());
|
|
||||||
|
|
||||||
combinedContent.append("\n\n");
|
|
||||||
combinedContent.append("=== 项目: ").append(projectName).append(" ===\n");
|
|
||||||
combinedContent.append(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
return combinedContent.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从文件名中提取项目名称
|
|
||||||
* 例如: svn_log_PRS-7050场站智慧管控_20260130_093348.md -> PRS-7050场站智慧管控
|
|
||||||
*/
|
|
||||||
private static String extractProjectName(String fileName) {
|
|
||||||
// 去掉 svn_log_ 前缀
|
|
||||||
if (fileName.startsWith("svn_log_")) {
|
|
||||||
fileName = fileName.substring(8);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去掉 .md 后缀
|
|
||||||
if (fileName.endsWith(".md")) {
|
|
||||||
fileName = fileName.substring(0, fileName.length() - 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去掉时间戳部分 (格式: _YYYYMMDD_HHMMSS)
|
|
||||||
int lastUnderscore = fileName.lastIndexOf('_');
|
|
||||||
if (lastUnderscore > 0) {
|
|
||||||
// 检查是否是时间戳格式
|
|
||||||
String timestampPart = fileName.substring(lastUnderscore + 1);
|
|
||||||
if (timestampPart.matches("\\d{8}_\\d{6}")) {
|
|
||||||
fileName = fileName.substring(0, lastUnderscore);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建发送给DeepSeek的提示词
|
|
||||||
*/
|
|
||||||
private static String buildPrompt(String markdownContent, String period) {
|
|
||||||
return "你是一个专业的项目管理助手。请分析以下多个项目的SVN日志,并生成工作量统计数据。\n\n" +
|
|
||||||
"日志内容包含多个项目,每个项目之间用 === 项目: xxx === 标识。\n" +
|
|
||||||
"工作周期: " + period + "\n\n" +
|
|
||||||
"SVN日志内容:\n" + markdownContent + "\n\n" +
|
|
||||||
"请按照以下JSON格式返回工作量统计数据:\n" +
|
|
||||||
"{\n" +
|
|
||||||
" \"team\": \"所属班组\",\n" +
|
|
||||||
" \"contact\": \"技术对接人\",\n" +
|
|
||||||
" \"developer\": \"开发人员\",\n" +
|
|
||||||
" \"period\": \"" + period + "\",\n" +
|
|
||||||
" \"records\": [\n" +
|
|
||||||
" {\n" +
|
|
||||||
" \"sequence\": 1,\n" +
|
|
||||||
" \"project\": \"项目1/项目2/项目3\",\n" +
|
|
||||||
" \"content\": \"# 项目1\\n1.工作内容1\\n2.工作内容2\\n\\n# 项目2\\n1.工作内容1\\n2.工作内容2\\n\\n# 项目3\\n1.工作内容1\\n2.工作内容2\"\n" +
|
|
||||||
" }\n" +
|
|
||||||
" ]\n" +
|
|
||||||
"}\n\n" +
|
|
||||||
"重要要求:\n" +
|
|
||||||
"1. 根据日志作者确定开发人员\n" +
|
|
||||||
"2. 将所有项目的工作内容合并到一条记录中\n" +
|
|
||||||
"3. 项目名称字段(project):使用 / 分隔多个项目,例如:\"PRS7050场站系统/PRS7950智能巡视现场问题/PRS7950电科院测试\"\n" +
|
|
||||||
"4. 具体工作内容字段(content):使用 # 作为项目分类标识,格式为:\"# 项目名称\\n1.工作内容\\n2.工作内容\\n\\n# 下一个项目\\n1.工作内容\"\n" +
|
|
||||||
"5. 不同项目之间用空行分隔\n" +
|
|
||||||
"6. 只返回JSON,不要有其他文字\n" +
|
|
||||||
"7. 提取具体工作内容,要详细和有条理\n" +
|
|
||||||
"8. 项目名称要简洁明确,去掉多余的前缀和后缀";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调用DeepSeek API(流式输出)
|
|
||||||
*/
|
|
||||||
private static String callDeepSeekAPI(String apiKey, String prompt) throws IOException {
|
|
||||||
JSONObject requestBody = new JSONObject();
|
|
||||||
requestBody.put("model", "deepseek-reasoner");
|
|
||||||
|
|
||||||
// 创建消息对象,包含 role 和 content 字段
|
|
||||||
JSONObject messageObj = new JSONObject();
|
|
||||||
messageObj.put("role", "user");
|
|
||||||
messageObj.put("content", prompt);
|
|
||||||
|
|
||||||
// 创建消息数组
|
|
||||||
com.google.gson.JsonArray messagesArray = new com.google.gson.JsonArray();
|
|
||||||
messagesArray.add(messageObj.jsonObject);
|
|
||||||
requestBody.put("messages", messagesArray);
|
|
||||||
|
|
||||||
requestBody.put("max_tokens", 4000);
|
|
||||||
requestBody.put("stream", true); // 启用流式输出
|
|
||||||
|
|
||||||
Request request = new Request.Builder()
|
|
||||||
.url(DEEPSEEK_API_URL)
|
|
||||||
.addHeader("Authorization", "Bearer " + apiKey)
|
|
||||||
.addHeader("Content-Type", "application/json")
|
|
||||||
.post(RequestBody.create(requestBody.toString(), MediaType.parse("application/json")))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
StringBuilder fullResponse = new StringBuilder();
|
|
||||||
int chunkCount = 0;
|
|
||||||
|
|
||||||
try (Response response = client.newCall(request).execute()) {
|
|
||||||
if (!response.isSuccessful()) {
|
|
||||||
System.err.println("API调用失败: " + response.code() + " " + response.message());
|
|
||||||
String errorResponse = response.body().string();
|
|
||||||
System.err.println("响应: " + errorResponse);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取流式响应
|
|
||||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream()))) {
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
if (line.startsWith("data: ")) {
|
|
||||||
String data = line.substring(6);
|
|
||||||
if (data.equals("[DONE]")) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
JSONObject chunk = new JSONObject(data);
|
|
||||||
if (chunk.has("choices") && chunk.getJSONArray("choices").size() > 0) {
|
|
||||||
JSONObject choice = chunk.getJSONArray("choices").get(0);
|
|
||||||
if (choice.has("delta")) {
|
|
||||||
JSONObject delta = choice.getJSONObject("delta");
|
|
||||||
// 打印思维链推理过程(不计入最终结果)
|
|
||||||
if (delta.has("reasoning_content")) {
|
|
||||||
String reasoning = delta.optString("reasoning_content", "");
|
|
||||||
if (!reasoning.isEmpty()) {
|
|
||||||
System.out.print(reasoning);
|
|
||||||
System.out.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 收集最终回答内容
|
|
||||||
if (delta.has("content")) {
|
|
||||||
String content = delta.optString("content", "");
|
|
||||||
if (!content.isEmpty()) {
|
|
||||||
if (chunkCount == 0) {
|
|
||||||
System.out.println("\n--- 最终结果 ---");
|
|
||||||
}
|
|
||||||
fullResponse.append(content);
|
|
||||||
chunkCount++;
|
|
||||||
System.out.print(content);
|
|
||||||
System.out.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
// 忽略解析错误,继续处理下一行
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("API调用过程中发生异常: " + e.getMessage());
|
|
||||||
e.printStackTrace();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println(); // 换行
|
|
||||||
System.out.println("收到 " + chunkCount + " 个数据块");
|
|
||||||
|
|
||||||
if (fullResponse.length() == 0) {
|
|
||||||
System.err.println("警告: 未收到任何响应内容");
|
|
||||||
}
|
|
||||||
|
|
||||||
return fullResponse.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从响应中提取纯 JSON 内容
|
|
||||||
*/
|
|
||||||
private static String extractJson(String response) {
|
|
||||||
String trimmed = response.trim();
|
|
||||||
|
|
||||||
// 去除 ```json 标记
|
|
||||||
if (trimmed.startsWith("```json")) {
|
|
||||||
trimmed = trimmed.substring(7);
|
|
||||||
} else if (trimmed.startsWith("```")) {
|
|
||||||
trimmed = trimmed.substring(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去除 ``` 结束标记
|
|
||||||
if (trimmed.endsWith("```")) {
|
|
||||||
trimmed = trimmed.substring(0, trimmed.length() - 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成Excel文件
|
|
||||||
*/
|
|
||||||
private static void generateExcel(String outputPath, String jsonResponse) throws IOException {
|
|
||||||
// 提取纯 JSON 内容(去除 ```json 和 ``` 标记)
|
|
||||||
String cleanJson = extractJson(jsonResponse);
|
|
||||||
|
|
||||||
// 解析JSON响应
|
|
||||||
JSONObject data = new JSONObject(cleanJson);
|
|
||||||
|
|
||||||
// 创建工作簿
|
|
||||||
Workbook workbook = new XSSFWorkbook();
|
|
||||||
Sheet sheet = workbook.createSheet("工作表1");
|
|
||||||
|
|
||||||
// 创建样式
|
|
||||||
CellStyle headerStyle = createHeaderStyle(workbook);
|
|
||||||
CellStyle contentStyle = createContentStyle(workbook);
|
|
||||||
CellStyle workContentStyle = createWorkContentStyle(workbook);
|
|
||||||
|
|
||||||
// 创建表头(7列,与参考文件一致)
|
|
||||||
Row headerRow = sheet.createRow(0);
|
|
||||||
headerRow.setHeightInPoints(14.25f); // 表头行高
|
|
||||||
String[] headers = {"序号", "所属班组", "技术对接", "开发人员", "工作周期", "开发项目名称", "具体工作内容"};
|
|
||||||
for (int i = 0; i < headers.length; i++) {
|
|
||||||
Cell cell = headerRow.createCell(i);
|
|
||||||
cell.setCellValue(headers[i]);
|
|
||||||
cell.setCellStyle(headerStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置固定列宽(与参考文件一致)
|
|
||||||
sheet.setColumnWidth(0, 2048); // 序号:8.00字符
|
|
||||||
sheet.setColumnWidth(1, 3328); // 所属班组:13.00字符
|
|
||||||
sheet.setColumnWidth(2, 4608); // 技术对接:18.00字符
|
|
||||||
sheet.setColumnWidth(3, 3840); // 开发人员:15.00字符
|
|
||||||
sheet.setColumnWidth(4, 5888); // 工作周期:23.00字符
|
|
||||||
sheet.setColumnWidth(5, 14080); // 开发项目名称:55.00字符
|
|
||||||
sheet.setColumnWidth(6, 43991); // 具体工作内容:171.84字符
|
|
||||||
|
|
||||||
// 获取记录
|
|
||||||
String team = data.optString("team", "");
|
|
||||||
String contact = data.optString("contact", "");
|
|
||||||
String developer = data.optString("developer", "");
|
|
||||||
String period = data.optString("period", "");
|
|
||||||
|
|
||||||
if (data.has("records")) {
|
|
||||||
JSONArray recordsArray = data.getJSONArray("records");
|
|
||||||
int rowNum = 1;
|
|
||||||
|
|
||||||
for (int i = 0; i < recordsArray.size(); i++) {
|
|
||||||
JSONObject record = recordsArray.get(i);
|
|
||||||
Row row = sheet.createRow(rowNum++);
|
|
||||||
row.setHeightInPoints(16.50f); // 内容行高
|
|
||||||
|
|
||||||
// 序号
|
|
||||||
Cell cell0 = row.createCell(0);
|
|
||||||
cell0.setCellValue(record.optDouble("sequence", i + 1));
|
|
||||||
cell0.setCellStyle(contentStyle);
|
|
||||||
|
|
||||||
// 所属班组
|
|
||||||
Cell cell1 = row.createCell(1);
|
|
||||||
cell1.setCellValue(team);
|
|
||||||
cell1.setCellStyle(contentStyle);
|
|
||||||
|
|
||||||
// 技术对接
|
|
||||||
Cell cell2 = row.createCell(2);
|
|
||||||
cell2.setCellValue(contact);
|
|
||||||
cell2.setCellStyle(contentStyle);
|
|
||||||
|
|
||||||
// 开发人员
|
|
||||||
Cell cell3 = row.createCell(3);
|
|
||||||
cell3.setCellValue(developer);
|
|
||||||
cell3.setCellStyle(contentStyle);
|
|
||||||
|
|
||||||
// 工作周期
|
|
||||||
Cell cell4 = row.createCell(4);
|
|
||||||
cell4.setCellValue(period);
|
|
||||||
cell4.setCellStyle(contentStyle);
|
|
||||||
|
|
||||||
// 项目名称(多个项目用 / 分隔)
|
|
||||||
Cell cell5 = row.createCell(5);
|
|
||||||
cell5.setCellValue(record.optString("project", ""));
|
|
||||||
cell5.setCellStyle(contentStyle);
|
|
||||||
|
|
||||||
// 工作内容(支持换行,用 # 标识不同项目)
|
|
||||||
Cell cell6 = row.createCell(6);
|
|
||||||
cell6.setCellValue(record.optString("content", ""));
|
|
||||||
cell6.setCellStyle(workContentStyle); // 使用工作内容样式
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入文件
|
|
||||||
try (FileOutputStream fos = new FileOutputStream(outputPath)) {
|
|
||||||
workbook.write(fos);
|
|
||||||
}
|
|
||||||
workbook.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建表头样式
|
|
||||||
*/
|
|
||||||
private static CellStyle createHeaderStyle(Workbook workbook) {
|
|
||||||
CellStyle style = workbook.createCellStyle();
|
|
||||||
Font font = workbook.createFont();
|
|
||||||
font.setFontName("SimSun"); // 字体名称:SimSun
|
|
||||||
font.setFontHeightInPoints((short) 11); // 字体大小:11磅
|
|
||||||
font.setBold(false); // 不粗体
|
|
||||||
font.setColor(IndexedColors.BLACK.getIndex()); // 黑色
|
|
||||||
style.setFont(font);
|
|
||||||
style.setAlignment(HorizontalAlignment.GENERAL); // 水平对齐:常规
|
|
||||||
style.setVerticalAlignment(VerticalAlignment.CENTER); // 垂直对齐:居中
|
|
||||||
style.setFillPattern(FillPatternType.NO_FILL); // 无填充
|
|
||||||
style.setBorderTop(BorderStyle.THIN);
|
|
||||||
style.setBorderBottom(BorderStyle.THIN);
|
|
||||||
style.setBorderLeft(BorderStyle.THIN);
|
|
||||||
style.setBorderRight(BorderStyle.THIN);
|
|
||||||
style.setTopBorderColor(IndexedColors.BLACK.getIndex());
|
|
||||||
style.setBottomBorderColor(IndexedColors.BLACK.getIndex());
|
|
||||||
style.setLeftBorderColor(IndexedColors.BLACK.getIndex());
|
|
||||||
style.setRightBorderColor(IndexedColors.BLACK.getIndex());
|
|
||||||
style.setWrapText(false); // 不换行
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建普通内容样式(列A-F)
|
|
||||||
*/
|
|
||||||
private static CellStyle createContentStyle(Workbook workbook) {
|
|
||||||
CellStyle style = workbook.createCellStyle();
|
|
||||||
Font font = workbook.createFont();
|
|
||||||
font.setFontName("宋体"); // 字体名称:宋体
|
|
||||||
font.setFontHeightInPoints((short) 11); // 字体大小:11磅
|
|
||||||
font.setBold(false); // 不粗体
|
|
||||||
style.setFont(font);
|
|
||||||
style.setAlignment(HorizontalAlignment.GENERAL); // 水平对齐:常规
|
|
||||||
style.setVerticalAlignment(VerticalAlignment.CENTER); // 垂直对齐:居中
|
|
||||||
style.setFillPattern(FillPatternType.NO_FILL); // 无填充
|
|
||||||
style.setBorderTop(BorderStyle.THIN);
|
|
||||||
style.setBorderBottom(BorderStyle.NONE);
|
|
||||||
style.setBorderLeft(BorderStyle.NONE);
|
|
||||||
style.setBorderRight(BorderStyle.NONE);
|
|
||||||
style.setTopBorderColor(IndexedColors.BLACK.getIndex());
|
|
||||||
style.setWrapText(false); // 不换行
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建工作内容样式(列G)
|
|
||||||
*/
|
|
||||||
private static CellStyle createWorkContentStyle(Workbook workbook) {
|
|
||||||
CellStyle style = workbook.createCellStyle();
|
|
||||||
Font font = workbook.createFont();
|
|
||||||
font.setFontName("NSimSun"); // 字体名称:新宋体
|
|
||||||
font.setFontHeightInPoints((short) 14); // 字体大小:14磅
|
|
||||||
font.setBold(true); // 粗体
|
|
||||||
font.setColor(IndexedColors.BLACK.getIndex()); // 黑色
|
|
||||||
style.setFont(font);
|
|
||||||
style.setAlignment(HorizontalAlignment.LEFT); // 水平对齐:左对齐
|
|
||||||
style.setVerticalAlignment(VerticalAlignment.TOP); // 垂直对齐:顶部
|
|
||||||
style.setFillForegroundColor(IndexedColors.YELLOW.getIndex()); // 黄色背景
|
|
||||||
style.setFillPattern(FillPatternType.SOLID_FOREGROUND); // 实心填充
|
|
||||||
style.setBorderTop(BorderStyle.THIN);
|
|
||||||
style.setBorderBottom(BorderStyle.NONE);
|
|
||||||
style.setBorderLeft(BorderStyle.NONE);
|
|
||||||
style.setBorderRight(BorderStyle.NONE);
|
|
||||||
style.setTopBorderColor(IndexedColors.BLACK.getIndex());
|
|
||||||
style.setWrapText(true); // 自动换行
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 简单的JSON工具类
|
|
||||||
*/
|
|
||||||
static class JSONObject {
|
|
||||||
private final com.google.gson.JsonObject jsonObject;
|
|
||||||
|
|
||||||
public JSONObject() {
|
|
||||||
this.jsonObject = new com.google.gson.JsonObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
public JSONObject(String jsonString) {
|
|
||||||
com.google.gson.Gson gson = new com.google.gson.Gson();
|
|
||||||
this.jsonObject = gson.fromJson(jsonString, com.google.gson.JsonObject.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public JSONObject(String key, String value) {
|
|
||||||
this();
|
|
||||||
put(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void put(String key, String value) {
|
|
||||||
jsonObject.addProperty(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void put(String key, int value) {
|
|
||||||
jsonObject.addProperty(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void put(String key, double value) {
|
|
||||||
jsonObject.addProperty(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void put(String key, boolean value) {
|
|
||||||
jsonObject.addProperty(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void put(String key, Object value) {
|
|
||||||
com.google.gson.Gson gson = new com.google.gson.Gson();
|
|
||||||
jsonObject.add(key, gson.toJsonTree(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
public String optString(String key, String defaultValue) {
|
|
||||||
if (jsonObject.has(key) && !jsonObject.get(key).isJsonNull()) {
|
|
||||||
return jsonObject.get(key).getAsString();
|
|
||||||
}
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double optDouble(String key, double defaultValue) {
|
|
||||||
if (jsonObject.has(key) && !jsonObject.get(key).isJsonNull()) {
|
|
||||||
return jsonObject.get(key).getAsDouble();
|
|
||||||
}
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean has(String key) {
|
|
||||||
return jsonObject.has(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public JSONArray getJSONArray(String key) {
|
|
||||||
return new JSONArray(jsonObject.get(key).getAsJsonArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
public JSONObject getJSONObject(String key) {
|
|
||||||
return new JSONObject(jsonObject.get(key).getAsJsonObject().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return jsonObject.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 简单的JSONArray工具类
|
|
||||||
*/
|
|
||||||
static class JSONArray {
|
|
||||||
private final com.google.gson.JsonArray jsonArray;
|
|
||||||
|
|
||||||
public JSONArray(com.google.gson.JsonArray jsonArray) {
|
|
||||||
this.jsonArray = jsonArray;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int size() {
|
|
||||||
return jsonArray.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
public JSONObject get(int index) {
|
|
||||||
return new JSONObject(jsonArray.get(index).getAsJsonObject().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public <T> java.util.List<JSONObject> toList() {
|
|
||||||
java.util.List<JSONObject> list = new ArrayList<>();
|
|
||||||
for (int i = 0; i < jsonArray.size(); i++) {
|
|
||||||
list.add(get(i));
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package com.svnlog;
|
|
||||||
|
|
||||||
import org.apache.poi.ss.usermodel.*;
|
|
||||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
|
||||||
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 临时工具类,用于分析现有Excel文件格式
|
|
||||||
*/
|
|
||||||
public class ExcelAnalyzer {
|
|
||||||
public static void main(String[] args) {
|
|
||||||
String excelPath = "/home/liumangmang/opencode/日志/202512工作量统计_刘靖.xlsx";
|
|
||||||
|
|
||||||
try (FileInputStream fis = new FileInputStream(excelPath);
|
|
||||||
Workbook workbook = new XSSFWorkbook(fis)) {
|
|
||||||
|
|
||||||
System.out.println("工作表数量: " + workbook.getNumberOfSheets());
|
|
||||||
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
|
|
||||||
System.out.println("工作表 " + i + ": " + workbook.getSheetName(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
Sheet sheet = workbook.getSheetAt(0);
|
|
||||||
System.out.println("\n工作表名称: " + sheet.getSheetName());
|
|
||||||
System.out.println("总行数: " + sheet.getPhysicalNumberOfRows());
|
|
||||||
System.out.println("最后一行索引: " + sheet.getLastRowNum());
|
|
||||||
|
|
||||||
// 读取前20行数据
|
|
||||||
System.out.println("\n前20行数据:");
|
|
||||||
for (int i = 0; i <= Math.min(19, sheet.getLastRowNum()); i++) {
|
|
||||||
Row row = sheet.getRow(i);
|
|
||||||
if (row != null) {
|
|
||||||
System.out.print("第" + (i + 1) + "行: ");
|
|
||||||
for (Cell cell : row) {
|
|
||||||
String value = getCellValueAsString(cell);
|
|
||||||
System.out.print("[" + value + "] ");
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取表头
|
|
||||||
Row headerRow = sheet.getRow(0);
|
|
||||||
if (headerRow != null) {
|
|
||||||
System.out.println("\n表头列数: " + headerRow.getLastCellNum());
|
|
||||||
System.out.print("表头: ");
|
|
||||||
for (Cell cell : headerRow) {
|
|
||||||
System.out.print("[" + getCellValueAsString(cell) + "] ");
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (IOException e) {
|
|
||||||
System.err.println("读取Excel文件出错: " + e.getMessage());
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String getCellValueAsString(Cell cell) {
|
|
||||||
if (cell == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (cell.getCellType()) {
|
|
||||||
case STRING:
|
|
||||||
return cell.getStringCellValue().trim();
|
|
||||||
case NUMERIC:
|
|
||||||
if (DateUtil.isCellDateFormatted(cell)) {
|
|
||||||
return cell.getDateCellValue().toString();
|
|
||||||
} else {
|
|
||||||
return String.valueOf(cell.getNumericCellValue());
|
|
||||||
}
|
|
||||||
case BOOLEAN:
|
|
||||||
return String.valueOf(cell.getBooleanCellValue());
|
|
||||||
case FORMULA:
|
|
||||||
return cell.getCellFormula();
|
|
||||||
case BLANK:
|
|
||||||
return "";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
package com.svnlog;
|
|
||||||
|
|
||||||
import org.tmatesoft.svn.core.SVNException;
|
|
||||||
|
|
||||||
import java.io.*;
|
|
||||||
import java.text.SimpleDateFormat;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
public class Main {
|
|
||||||
private static final Scanner scanner = new Scanner(System.in);
|
|
||||||
private static final SimpleDateFormat fileNameDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss");
|
|
||||||
|
|
||||||
// 预设项目列表
|
|
||||||
private static final Project[] PRESET_PROJECTS = {
|
|
||||||
new Project("PRS-7050场站智慧管控", "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00/src_java"),
|
|
||||||
new Project("PRS-7950在线巡视", "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00/src_java"),
|
|
||||||
new Project("PRS-7950在线巡视电科院测试版", "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024/src_java")
|
|
||||||
};
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
System.out.println("===========================================");
|
|
||||||
System.out.println(" SVN 日志查询工具 v1.0");
|
|
||||||
System.out.println("===========================================");
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建 md 目录
|
|
||||||
File mdDir = new File("md");
|
|
||||||
if (!mdDir.exists()) {
|
|
||||||
boolean created = mdDir.mkdir();
|
|
||||||
if (created) {
|
|
||||||
System.out.println("已创建 md 目录用于存放日志文件");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
// 选择项目
|
|
||||||
Project selectedProject = selectProject();
|
|
||||||
String url = selectedProject.getUrl();
|
|
||||||
System.out.println("已选择项目: " + selectedProject.getName());
|
|
||||||
System.out.println("SVN地址: " + url);
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
String username = readInput("请输入SVN账号: ");
|
|
||||||
String password = readPassword("请输入SVN密码: ");
|
|
||||||
|
|
||||||
System.out.println("正在连接SVN仓库...");
|
|
||||||
SVNLogFetcher fetcher = new SVNLogFetcher(url, username, password);
|
|
||||||
fetcher.testConnection();
|
|
||||||
System.out.println("连接成功!");
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
long latestRevision = fetcher.getLatestRevision();
|
|
||||||
System.out.println("最新版本号: " + latestRevision);
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
long startRevision = readLongInput("请输入开始版本号 (回车使用最新版本): ", latestRevision);
|
|
||||||
long endRevision = readLongInput("请输入结束版本号 (回车使用最新版本): ", latestRevision);
|
|
||||||
String filterUser = readInput("请输入过滤用户名 (包含匹配,回车跳过过滤): ");
|
|
||||||
|
|
||||||
System.out.println();
|
|
||||||
System.out.println("正在获取日志...");
|
|
||||||
List<LogEntry> logs = fetcher.fetchLogs(startRevision, endRevision, filterUser);
|
|
||||||
|
|
||||||
if (logs.isEmpty()) {
|
|
||||||
System.out.println("没有找到符合条件的日志记录。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("获取到 " + logs.size() + " 条日志记录。");
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
// 生成Markdown文件(保存到 md 目录)
|
|
||||||
String fileName = "md/svn_log_" + selectedProject.getName() + "_" + fileNameDateFormat.format(new Date()) + ".md";
|
|
||||||
generateMarkdown(fileName, url, username, startRevision, endRevision, filterUser, logs, fetcher);
|
|
||||||
|
|
||||||
System.out.println();
|
|
||||||
System.out.println("日志已成功导出到: " + fileName);
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
} catch (SVNException e) {
|
|
||||||
System.err.println("SVN错误: " + e.getMessage());
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("发生错误: " + e.getMessage());
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 让用户选择项目
|
|
||||||
*/
|
|
||||||
private static Project selectProject() {
|
|
||||||
System.out.println("请选择SVN项目:");
|
|
||||||
for (int i = 0; i < PRESET_PROJECTS.length; i++) {
|
|
||||||
System.out.println(" " + (i + 1) + ". " + PRESET_PROJECTS[i].getName());
|
|
||||||
}
|
|
||||||
System.out.println(" 0. 自定义SVN地址");
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
System.out.print("请输入项目编号 (1-" + PRESET_PROJECTS.length + ", 0为自定义): ");
|
|
||||||
String input = scanner.nextLine().trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
int choice = Integer.parseInt(input);
|
|
||||||
|
|
||||||
if (choice == 0) {
|
|
||||||
String customUrl = readInput("请输入SVN仓库地址: ");
|
|
||||||
return new Project("自定义项目", customUrl);
|
|
||||||
} else if (choice >= 1 && choice <= PRESET_PROJECTS.length) {
|
|
||||||
return PRESET_PROJECTS[choice - 1];
|
|
||||||
} else {
|
|
||||||
System.out.println("输入无效,请重新选择!");
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
System.out.println("输入无效,请输入数字!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String readInput(String prompt) {
|
|
||||||
System.out.print(prompt);
|
|
||||||
return scanner.nextLine().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String readPassword(String prompt) {
|
|
||||||
if (System.console() != null) {
|
|
||||||
char[] password = System.console().readPassword("%s", prompt);
|
|
||||||
return new String(password);
|
|
||||||
} else {
|
|
||||||
System.out.print(prompt);
|
|
||||||
return scanner.nextLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long readLongInput(String prompt, long defaultValue) {
|
|
||||||
System.out.print(prompt);
|
|
||||||
String input = scanner.nextLine().trim();
|
|
||||||
|
|
||||||
if (input.isEmpty()) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Long.parseLong(input);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
System.out.println("输入无效,使用默认值: " + defaultValue);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void generateMarkdown(String fileName, String url, String username,
|
|
||||||
long startRevision, long endRevision, String filterUser,
|
|
||||||
List<LogEntry> logs, SVNLogFetcher fetcher) throws IOException {
|
|
||||||
StringBuilder markdown = new StringBuilder();
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
markdown.append("# SVN 日志报告\n\n");
|
|
||||||
|
|
||||||
// 查询条件(简化版)
|
|
||||||
markdown.append("## 查询条件\n\n");
|
|
||||||
markdown.append("- **SVN地址**: `").append(url).append("`\n");
|
|
||||||
markdown.append("- **版本范围**: r").append(startRevision).append(" - r").append(endRevision).append("\n");
|
|
||||||
if (filterUser != null && !filterUser.isEmpty()) {
|
|
||||||
markdown.append("- **过滤用户**: `").append(filterUser).append("`\n");
|
|
||||||
}
|
|
||||||
markdown.append("\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");
|
|
||||||
|
|
||||||
String message = entry.getMessage();
|
|
||||||
if (message != null && !message.isEmpty()) {
|
|
||||||
markdown.append("**提交信息**:\n\n");
|
|
||||||
markdown.append("```\n").append(message).append("\n```\n\n");
|
|
||||||
} else {
|
|
||||||
markdown.append("**提交信息**: (无)\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
markdown.append("---\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入文件
|
|
||||||
try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) {
|
|
||||||
writer.write(markdown.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 项目信息类
|
|
||||||
*/
|
|
||||||
private static class Project {
|
|
||||||
private String name;
|
|
||||||
private String url;
|
|
||||||
|
|
||||||
public Project(String name, String url) {
|
|
||||||
this.name = name;
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getName() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUrl() {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package com.svnlog;
|
|
||||||
|
|
||||||
import org.tmatesoft.svn.core.*;
|
|
||||||
import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager;
|
|
||||||
import org.tmatesoft.svn.core.internal.io.svn.SVNRepositoryFactoryImpl;
|
|
||||||
import org.tmatesoft.svn.core.io.SVNRepository;
|
|
||||||
import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
|
|
||||||
import org.tmatesoft.svn.core.wc.SVNWCUtil;
|
|
||||||
|
|
||||||
import java.text.SimpleDateFormat;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
public class SVNLogFetcher {
|
|
||||||
private String url;
|
|
||||||
private String username;
|
|
||||||
private String password;
|
|
||||||
private SVNRepository repository;
|
|
||||||
private SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
|
||||||
|
|
||||||
public SVNLogFetcher(String url, String username, String password) throws SVNException {
|
|
||||||
this.url = url;
|
|
||||||
this.username = username;
|
|
||||||
this.password = password;
|
|
||||||
|
|
||||||
SVNRepositoryFactoryImpl.setup();
|
|
||||||
this.repository = SVNRepositoryFactory.create(SVNURL.parseURIEncoded(url));
|
|
||||||
|
|
||||||
ISVNAuthenticationManager authManager = SVNWCUtil.createDefaultAuthenticationManager(username, password.toCharArray());
|
|
||||||
repository.setAuthenticationManager(authManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<LogEntry> fetchLogs(long startRevision, long endRevision) throws SVNException {
|
|
||||||
return fetchLogs(startRevision, endRevision, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<LogEntry> fetchLogs(long startRevision, long endRevision, String filterUser) throws SVNException {
|
|
||||||
List<LogEntry> entries = new ArrayList<>();
|
|
||||||
|
|
||||||
if (startRevision < 0) {
|
|
||||||
startRevision = repository.getLatestRevision();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endRevision < 0) {
|
|
||||||
endRevision = repository.getLatestRevision();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startRevision > endRevision) {
|
|
||||||
long temp = startRevision;
|
|
||||||
startRevision = endRevision;
|
|
||||||
endRevision = temp;
|
|
||||||
}
|
|
||||||
|
|
||||||
Collection<SVNLogEntry> logEntries = repository.log(new String[]{""}, null, startRevision, endRevision, true, true);
|
|
||||||
|
|
||||||
for (SVNLogEntry logEntry : logEntries) {
|
|
||||||
String author = logEntry.getAuthor();
|
|
||||||
|
|
||||||
// 如果设置了用户名过滤器,则跳过不匹配的记录(包含匹配,不区分大小写)
|
|
||||||
if (filterUser != null && !filterUser.isEmpty() && (author == null || !author.toLowerCase().contains(filterUser.toLowerCase()))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
LogEntry entry = new LogEntry();
|
|
||||||
entry.setRevision(logEntry.getRevision());
|
|
||||||
entry.setAuthor(author != null ? author : "(无作者)");
|
|
||||||
entry.setDate(logEntry.getDate());
|
|
||||||
entry.setMessage(logEntry.getMessage() != null ? logEntry.getMessage().trim() : "");
|
|
||||||
|
|
||||||
// 获取变更的文件路径
|
|
||||||
if (logEntry.getChangedPaths() != null) {
|
|
||||||
List<String> paths = new ArrayList<>();
|
|
||||||
for (Map.Entry<String, SVNLogEntryPath> pathEntry : logEntry.getChangedPaths().entrySet()) {
|
|
||||||
paths.add(pathEntry.getKey());
|
|
||||||
}
|
|
||||||
entry.setChangedPaths(paths.toArray(new String[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.add(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按版本号降序排序
|
|
||||||
entries.sort((e1, e2) -> Long.compare(e2.getRevision(), e1.getRevision()));
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getLatestRevision() throws SVNException {
|
|
||||||
return repository.getLatestRevision();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String formatDate(Date date) {
|
|
||||||
return dateFormat.format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void testConnection() throws SVNException {
|
|
||||||
repository.testConnection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
101
src/main/java/com/svnlog/core/report/MarkdownReportWriter.java
Normal file
101
src/main/java/com/svnlog/core/report/MarkdownReportWriter.java
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package com.svnlog.core.report;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.svnlog.core.svn.LogEntry;
|
||||||
|
import com.svnlog.core.svn.SVNLogFetcher;
|
||||||
|
|
||||||
|
public final class MarkdownReportWriter {
|
||||||
|
|
||||||
|
public static final class Options {
|
||||||
|
private boolean includeAccount;
|
||||||
|
private boolean includeGeneratedTime;
|
||||||
|
private boolean includeStatistics;
|
||||||
|
private boolean placeholderForEmptyMessage;
|
||||||
|
|
||||||
|
public Options includeAccount(boolean includeAccount) {
|
||||||
|
this.includeAccount = includeAccount;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Options includeGeneratedTime(boolean includeGeneratedTime) {
|
||||||
|
this.includeGeneratedTime = includeGeneratedTime;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Options includeStatistics(boolean includeStatistics) {
|
||||||
|
this.includeStatistics = includeStatistics;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Options placeholderForEmptyMessage(boolean placeholderForEmptyMessage) {
|
||||||
|
this.placeholderForEmptyMessage = placeholderForEmptyMessage;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MarkdownReportWriter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void write(Path path,
|
||||||
|
String url,
|
||||||
|
String username,
|
||||||
|
long startRevision,
|
||||||
|
long endRevision,
|
||||||
|
String filterUser,
|
||||||
|
List<LogEntry> logs,
|
||||||
|
SVNLogFetcher fetcher,
|
||||||
|
Options options) throws IOException {
|
||||||
|
final Options effectiveOptions = options == null ? new Options() : options;
|
||||||
|
final StringBuilder markdown = new StringBuilder();
|
||||||
|
|
||||||
|
markdown.append("# SVN 日志报告\n\n");
|
||||||
|
markdown.append("## 查询条件\n\n");
|
||||||
|
markdown.append("- **SVN地址**: `").append(url).append("`\n");
|
||||||
|
if (effectiveOptions.includeAccount) {
|
||||||
|
markdown.append("- **账号**: `").append(safe(username)).append("`\n");
|
||||||
|
}
|
||||||
|
markdown.append("- **版本范围**: r").append(startRevision).append(" - r").append(endRevision).append("\n");
|
||||||
|
if (!safe(filterUser).isEmpty()) {
|
||||||
|
markdown.append("- **过滤用户**: `").append(filterUser).append("`\n");
|
||||||
|
}
|
||||||
|
if (effectiveOptions.includeGeneratedTime) {
|
||||||
|
markdown.append("- **生成时间**: ").append(fetcher.formatDate(new Date())).append("\n");
|
||||||
|
}
|
||||||
|
markdown.append("\n");
|
||||||
|
|
||||||
|
if (effectiveOptions.includeStatistics) {
|
||||||
|
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(safe(entry.getAuthor())).append("` \n");
|
||||||
|
markdown.append("**时间**: ").append(fetcher.formatDate(entry.getDate())).append(" \n");
|
||||||
|
markdown.append("**版本**: r").append(entry.getRevision()).append("\n\n");
|
||||||
|
|
||||||
|
final String message = safe(entry.getMessage());
|
||||||
|
if (message.isEmpty() && effectiveOptions.placeholderForEmptyMessage) {
|
||||||
|
markdown.append("**提交信息**: (无)\n\n");
|
||||||
|
} else {
|
||||||
|
markdown.append("**提交信息**:\n\n");
|
||||||
|
markdown.append("```\n").append(message).append("\n```\n\n");
|
||||||
|
}
|
||||||
|
markdown.append("---\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
Files.write(path, markdown.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.svnlog;
|
package com.svnlog.core.svn;
|
||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
245
src/main/java/com/svnlog/core/svn/SVNLogFetcher.java
Normal file
245
src/main/java/com/svnlog/core/svn/SVNLogFetcher.java
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package com.svnlog.core.svn;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Calendar;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.tmatesoft.svn.core.SVNException;
|
||||||
|
import org.tmatesoft.svn.core.SVNLogEntry;
|
||||||
|
import org.tmatesoft.svn.core.SVNLogEntryPath;
|
||||||
|
import org.tmatesoft.svn.core.SVNURL;
|
||||||
|
import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager;
|
||||||
|
import org.tmatesoft.svn.core.internal.io.dav.DAVRepositoryFactory;
|
||||||
|
import org.tmatesoft.svn.core.internal.io.svn.SVNRepositoryFactoryImpl;
|
||||||
|
import org.tmatesoft.svn.core.io.SVNRepository;
|
||||||
|
import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
|
||||||
|
import org.tmatesoft.svn.core.wc.SVNWCUtil;
|
||||||
|
|
||||||
|
public class SVNLogFetcher {
|
||||||
|
private final SVNRepository repository;
|
||||||
|
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
static {
|
||||||
|
// 初始化 SVNKit 工厂(必须在创建 repository 之前调用)
|
||||||
|
DAVRepositoryFactory.setup();
|
||||||
|
SVNRepositoryFactoryImpl.setup();
|
||||||
|
System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public SVNLogFetcher(String url, String username, String password) throws SVNException {
|
||||||
|
this.repository = SVNRepositoryFactory.create(SVNURL.parseURIEncoded(url));
|
||||||
|
|
||||||
|
// 创建认证管理器并配置 SSL
|
||||||
|
ISVNAuthenticationManager authManager = SVNWCUtil.createDefaultAuthenticationManager(
|
||||||
|
username,
|
||||||
|
password.toCharArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 配置认证管理器接受所有 SSL 证书
|
||||||
|
if (authManager instanceof org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) {
|
||||||
|
org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager defaultAuthManager =
|
||||||
|
(org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) authManager;
|
||||||
|
|
||||||
|
// 设置为接受所有 SSL 证书
|
||||||
|
defaultAuthManager.setAuthenticationForced(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.setAuthenticationManager(authManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<LogEntry> fetchLogs(long startRevision, long endRevision) throws SVNException {
|
||||||
|
return fetchLogs(startRevision, endRevision, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<LogEntry> fetchLogs(long startRevision, long endRevision, String filterUser) throws SVNException {
|
||||||
|
List<LogEntry> entries = new ArrayList<>();
|
||||||
|
|
||||||
|
if (startRevision < 0) {
|
||||||
|
startRevision = repository.getLatestRevision();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endRevision < 0) {
|
||||||
|
endRevision = repository.getLatestRevision();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startRevision > endRevision) {
|
||||||
|
long temp = startRevision;
|
||||||
|
startRevision = endRevision;
|
||||||
|
endRevision = temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
Collection<SVNLogEntry> logEntries = repository.log(new String[]{""}, null, startRevision, endRevision, true, true);
|
||||||
|
|
||||||
|
for (SVNLogEntry logEntry : logEntries) {
|
||||||
|
String author = logEntry.getAuthor();
|
||||||
|
|
||||||
|
// 如果设置了用户名过滤器,则跳过不匹配的记录(包含匹配,不区分大小写)
|
||||||
|
if (filterUser != null && !filterUser.isEmpty() && (author == null || !author.toLowerCase().contains(filterUser.toLowerCase()))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogEntry entry = new LogEntry();
|
||||||
|
entry.setRevision(logEntry.getRevision());
|
||||||
|
entry.setAuthor(author != null ? author : "(无作者)");
|
||||||
|
entry.setDate(logEntry.getDate());
|
||||||
|
entry.setMessage(logEntry.getMessage() != null ? logEntry.getMessage().trim() : "");
|
||||||
|
|
||||||
|
// 获取变更的文件路径
|
||||||
|
if (logEntry.getChangedPaths() != null) {
|
||||||
|
List<String> paths = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, SVNLogEntryPath> pathEntry : logEntry.getChangedPaths().entrySet()) {
|
||||||
|
paths.add(pathEntry.getKey());
|
||||||
|
}
|
||||||
|
entry.setChangedPaths(paths.toArray(new String[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按版本号降序排序
|
||||||
|
entries.sort((e1, e2) -> Long.compare(e2.getRevision(), e1.getRevision()));
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getLatestRevision() throws SVNException {
|
||||||
|
return repository.getLatestRevision();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String formatDate(Date date) {
|
||||||
|
return dateFormat.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void testConnection() throws SVNException {
|
||||||
|
repository.testConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定年月的版本范围(采样估算法,不过滤用户)
|
||||||
|
* @param year 年份
|
||||||
|
* @param month 月份(1-12)
|
||||||
|
* @return 数组 [startRevision, endRevision],如果该月无提交返回null
|
||||||
|
* @throws SVNException SVN异常
|
||||||
|
*/
|
||||||
|
public long[] getVersionRangeByMonth(int year, int month) throws SVNException {
|
||||||
|
// 计算目标月份的时间范围
|
||||||
|
Calendar startCal = Calendar.getInstance();
|
||||||
|
startCal.set(year, month - 1, 1, 0, 0, 0);
|
||||||
|
startCal.set(Calendar.MILLISECOND, 0);
|
||||||
|
long targetStartTime = startCal.getTimeInMillis();
|
||||||
|
|
||||||
|
Calendar endCal = Calendar.getInstance();
|
||||||
|
endCal.set(year, month - 1, 1, 23, 59, 59);
|
||||||
|
endCal.set(Calendar.MILLISECOND, 999);
|
||||||
|
endCal.set(Calendar.DAY_OF_MONTH, endCal.getActualMaximum(Calendar.DAY_OF_MONTH));
|
||||||
|
long targetEndTime = endCal.getTimeInMillis();
|
||||||
|
|
||||||
|
long latestRevision = getLatestRevision();
|
||||||
|
System.out.println("查询 " + year + "年" + month + "月,最新版本: " + latestRevision);
|
||||||
|
|
||||||
|
// 采样策略:每隔20个版本采样一次
|
||||||
|
long sampleInterval = 20;
|
||||||
|
long firstRevisionInMonth = -1; // 第一个在目标月份内的版本
|
||||||
|
long lastRevisionBeforeMonth = -1; // 最后一个在目标月份之前的版本
|
||||||
|
int samplesChecked = 0;
|
||||||
|
int maxSamples = 10000;
|
||||||
|
|
||||||
|
// 从最新版本往旧版本采样
|
||||||
|
for (long rev = latestRevision; rev >= 1 && samplesChecked < maxSamples; rev -= sampleInterval) {
|
||||||
|
samplesChecked++;
|
||||||
|
try {
|
||||||
|
Collection<SVNLogEntry> sample = repository.log(new String[]{""}, (Collection) null, rev, rev, false, false);
|
||||||
|
if (sample.isEmpty()) continue;
|
||||||
|
|
||||||
|
SVNLogEntry entry = sample.iterator().next();
|
||||||
|
Date logDate = entry.getDate();
|
||||||
|
if (logDate == null) continue;
|
||||||
|
|
||||||
|
long logTime = logDate.getTime();
|
||||||
|
|
||||||
|
// 找到第一个在目标月份内的版本(从新到旧方向)
|
||||||
|
if (logTime >= targetStartTime && logTime <= targetEndTime) {
|
||||||
|
firstRevisionInMonth = rev;
|
||||||
|
System.out.println("找到月份内的版本: " + rev + ", 日期: " + formatDate(logDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到第一个在目标月份之前的版本
|
||||||
|
if (logTime < targetStartTime) {
|
||||||
|
lastRevisionBeforeMonth = rev;
|
||||||
|
System.out.println("找到月份之前的版本: " + rev + ", 日期: " + formatDate(logDate));
|
||||||
|
break; // 已经超出目标月份,停止采样
|
||||||
|
}
|
||||||
|
} catch (SVNException e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定粗略范围
|
||||||
|
long roughStart;
|
||||||
|
long roughEnd;
|
||||||
|
|
||||||
|
if (firstRevisionInMonth == -1) {
|
||||||
|
// 没有找到目标月份内的版本,可能该月无提交
|
||||||
|
System.out.println("采样未找到目标月份内的版本");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 粗略起始:从最后一个月份之前的版本开始,向前扩展1000个版本
|
||||||
|
if (lastRevisionBeforeMonth != -1) {
|
||||||
|
roughStart = Math.max(1, lastRevisionBeforeMonth - 1000);
|
||||||
|
} else {
|
||||||
|
// 如果没找到月份之前的版本,说明目标月份很早,从版本1开始
|
||||||
|
roughStart = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 粗略结束:从第一个月份内的版本开始,向后扩展1000个版本
|
||||||
|
roughEnd = Math.min(latestRevision, firstRevisionInMonth + 1000);
|
||||||
|
|
||||||
|
System.out.println("粗略范围: " + roughStart + " - " + roughEnd);
|
||||||
|
|
||||||
|
// 在粗略范围内精确查询
|
||||||
|
Collection<SVNLogEntry> entries = repository.log(
|
||||||
|
new String[]{""},
|
||||||
|
(Collection) null,
|
||||||
|
roughStart,
|
||||||
|
roughEnd,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entries == null || entries.isEmpty()) {
|
||||||
|
System.out.println("粗略范围内无日志记录");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("粗略范围内共 " + entries.size() + " 条记录");
|
||||||
|
|
||||||
|
long minRevision = Long.MAX_VALUE;
|
||||||
|
long maxRevision = Long.MIN_VALUE;
|
||||||
|
|
||||||
|
// 过滤出目标月份的版本(不过滤用户)
|
||||||
|
for (SVNLogEntry entry : entries) {
|
||||||
|
Date logDate = entry.getDate();
|
||||||
|
if (logDate == null) continue;
|
||||||
|
|
||||||
|
long logTime = logDate.getTime();
|
||||||
|
if (logTime >= targetStartTime && logTime <= targetEndTime) {
|
||||||
|
long revision = entry.getRevision();
|
||||||
|
if (revision < minRevision) minRevision = revision;
|
||||||
|
if (revision > maxRevision) maxRevision = revision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minRevision == Long.MAX_VALUE || maxRevision == Long.MIN_VALUE) {
|
||||||
|
System.out.println("目标月份无匹配记录");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("找到版本范围: " + minRevision + " - " + maxRevision);
|
||||||
|
return new long[]{minRevision, maxRevision};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java
Normal file
39
src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package com.svnlog.core.svn;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.TrustManager;
|
||||||
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供信任所有证书的 SSLContext(仅用于内网 SVN 服务器)
|
||||||
|
*/
|
||||||
|
public class TrustAllSSLContext {
|
||||||
|
|
||||||
|
private static SSLContext sslContext;
|
||||||
|
|
||||||
|
static {
|
||||||
|
try {
|
||||||
|
TrustManager[] trustAllCerts = new TrustManager[]{
|
||||||
|
new X509TrustManager() {
|
||||||
|
public X509Certificate[] getAcceptedIssuers() {
|
||||||
|
return new X509Certificate[0];
|
||||||
|
}
|
||||||
|
public void checkClientTrusted(X509Certificate[] certs, String authType) {
|
||||||
|
}
|
||||||
|
public void checkServerTrusted(X509Certificate[] certs, String authType) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sslContext = SSLContext.getInstance("TLS");
|
||||||
|
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to initialize TrustAll SSLContext", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SSLContext getInstance() {
|
||||||
|
return sslContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/main/java/com/svnlog/web/WebApplication.java
Normal file
71
src/main/java/com/svnlog/web/WebApplication.java
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package com.svnlog.web;
|
||||||
|
|
||||||
|
import javax.net.ssl.HostnameVerifier;
|
||||||
|
import javax.net.ssl.HttpsURLConnection;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.SSLSession;
|
||||||
|
import javax.net.ssl.TrustManager;
|
||||||
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication(scanBasePackages = "com.svnlog")
|
||||||
|
public class WebApplication {
|
||||||
|
|
||||||
|
static {
|
||||||
|
// 配置 Java 全局 SSL 上下文(用于内网 SVN 服务器)
|
||||||
|
try {
|
||||||
|
// 移除 TLSv1 和 TLSv1.1 的禁用限制
|
||||||
|
String disabledAlgorithms = java.security.Security.getProperty("jdk.tls.disabledAlgorithms");
|
||||||
|
if (disabledAlgorithms != null && (disabledAlgorithms.contains("TLSv1") || disabledAlgorithms.contains("TLSv1.1"))) {
|
||||||
|
disabledAlgorithms = disabledAlgorithms
|
||||||
|
.replaceAll("TLSv1\\.1,\\s*", "")
|
||||||
|
.replaceAll("TLSv1,\\s*", "")
|
||||||
|
.replaceAll(",\\s*TLSv1\\.1", "")
|
||||||
|
.replaceAll(",\\s*TLSv1", "");
|
||||||
|
java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms);
|
||||||
|
System.out.println("TLS configuration updated: " + disabledAlgorithms);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置信任所有证书的 SSL 上下文
|
||||||
|
TrustManager[] trustAllCerts = new TrustManager[]{
|
||||||
|
new X509TrustManager() {
|
||||||
|
public X509Certificate[] getAcceptedIssuers() {
|
||||||
|
return new X509Certificate[0];
|
||||||
|
}
|
||||||
|
public void checkClientTrusted(X509Certificate[] certs, String authType) {
|
||||||
|
}
|
||||||
|
public void checkServerTrusted(X509Certificate[] certs, String authType) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||||
|
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
|
||||||
|
|
||||||
|
// 设置为默认 SSL 上下文
|
||||||
|
SSLContext.setDefault(sslContext);
|
||||||
|
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
|
||||||
|
HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() {
|
||||||
|
public boolean verify(String hostname, SSLSession session) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
System.out.println("SSL context configured to trust all certificates");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Warning: Failed to configure SSL context: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置 TLS 协议版本
|
||||||
|
System.setProperty("https.protocols", "TLSv1.2,TLSv1.1,TLSv1");
|
||||||
|
System.setProperty("jdk.tls.client.protocols", "TLSv1.2,TLSv1.1,TLSv1");
|
||||||
|
System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(WebApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/main/java/com/svnlog/web/config/SvnPresetProperties.java
Normal file
33
src/main/java/com/svnlog/web/config/SvnPresetProperties.java
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package com.svnlog.web.config;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.svnlog.web.model.SvnPreset;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "svn")
|
||||||
|
public class SvnPresetProperties {
|
||||||
|
|
||||||
|
private String defaultPresetId;
|
||||||
|
private List<SvnPreset> presets = new ArrayList<SvnPreset>();
|
||||||
|
|
||||||
|
public String getDefaultPresetId() {
|
||||||
|
return defaultPresetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDefaultPresetId(String defaultPresetId) {
|
||||||
|
this.defaultPresetId = defaultPresetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SvnPreset> getPresets() {
|
||||||
|
return presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPresets(List<SvnPreset> presets) {
|
||||||
|
this.presets = presets;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,12 +22,17 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import com.svnlog.core.svn.SVNLogFetcher;
|
||||||
|
|
||||||
import com.svnlog.web.dto.AiAnalyzeRequest;
|
import com.svnlog.web.dto.AiAnalyzeRequest;
|
||||||
import com.svnlog.web.dto.SettingsUpdateRequest;
|
import com.svnlog.web.dto.SettingsUpdateRequest;
|
||||||
import com.svnlog.web.dto.SvnConnectionRequest;
|
import com.svnlog.web.dto.SvnConnectionRequest;
|
||||||
import com.svnlog.web.dto.SvnFetchRequest;
|
import com.svnlog.web.dto.SvnFetchRequest;
|
||||||
|
import com.svnlog.web.dto.SvnVersionRangeRequest;
|
||||||
import com.svnlog.web.model.SvnPreset;
|
import com.svnlog.web.model.SvnPreset;
|
||||||
|
import com.svnlog.web.model.SvnPresetSummary;
|
||||||
import com.svnlog.web.model.TaskInfo;
|
import com.svnlog.web.model.TaskInfo;
|
||||||
import com.svnlog.web.model.TaskPageResult;
|
import com.svnlog.web.model.TaskPageResult;
|
||||||
import com.svnlog.web.service.AiWorkflowService;
|
import com.svnlog.web.service.AiWorkflowService;
|
||||||
@@ -85,6 +90,29 @@ public class AppController {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定月份的SVN版本范围
|
||||||
|
*/
|
||||||
|
@PostMapping("/svn/version-range")
|
||||||
|
public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception {
|
||||||
|
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||||
|
final String url = preset.getUrl();
|
||||||
|
final String username = request.getUsername();
|
||||||
|
final String password = request.getPassword();
|
||||||
|
final int year = request.getYear().intValue();
|
||||||
|
final int month = request.getMonth().intValue();
|
||||||
|
|
||||||
|
SVNLogFetcher fetcher = new SVNLogFetcher(url, username, password);
|
||||||
|
long[] range = fetcher.getVersionRangeByMonth(year, month);
|
||||||
|
|
||||||
|
final Map<String, Object> response = new HashMap<String, Object>();
|
||||||
|
if (range != null) {
|
||||||
|
response.put("startRevision", range[0]);
|
||||||
|
response.put("endRevision", range[1]);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/svn/fetch")
|
@PostMapping("/svn/fetch")
|
||||||
public Map<String, String> fetchSvnLogs(@Valid @RequestBody SvnFetchRequest request) {
|
public Map<String, String> fetchSvnLogs(@Valid @RequestBody SvnFetchRequest request) {
|
||||||
final String taskId = taskService.submit("SVN_FETCH", context -> svnWorkflowService.fetchToMarkdown(request, context));
|
final String taskId = taskService.submit("SVN_FETCH", context -> svnWorkflowService.fetchToMarkdown(request, context));
|
||||||
@@ -96,7 +124,7 @@ public class AppController {
|
|||||||
@GetMapping("/svn/presets")
|
@GetMapping("/svn/presets")
|
||||||
public Map<String, Object> listSvnPresets() {
|
public Map<String, Object> listSvnPresets() {
|
||||||
final Map<String, Object> response = new HashMap<String, Object>();
|
final Map<String, Object> response = new HashMap<String, Object>();
|
||||||
final List<SvnPreset> presets = svnPresetService.listPresets();
|
final List<SvnPresetSummary> presets = svnPresetService.listPresetSummaries();
|
||||||
response.put("presets", presets);
|
response.put("presets", presets);
|
||||||
response.put("defaultPresetId", settingsService.getDefaultSvnPresetId());
|
response.put("defaultPresetId", settingsService.getDefaultSvnPresetId());
|
||||||
return response;
|
return response;
|
||||||
@@ -135,6 +163,11 @@ public class AppController {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/tasks/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter streamTask(@PathVariable("taskId") String taskId) {
|
||||||
|
return taskService.subscribeTaskStream(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/tasks/{taskId}/cancel")
|
@PostMapping("/tasks/{taskId}/cancel")
|
||||||
public Map<String, Object> cancelTask(@PathVariable("taskId") String taskId) {
|
public Map<String, Object> cancelTask(@PathVariable("taskId") String taskId) {
|
||||||
final boolean cancelled = taskService.cancelTask(taskId);
|
final boolean cancelled = taskService.cancelTask(taskId);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import javax.validation.constraints.NotBlank;
|
|||||||
public class SvnConnectionRequest {
|
public class SvnConnectionRequest {
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
private String url;
|
private String presetId;
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
private String username;
|
private String username;
|
||||||
@@ -13,12 +13,12 @@ public class SvnConnectionRequest {
|
|||||||
@NotBlank
|
@NotBlank
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
public String getUrl() {
|
public String getPresetId() {
|
||||||
return url;
|
return presetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUrl(String url) {
|
public void setPresetId(String presetId) {
|
||||||
this.url = url;
|
this.presetId = presetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getUsername() {
|
public String getUsername() {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import javax.validation.constraints.NotBlank;
|
|||||||
|
|
||||||
public class SvnFetchRequest {
|
public class SvnFetchRequest {
|
||||||
|
|
||||||
private String projectName;
|
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
private String url;
|
private String presetId;
|
||||||
|
|
||||||
|
private String projectName;
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
private String username;
|
private String username;
|
||||||
@@ -19,6 +19,14 @@ public class SvnFetchRequest {
|
|||||||
private Long endRevision;
|
private Long endRevision;
|
||||||
private String filterUser;
|
private String filterUser;
|
||||||
|
|
||||||
|
public String getPresetId() {
|
||||||
|
return presetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPresetId(String presetId) {
|
||||||
|
this.presetId = presetId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getProjectName() {
|
public String getProjectName() {
|
||||||
return projectName;
|
return projectName;
|
||||||
}
|
}
|
||||||
@@ -27,14 +35,6 @@ public class SvnFetchRequest {
|
|||||||
this.projectName = projectName;
|
this.projectName = projectName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getUrl() {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUrl(String url) {
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUsername() {
|
public String getUsername() {
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java
Normal file
62
src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package com.svnlog.web.dto;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public class SvnVersionRangeRequest {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String presetId;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private Integer year;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private Integer month;
|
||||||
|
|
||||||
|
public String getPresetId() {
|
||||||
|
return presetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPresetId(String presetId) {
|
||||||
|
this.presetId = presetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Integer getYear() {
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setYear(Integer year) {
|
||||||
|
this.year = year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getMonth() {
|
||||||
|
return month;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMonth(Integer month) {
|
||||||
|
this.month = month;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/main/java/com/svnlog/web/model/SvnPresetSummary.java
Normal file
31
src/main/java/com/svnlog/web/model/SvnPresetSummary.java
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package com.svnlog.web.model;
|
||||||
|
|
||||||
|
public class SvnPresetSummary {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
public SvnPresetSummary() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public SvnPresetSummary(String id, String name) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,15 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import org.apache.poi.ss.usermodel.BorderStyle;
|
import org.apache.poi.ss.usermodel.BorderStyle;
|
||||||
import org.apache.poi.ss.usermodel.Cell;
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
@@ -42,6 +48,19 @@ import okhttp3.Response;
|
|||||||
public class AiWorkflowService {
|
public class AiWorkflowService {
|
||||||
|
|
||||||
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
|
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
|
||||||
|
private static final String DEFAULT_TEAM = "系统部";
|
||||||
|
private static final String DEFAULT_CONTACT = "杨志强\n(系统平台组)";
|
||||||
|
private static final String DEFAULT_DEVELOPER = "刘靖";
|
||||||
|
private static final String[] FIXED_PROJECTS = {
|
||||||
|
"PRS-7050场站智慧管控",
|
||||||
|
"PRS-7950在线巡视",
|
||||||
|
"PRS-7950在线巡视电科院测试版"
|
||||||
|
};
|
||||||
|
private static final String FIXED_PROJECT_VALUE =
|
||||||
|
"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控";
|
||||||
|
private static final Pattern NUMBERED_ITEM_PATTERN = Pattern.compile("^\\s*(\\d+)[\\.、\\)]\\s*(.+)$");
|
||||||
|
private static final Pattern BULLET_ITEM_PATTERN = Pattern.compile("^\\s*[-*•]\\s*(.+)$");
|
||||||
|
private static final Pattern REVISION_ITEM_PATTERN = Pattern.compile("^\\s*(?:\\*\\s*)?r\\d+\\s*[-::]+\\s*(.+)$");
|
||||||
|
|
||||||
private final OkHttpClient httpClient = new OkHttpClient.Builder()
|
private final OkHttpClient httpClient = new OkHttpClient.Builder()
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
@@ -63,8 +82,14 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
|
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
|
||||||
context.setProgress(10, "正在读取 Markdown 文件");
|
final Path outputRoot = outputFileService.getOutputRoot();
|
||||||
|
final List<String> requestedPaths = request.getFilePaths() == null
|
||||||
|
? java.util.Collections.<String>emptyList()
|
||||||
|
: request.getFilePaths();
|
||||||
|
context.setProgress(10, "正在读取 Markdown 文件,输出目录: " + outputRoot);
|
||||||
|
context.setProgress(12, "待处理文件: " + joinPaths(requestedPaths));
|
||||||
final List<Path> markdownFiles = resolveUserFiles(request.getFilePaths());
|
final List<Path> markdownFiles = resolveUserFiles(request.getFilePaths());
|
||||||
|
context.setProgress(18, "路径解析完成: " + joinResolvedPaths(markdownFiles));
|
||||||
aiInputValidator.validate(markdownFiles);
|
aiInputValidator.validate(markdownFiles);
|
||||||
final String content = readMarkdownFiles(markdownFiles);
|
final String content = readMarkdownFiles(markdownFiles);
|
||||||
|
|
||||||
@@ -79,7 +104,7 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final String prompt = buildPrompt(content, period);
|
final String prompt = buildPrompt(content, period);
|
||||||
final String aiResponse = callDeepSeek(apiKey, prompt);
|
final String aiResponse = callDeepSeek(apiKey, prompt, context);
|
||||||
final JsonObject payload = extractJson(aiResponse);
|
final JsonObject payload = extractJson(aiResponse);
|
||||||
|
|
||||||
context.setProgress(75, "正在生成 Excel 文件");
|
context.setProgress(75, "正在生成 Excel 文件");
|
||||||
@@ -121,39 +146,101 @@ public class AiWorkflowService {
|
|||||||
throw new IllegalArgumentException("文件路径不能为空");
|
throw new IllegalArgumentException("文件路径不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final String normalizedInput = userPath.trim();
|
||||||
final Path outputRoot = outputFileService.getOutputRoot();
|
final Path outputRoot = outputFileService.getOutputRoot();
|
||||||
final Path rootPath = Paths.get("").toAbsolutePath().normalize();
|
final Path rootPath = Paths.get("").toAbsolutePath().normalize();
|
||||||
final Path candidate = rootPath.resolve(userPath).normalize();
|
final Path docsRoot = rootPath.resolve("docs").normalize();
|
||||||
|
|
||||||
if (candidate.startsWith(outputRoot) || candidate.startsWith(rootPath.resolve("docs").normalize())) {
|
// 优先按输出目录相对路径解析(例如 md/*.md、excel/*.xlsx)
|
||||||
|
final Path outputCandidate = outputFileService.resolveInOutput(normalizedInput);
|
||||||
|
if (Files.exists(outputCandidate) && Files.isRegularFile(outputCandidate)) {
|
||||||
|
return outputCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容绝对路径或历史路径输入,但仍限制在允许目录内
|
||||||
|
final Path raw = Paths.get(normalizedInput);
|
||||||
|
final Path candidate = raw.isAbsolute() ? raw.normalize() : rootPath.resolve(raw).normalize();
|
||||||
|
|
||||||
|
if (candidate.startsWith(outputRoot) || candidate.startsWith(docsRoot)) {
|
||||||
if (Files.exists(candidate) && Files.isRegularFile(candidate)) {
|
if (Files.exists(candidate) && Files.isRegularFile(candidate)) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new IllegalArgumentException("文件不存在或不在允许目录:" + userPath);
|
final boolean outputCandidateExists = Files.exists(outputCandidate);
|
||||||
|
final boolean outputCandidateIsFile = outputCandidateExists && Files.isRegularFile(outputCandidate);
|
||||||
|
final boolean rootCandidateExists = Files.exists(candidate);
|
||||||
|
final boolean rootCandidateIsFile = rootCandidateExists && Files.isRegularFile(candidate);
|
||||||
|
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"文件不存在或不在允许目录:" + normalizedInput
|
||||||
|
+ " | outputCandidate=" + outputCandidate
|
||||||
|
+ " (exists=" + outputCandidateExists + ", file=" + outputCandidateIsFile + ")"
|
||||||
|
+ " | rootCandidate=" + candidate
|
||||||
|
+ " (exists=" + rootCandidateExists + ", file=" + rootCandidateIsFile + ")"
|
||||||
|
+ " | outputRoot=" + outputRoot
|
||||||
|
+ " | docsRoot=" + docsRoot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String joinPaths(List<String> paths) {
|
||||||
|
if (paths == null || paths.isEmpty()) {
|
||||||
|
return "(empty)";
|
||||||
|
}
|
||||||
|
final StringBuilder builder = new StringBuilder();
|
||||||
|
for (int i = 0; i < paths.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
builder.append(", ");
|
||||||
|
}
|
||||||
|
builder.append(paths.get(i));
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String joinResolvedPaths(List<Path> paths) {
|
||||||
|
if (paths == null || paths.isEmpty()) {
|
||||||
|
return "(empty)";
|
||||||
|
}
|
||||||
|
final StringBuilder builder = new StringBuilder();
|
||||||
|
for (int i = 0; i < paths.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
builder.append(", ");
|
||||||
|
}
|
||||||
|
builder.append(paths.get(i).toString());
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildPrompt(String markdownContent, String period) {
|
private String buildPrompt(String markdownContent, String period) {
|
||||||
return "你是项目管理助手,请根据以下 SVN Markdown 日志生成工作量统计 JSON。\n"
|
return "你是项目管理助手,请根据以下 SVN Markdown 日志生成工作量统计 JSON。\n"
|
||||||
+ "工作周期: " + period + "\n"
|
+ "工作周期: " + period + "\n"
|
||||||
+ "要求:仅输出 JSON,不要输出额外文字。\n"
|
+ "要求:仅输出 JSON,不要输出额外文字。\n"
|
||||||
|
+ "固定字段要求:\n"
|
||||||
|
+ "1. team 固定为 \"系统部\"\n"
|
||||||
|
+ "2. contact 固定为 \"杨志强\\n(系统平台组)\"\n"
|
||||||
|
+ "3. developer 固定为 \"刘靖\"\n"
|
||||||
|
+ "4. records 固定仅 1 条,sequence 固定为 1\n"
|
||||||
|
+ "5. project 固定为 \"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控\"\n"
|
||||||
|
+ "6. content 必须按下面三段输出,且每段使用数字编号,不要包含 SVN 地址/仓库路径/版本范围/提交总数等元信息:\n"
|
||||||
|
+ "PRS-7050场站智慧管控\\n1. xxx\\n2. xxx\\n\\n"
|
||||||
|
+ "PRS-7950在线巡视\\n1. xxx\\n2. xxx\\n\\n"
|
||||||
|
+ "PRS-7950在线巡视电科院测试版\\n1. xxx\\n2. xxx\n"
|
||||||
+ "JSON结构:\n"
|
+ "JSON结构:\n"
|
||||||
+ "{\n"
|
+ "{\n"
|
||||||
+ " \"team\": \"所属班组\",\n"
|
+ " \"team\": \"系统部\",\n"
|
||||||
+ " \"contact\": \"技术对接人\",\n"
|
+ " \"contact\": \"杨志强\\n(系统平台组)\",\n"
|
||||||
+ " \"developer\": \"开发人员\",\n"
|
+ " \"developer\": \"刘靖\",\n"
|
||||||
+ " \"period\": \"" + period + "\",\n"
|
+ " \"period\": \"" + period + "\",\n"
|
||||||
+ " \"records\": [\n"
|
+ " \"records\": [\n"
|
||||||
+ " {\"sequence\":1,\"project\":\"项目A/项目B\",\"content\":\"# 项目A\\n1.xxx\\n2.xxx\"}\n"
|
+ " {\"sequence\":1,\"project\":\"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控\",\"content\":\"PRS-7050场站智慧管控\\n1.xxx\\n2.xxx\\n\\nPRS-7950在线巡视\\n1.xxx\\n2.xxx\\n\\nPRS-7950在线巡视电科院测试版\\n1.xxx\"}\n"
|
||||||
+ " ]\n"
|
+ " ]\n"
|
||||||
+ "}\n\n"
|
+ "}\n\n"
|
||||||
+ "日志内容:\n" + markdownContent;
|
+ "日志内容:\n" + markdownContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String callDeepSeek(String apiKey, String prompt) throws IOException {
|
private String callDeepSeek(String apiKey, String prompt, TaskContext context) throws IOException {
|
||||||
try {
|
try {
|
||||||
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt), 3, 1000L);
|
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt, context), 3, 1000L);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -161,7 +248,7 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String callDeepSeekOnce(String apiKey, String prompt) throws Exception {
|
private String callDeepSeekOnce(String apiKey, String prompt, TaskContext context) throws Exception {
|
||||||
final JsonObject message = new JsonObject();
|
final JsonObject message = new JsonObject();
|
||||||
message.addProperty("role", "user");
|
message.addProperty("role", "user");
|
||||||
message.addProperty("content", prompt);
|
message.addProperty("content", prompt);
|
||||||
@@ -173,7 +260,13 @@ public class AiWorkflowService {
|
|||||||
body.addProperty("model", "deepseek-reasoner");
|
body.addProperty("model", "deepseek-reasoner");
|
||||||
body.add("messages", messages);
|
body.add("messages", messages);
|
||||||
body.addProperty("max_tokens", 3500);
|
body.addProperty("max_tokens", 3500);
|
||||||
body.addProperty("stream", false);
|
body.addProperty("stream", true);
|
||||||
|
final JsonObject responseFormat = new JsonObject();
|
||||||
|
responseFormat.addProperty("type", "json_object");
|
||||||
|
body.add("response_format", responseFormat);
|
||||||
|
final JsonObject streamOptions = new JsonObject();
|
||||||
|
streamOptions.addProperty("include_usage", true);
|
||||||
|
body.add("stream_options", streamOptions);
|
||||||
|
|
||||||
final Request request = new Request.Builder()
|
final Request request = new Request.Builder()
|
||||||
.url(DEEPSEEK_API_URL)
|
.url(DEEPSEEK_API_URL)
|
||||||
@@ -198,19 +291,96 @@ public class AiWorkflowService {
|
|||||||
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
|
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
|
||||||
}
|
}
|
||||||
|
|
||||||
final String raw = response.body().string();
|
final StringBuilder answerBuilder = new StringBuilder();
|
||||||
final JsonObject data = JsonParser.parseString(raw).getAsJsonObject();
|
final okhttp3.ResponseBody responseBody = response.body();
|
||||||
final JsonArray choices = data.getAsJsonArray("choices");
|
final okio.BufferedSource source = responseBody.source();
|
||||||
if (choices == null || choices.size() == 0) {
|
String finishReason = "";
|
||||||
throw new IllegalStateException("DeepSeek API 未返回可用结果");
|
|
||||||
|
context.emitEvent("phase", buildEventPayload("正在流式接收 DeepSeek 输出"));
|
||||||
|
while (!source.exhausted()) {
|
||||||
|
final String line = source.readUtf8Line();
|
||||||
|
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String dataLine = line.substring(5).trim();
|
||||||
|
if ("[DONE]".equals(dataLine)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
|
||||||
|
if (data.has("usage") && data.get("usage").isJsonObject()) {
|
||||||
|
final JsonObject usage = data.getAsJsonObject("usage");
|
||||||
|
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
|
||||||
|
usagePayload.put("promptTokens", optLong(usage, "prompt_tokens"));
|
||||||
|
usagePayload.put("completionTokens", optLong(usage, "completion_tokens"));
|
||||||
|
usagePayload.put("totalTokens", optLong(usage, "total_tokens"));
|
||||||
|
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
|
||||||
|
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
|
||||||
|
context.emitEvent("usage", usagePayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonArray choices = data.getAsJsonArray("choices");
|
||||||
|
if (choices == null || choices.size() == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject first = choices.get(0).getAsJsonObject();
|
||||||
|
if (first.has("delta") && first.get("delta").isJsonObject()) {
|
||||||
|
final JsonObject delta = first.getAsJsonObject("delta");
|
||||||
|
|
||||||
|
final String reasoning = optString(delta, "reasoning_content");
|
||||||
|
if (reasoning != null && !reasoning.isEmpty()) {
|
||||||
|
context.emitEvent("reasoning_delta", buildTextPayload(reasoning));
|
||||||
|
}
|
||||||
|
|
||||||
|
final String answer = optString(delta, "content");
|
||||||
|
if (answer != null && !answer.isEmpty()) {
|
||||||
|
answerBuilder.append(answer);
|
||||||
|
context.emitEvent("answer_delta", buildTextPayload(answer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.has("finish_reason") && !first.get("finish_reason").isJsonNull()) {
|
||||||
|
finishReason = first.get("finish_reason").getAsString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final JsonObject first = choices.get(0).getAsJsonObject();
|
if ("length".equalsIgnoreCase(finishReason)) {
|
||||||
final JsonObject messageObj = first.getAsJsonObject("message");
|
throw new IllegalStateException("DeepSeek 输出被截断(finish_reason=length),请增大 max_tokens 或缩短输入");
|
||||||
if (messageObj == null || !messageObj.has("content")) {
|
|
||||||
throw new IllegalStateException("DeepSeek API 响应缺少 content 字段");
|
|
||||||
}
|
}
|
||||||
return messageObj.get("content").getAsString();
|
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
|
||||||
|
throw new RetrySupport.RetryableException("DeepSeek 资源不足,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
final String answer = answerBuilder.toString().trim();
|
||||||
|
if (answer.isEmpty()) {
|
||||||
|
throw new IllegalStateException("DeepSeek 未返回有效 content 内容");
|
||||||
|
}
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildTextPayload(String text) {
|
||||||
|
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
|
||||||
|
payload.put("text", text);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildEventPayload(String message) {
|
||||||
|
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
|
||||||
|
payload.put("message", message);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long optLong(JsonObject object, String key) {
|
||||||
|
if (object == null || key == null || !object.has(key) || object.get(key).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.valueOf(object.get(key).getAsLong());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,10 +409,14 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void writeExcel(Path outputFile, JsonObject payload, String defaultPeriod) throws IOException {
|
private void writeExcel(Path outputFile, JsonObject payload, String defaultPeriod) throws IOException {
|
||||||
final String team = optString(payload, "team");
|
final String period = payload.has("period")
|
||||||
final String contact = optString(payload, "contact");
|
? firstNonBlank(optString(payload, "period"), defaultPeriod)
|
||||||
final String developer = optString(payload, "developer");
|
: defaultPeriod;
|
||||||
final String period = payload.has("period") ? optString(payload, "period") : defaultPeriod;
|
final String team = DEFAULT_TEAM;
|
||||||
|
final String contact = DEFAULT_CONTACT;
|
||||||
|
final String developer = DEFAULT_DEVELOPER;
|
||||||
|
final String project = FIXED_PROJECT_VALUE;
|
||||||
|
final String content = buildContentFromPayload(payload);
|
||||||
|
|
||||||
try (Workbook workbook = new XSSFWorkbook()) {
|
try (Workbook workbook = new XSSFWorkbook()) {
|
||||||
final Sheet sheet = workbook.createSheet("工作量统计");
|
final Sheet sheet = workbook.createSheet("工作量统计");
|
||||||
@@ -259,19 +433,15 @@ public class AiWorkflowService {
|
|||||||
cell.setCellStyle(headerStyle);
|
cell.setCellStyle(headerStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
|
final Row row = sheet.createRow(1);
|
||||||
for (int i = 0; i < records.size(); i++) {
|
row.setHeightInPoints(calculateRowHeight(content));
|
||||||
final JsonObject record = records.get(i).getAsJsonObject();
|
createCell(row, 0, 1, textStyle);
|
||||||
final Row row = sheet.createRow(i + 1);
|
createCell(row, 1, team, textStyle);
|
||||||
|
createCell(row, 2, contact, textStyle);
|
||||||
createCell(row, 0, getAsInt(record.get("sequence"), i + 1), textStyle);
|
createCell(row, 3, developer, textStyle);
|
||||||
createCell(row, 1, team, textStyle);
|
createCell(row, 4, period, textStyle);
|
||||||
createCell(row, 2, contact, textStyle);
|
createCell(row, 5, project, textStyle);
|
||||||
createCell(row, 3, developer, textStyle);
|
createCell(row, 6, content, contentStyle);
|
||||||
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(0, 2200);
|
||||||
sheet.setColumnWidth(1, 4200);
|
sheet.setColumnWidth(1, 4200);
|
||||||
@@ -360,6 +530,164 @@ public class AiWorkflowService {
|
|||||||
return object.get(key).getAsString();
|
return object.get(key).getAsString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String buildContentFromPayload(JsonObject payload) {
|
||||||
|
final Map<String, LinkedHashSet<String>> groupedItems = createGroupedItems();
|
||||||
|
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
|
||||||
|
for (JsonElement element : records) {
|
||||||
|
if (element == null || !element.isJsonObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final JsonObject record = element.getAsJsonObject();
|
||||||
|
final String recordProject = optString(record, "project");
|
||||||
|
final String recordContent = optString(record, "content");
|
||||||
|
collectItems(groupedItems, recordProject, recordContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
final StringBuilder builder = new StringBuilder();
|
||||||
|
for (String project : FIXED_PROJECTS) {
|
||||||
|
final LinkedHashSet<String> items = groupedItems.get(project);
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (builder.length() > 0) {
|
||||||
|
builder.append("\n\n");
|
||||||
|
}
|
||||||
|
builder.append(project).append('\n');
|
||||||
|
int index = 1;
|
||||||
|
for (String item : items) {
|
||||||
|
builder.append(index++).append(". ").append(item).append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, LinkedHashSet<String>> createGroupedItems() {
|
||||||
|
final Map<String, LinkedHashSet<String>> groupedItems =
|
||||||
|
new LinkedHashMap<String, LinkedHashSet<String>>();
|
||||||
|
for (String project : FIXED_PROJECTS) {
|
||||||
|
groupedItems.put(project, new LinkedHashSet<String>());
|
||||||
|
}
|
||||||
|
return groupedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectItems(Map<String, LinkedHashSet<String>> groupedItems, String projectText, String content) {
|
||||||
|
final String fallbackProject = normalizeProject(projectText);
|
||||||
|
String currentProject = fallbackProject;
|
||||||
|
final String[] lines = content == null ? new String[0] : content.split("\\r?\\n");
|
||||||
|
for (String rawLine : lines) {
|
||||||
|
final String line = rawLine == null ? "" : rawLine.trim();
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String headingProject = parseHeadingProject(line);
|
||||||
|
if (headingProject != null) {
|
||||||
|
currentProject = headingProject;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String extracted = parseWorkItem(line);
|
||||||
|
if (extracted == null || extracted.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject;
|
||||||
|
groupedItems.get(targetProject).add(extracted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseHeadingProject(String line) {
|
||||||
|
if (line.startsWith("#")) {
|
||||||
|
final String stripped = line.replaceFirst("^#+\\s*", "");
|
||||||
|
return normalizeProject(stripped);
|
||||||
|
}
|
||||||
|
return normalizeProject(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseWorkItem(String line) {
|
||||||
|
if (isMetaLine(line)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher matcher = NUMBERED_ITEM_PATTERN.matcher(line);
|
||||||
|
if (matcher.matches()) {
|
||||||
|
return cleanWorkItem(matcher.group(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher = BULLET_ITEM_PATTERN.matcher(line);
|
||||||
|
if (matcher.matches()) {
|
||||||
|
return cleanWorkItem(matcher.group(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher = REVISION_ITEM_PATTERN.matcher(line);
|
||||||
|
if (matcher.matches()) {
|
||||||
|
return cleanWorkItem(matcher.group(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.length() > 6 && !line.startsWith("=") && !line.startsWith("```")) {
|
||||||
|
return cleanWorkItem(line);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isMetaLine(String line) {
|
||||||
|
return line.startsWith("SVN")
|
||||||
|
|| line.startsWith("仓库")
|
||||||
|
|| line.startsWith("分支")
|
||||||
|
|| line.startsWith("版本")
|
||||||
|
|| line.startsWith("提交总数")
|
||||||
|
|| line.startsWith("日志详情")
|
||||||
|
|| line.startsWith("作者")
|
||||||
|
|| line.startsWith("时间")
|
||||||
|
|| line.startsWith("消息")
|
||||||
|
|| line.startsWith("文件")
|
||||||
|
|| line.startsWith("=== 文件:");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cleanWorkItem(String item) {
|
||||||
|
String cleaned = item == null ? "" : item.trim();
|
||||||
|
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
|
||||||
|
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
|
||||||
|
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
|
||||||
|
cleaned = cleaned.replaceAll("\\s+", " ");
|
||||||
|
return cleaned.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeProject(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String input = value.trim();
|
||||||
|
if (input.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (input.contains("7050")) {
|
||||||
|
return "PRS-7050场站智慧管控";
|
||||||
|
}
|
||||||
|
if (input.contains("电科院")) {
|
||||||
|
return "PRS-7950在线巡视电科院测试版";
|
||||||
|
}
|
||||||
|
if (input.contains("7950")) {
|
||||||
|
return "PRS-7950在线巡视";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float calculateRowHeight(String content) {
|
||||||
|
final String safeContent = content == null ? "" : content;
|
||||||
|
final String[] lines = safeContent.split("\\r?\\n");
|
||||||
|
final int visibleLines = Math.max(lines.length, 1);
|
||||||
|
final float lineHeight = 19.0f;
|
||||||
|
final float minHeight = 220.0f;
|
||||||
|
return Math.max(minHeight, visibleLines * lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String preferred, String fallback) {
|
||||||
|
if (preferred != null && !preferred.trim().isEmpty()) {
|
||||||
|
return preferred.trim();
|
||||||
|
}
|
||||||
|
return fallback == null ? "" : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
private String sanitize(String value) {
|
private String sanitize(String value) {
|
||||||
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_");
|
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ public class OutputFileService {
|
|||||||
final Path root = getOutputRoot();
|
final Path root = getOutputRoot();
|
||||||
final Path resolved = root.resolve(relative).normalize();
|
final Path resolved = root.resolve(relative).normalize();
|
||||||
if (!resolved.startsWith(root)) {
|
if (!resolved.startsWith(root)) {
|
||||||
throw new IllegalArgumentException("非法文件路径");
|
throw new IllegalArgumentException(
|
||||||
|
"非法文件路径: relative=" + relative + ", resolved=" + resolved + ", outputRoot=" + root
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class SettingsService {
|
|||||||
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
||||||
this.outputFileService = outputFileService;
|
this.outputFileService = outputFileService;
|
||||||
this.svnPresetService = svnPresetService;
|
this.svnPresetService = svnPresetService;
|
||||||
this.defaultSvnPresetId = svnPresetService.firstPresetId();
|
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> getSettings() throws IOException {
|
public Map<String, Object> getSettings() throws IOException {
|
||||||
@@ -26,7 +26,7 @@ public class SettingsService {
|
|||||||
final String activeKey = pickActiveKey(null);
|
final String activeKey = pickActiveKey(null);
|
||||||
|
|
||||||
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
|
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
|
||||||
result.put("apiKeySource", runtimeApiKey != null ? "runtime" : (envKey != null ? "env" : "none"));
|
result.put("apiKeySource", detectApiKeySource(envKey));
|
||||||
result.put("outputDir", outputFileService.getOutputRoot().toString());
|
result.put("outputDir", outputFileService.getOutputRoot().toString());
|
||||||
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
|
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
|
||||||
return result;
|
return result;
|
||||||
@@ -58,6 +58,16 @@ public class SettingsService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String detectApiKeySource(String envKey) {
|
||||||
|
if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) {
|
||||||
|
return "runtime";
|
||||||
|
}
|
||||||
|
if (envKey != null && !envKey.trim().isEmpty()) {
|
||||||
|
return "env";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
public String getDefaultSvnPresetId() {
|
public String getDefaultSvnPresetId() {
|
||||||
if (svnPresetService.containsPresetId(defaultSvnPresetId)) {
|
if (svnPresetService.containsPresetId(defaultSvnPresetId)) {
|
||||||
return defaultSvnPresetId;
|
return defaultSvnPresetId;
|
||||||
|
|||||||
@@ -2,41 +2,66 @@ package com.svnlog.web.service;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.svnlog.web.config.SvnPresetProperties;
|
||||||
import com.svnlog.web.model.SvnPreset;
|
import com.svnlog.web.model.SvnPreset;
|
||||||
|
import com.svnlog.web.model.SvnPresetSummary;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SvnPresetService {
|
public class SvnPresetService {
|
||||||
|
|
||||||
private final List<SvnPreset> presets;
|
private final List<SvnPreset> presets;
|
||||||
|
private final String configuredDefaultPresetId;
|
||||||
|
|
||||||
public SvnPresetService() {
|
public SvnPresetService(SvnPresetProperties properties) {
|
||||||
List<SvnPreset> list = new ArrayList<SvnPreset>();
|
final List<SvnPreset> source = properties.getPresets() == null
|
||||||
list.add(new SvnPreset(
|
? Collections.<SvnPreset>emptyList()
|
||||||
"preset-1",
|
: properties.getPresets();
|
||||||
"PRS-7050场站智慧管控",
|
if (source.isEmpty()) {
|
||||||
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00/src_java"
|
throw new IllegalStateException("SVN 预设未配置,请检查 application.properties 中的 svn.presets");
|
||||||
));
|
}
|
||||||
list.add(new SvnPreset(
|
|
||||||
"preset-2",
|
final List<SvnPreset> list = new ArrayList<SvnPreset>();
|
||||||
"PRS-7950在线巡视",
|
final Set<String> ids = new HashSet<String>();
|
||||||
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00/src_java"
|
for (SvnPreset preset : source) {
|
||||||
));
|
final String id = trim(preset.getId());
|
||||||
list.add(new SvnPreset(
|
final String name = trim(preset.getName());
|
||||||
"preset-3",
|
final String url = trim(preset.getUrl());
|
||||||
"PRS-7950在线巡视电科院测试版",
|
if (id.isEmpty() || name.isEmpty() || url.isEmpty()) {
|
||||||
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024/src_java"
|
throw new IllegalStateException("SVN 预设配置不完整,id/name/url 均不能为空");
|
||||||
));
|
}
|
||||||
|
if (!ids.add(id)) {
|
||||||
|
throw new IllegalStateException("SVN 预设 id 重复: " + id);
|
||||||
|
}
|
||||||
|
list.add(new SvnPreset(id, name, url));
|
||||||
|
}
|
||||||
this.presets = Collections.unmodifiableList(list);
|
this.presets = Collections.unmodifiableList(list);
|
||||||
|
|
||||||
|
final String configured = trim(properties.getDefaultPresetId());
|
||||||
|
if (!configured.isEmpty() && containsPresetId(configured)) {
|
||||||
|
this.configuredDefaultPresetId = configured;
|
||||||
|
} else {
|
||||||
|
this.configuredDefaultPresetId = this.presets.get(0).getId();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SvnPreset> listPresets() {
|
public List<SvnPreset> listPresets() {
|
||||||
return presets;
|
return presets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<SvnPresetSummary> listPresetSummaries() {
|
||||||
|
final List<SvnPresetSummary> summaries = new ArrayList<SvnPresetSummary>();
|
||||||
|
for (SvnPreset preset : presets) {
|
||||||
|
summaries.add(new SvnPresetSummary(preset.getId(), preset.getName()));
|
||||||
|
}
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean containsPresetId(String presetId) {
|
public boolean containsPresetId(String presetId) {
|
||||||
if (presetId == null || presetId.trim().isEmpty()) {
|
if (presetId == null || presetId.trim().isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -49,7 +74,25 @@ public class SvnPresetService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SvnPreset getById(String presetId) {
|
||||||
|
final String id = trim(presetId);
|
||||||
|
for (SvnPreset preset : presets) {
|
||||||
|
if (id.equals(preset.getId())) {
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("无效的 SVN 预设ID: " + presetId);
|
||||||
|
}
|
||||||
|
|
||||||
public String firstPresetId() {
|
public String firstPresetId() {
|
||||||
return presets.isEmpty() ? "" : presets.get(0).getId();
|
return presets.isEmpty() ? "" : presets.get(0).getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String configuredDefaultPresetId() {
|
||||||
|
return configuredDefaultPresetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package com.svnlog.web.service;
|
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.nio.file.Path;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -12,29 +8,35 @@ import java.util.List;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.tmatesoft.svn.core.SVNException;
|
import org.tmatesoft.svn.core.SVNException;
|
||||||
|
|
||||||
import com.svnlog.LogEntry;
|
import com.svnlog.core.report.MarkdownReportWriter;
|
||||||
import com.svnlog.SVNLogFetcher;
|
import com.svnlog.core.svn.LogEntry;
|
||||||
|
import com.svnlog.core.svn.SVNLogFetcher;
|
||||||
import com.svnlog.web.dto.SvnConnectionRequest;
|
import com.svnlog.web.dto.SvnConnectionRequest;
|
||||||
import com.svnlog.web.dto.SvnFetchRequest;
|
import com.svnlog.web.dto.SvnFetchRequest;
|
||||||
|
import com.svnlog.web.model.SvnPreset;
|
||||||
import com.svnlog.web.model.TaskResult;
|
import com.svnlog.web.model.TaskResult;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SvnWorkflowService {
|
public class SvnWorkflowService {
|
||||||
|
|
||||||
private final OutputFileService outputFileService;
|
private final OutputFileService outputFileService;
|
||||||
|
private final SvnPresetService svnPresetService;
|
||||||
|
|
||||||
public SvnWorkflowService(OutputFileService outputFileService) {
|
public SvnWorkflowService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
||||||
this.outputFileService = outputFileService;
|
this.outputFileService = outputFileService;
|
||||||
|
this.svnPresetService = svnPresetService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void testConnection(SvnConnectionRequest request) throws SVNException {
|
public void testConnection(SvnConnectionRequest request) throws SVNException {
|
||||||
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
|
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||||
|
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword());
|
||||||
fetcher.testConnection();
|
fetcher.testConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception {
|
public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception {
|
||||||
context.setProgress(10, "正在连接 SVN 仓库");
|
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||||
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
|
context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName());
|
||||||
|
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword());
|
||||||
fetcher.testConnection();
|
fetcher.testConnection();
|
||||||
|
|
||||||
context.setProgress(30, "正在拉取 SVN 日志");
|
context.setProgress(30, "正在拉取 SVN 日志");
|
||||||
@@ -50,13 +52,25 @@ public class SvnWorkflowService {
|
|||||||
context.setProgress(70, "正在生成 Markdown 文件");
|
context.setProgress(70, "正在生成 Markdown 文件");
|
||||||
final String projectName = request.getProjectName() != null && !request.getProjectName().trim().isEmpty()
|
final String projectName = request.getProjectName() != null && !request.getProjectName().trim().isEmpty()
|
||||||
? request.getProjectName().trim()
|
? request.getProjectName().trim()
|
||||||
: "custom";
|
: preset.getName();
|
||||||
final String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
|
final String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
|
||||||
final String fileName = "md/svn_log_" + sanitize(projectName) + "_" + timestamp + ".md";
|
final String fileName = "md/svn_log_" + sanitize(projectName) + "_" + timestamp + ".md";
|
||||||
final Path outputPath = outputFileService.resolveInOutput(fileName);
|
final Path outputPath = outputFileService.resolveInOutput(fileName);
|
||||||
|
|
||||||
Files.createDirectories(outputPath.getParent());
|
MarkdownReportWriter.write(
|
||||||
writeMarkdown(outputPath, request, start, end, logs, fetcher);
|
outputPath,
|
||||||
|
preset.getUrl(),
|
||||||
|
request.getUsername(),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
request.getFilterUser(),
|
||||||
|
logs,
|
||||||
|
fetcher,
|
||||||
|
new MarkdownReportWriter.Options()
|
||||||
|
.includeAccount(true)
|
||||||
|
.includeGeneratedTime(true)
|
||||||
|
.includeStatistics(true)
|
||||||
|
);
|
||||||
|
|
||||||
context.setProgress(100, "SVN 日志导出完成");
|
context.setProgress(100, "SVN 日志导出完成");
|
||||||
final TaskResult result = new TaskResult("成功导出 " + logs.size() + " 条日志");
|
final TaskResult result = new TaskResult("成功导出 " + logs.size() + " 条日志");
|
||||||
@@ -64,39 +78,6 @@ public class SvnWorkflowService {
|
|||||||
return result;
|
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) {
|
private String sanitize(String value) {
|
||||||
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_");
|
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
package com.svnlog.web.service;
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import com.svnlog.web.model.TaskInfo;
|
import com.svnlog.web.model.TaskInfo;
|
||||||
|
|
||||||
public class TaskContext {
|
public class TaskContext {
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface EventPublisher {
|
||||||
|
void publish(String eventName, Map<String, Object> payload);
|
||||||
|
}
|
||||||
|
|
||||||
private final TaskInfo taskInfo;
|
private final TaskInfo taskInfo;
|
||||||
private final Runnable onUpdate;
|
private final Runnable onUpdate;
|
||||||
|
private final EventPublisher eventPublisher;
|
||||||
|
|
||||||
public TaskContext(TaskInfo taskInfo, Runnable onUpdate) {
|
public TaskContext(TaskInfo taskInfo, Runnable onUpdate, EventPublisher eventPublisher) {
|
||||||
this.taskInfo = taskInfo;
|
this.taskInfo = taskInfo;
|
||||||
this.onUpdate = onUpdate;
|
this.onUpdate = onUpdate;
|
||||||
|
this.eventPublisher = eventPublisher;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setProgress(int progress, String message) {
|
public void setProgress(int progress, String message) {
|
||||||
@@ -20,5 +30,17 @@ public class TaskContext {
|
|||||||
if (onUpdate != null) {
|
if (onUpdate != null) {
|
||||||
onUpdate.run();
|
onUpdate.run();
|
||||||
}
|
}
|
||||||
|
final Map<String, Object> payload = new HashMap<String, Object>();
|
||||||
|
payload.put("taskId", taskInfo.getTaskId());
|
||||||
|
payload.put("status", taskInfo.getStatus() == null ? "" : taskInfo.getStatus().name());
|
||||||
|
payload.put("progress", taskInfo.getProgress());
|
||||||
|
payload.put("message", taskInfo.getMessage());
|
||||||
|
emitEvent("phase", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void emitEvent(String eventName, Map<String, Object> payload) {
|
||||||
|
if (eventPublisher != null && eventName != null && !eventName.trim().isEmpty()) {
|
||||||
|
eventPublisher.publish(eventName, payload == null ? new HashMap<String, Object>() : payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.svnlog.web.service;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
@@ -17,6 +19,7 @@ import java.util.concurrent.Future;
|
|||||||
import javax.annotation.PreDestroy;
|
import javax.annotation.PreDestroy;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import com.svnlog.web.model.TaskInfo;
|
import com.svnlog.web.model.TaskInfo;
|
||||||
import com.svnlog.web.model.TaskPageResult;
|
import com.svnlog.web.model.TaskPageResult;
|
||||||
@@ -33,6 +36,8 @@ public class TaskService {
|
|||||||
private final ExecutorService executor = Executors.newFixedThreadPool(4);
|
private final ExecutorService executor = Executors.newFixedThreadPool(4);
|
||||||
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
|
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
|
||||||
private final Map<String, Future<?>> futures = new ConcurrentHashMap<String, Future<?>>();
|
private final Map<String, Future<?>> futures = new ConcurrentHashMap<String, Future<?>>();
|
||||||
|
private final Map<String, CopyOnWriteArrayList<SseEmitter>> taskEmitters =
|
||||||
|
new ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>>();
|
||||||
private final TaskPersistenceService persistenceService;
|
private final TaskPersistenceService persistenceService;
|
||||||
private final OutputFileService outputFileService;
|
private final OutputFileService outputFileService;
|
||||||
|
|
||||||
@@ -56,6 +61,7 @@ public class TaskService {
|
|||||||
taskInfo.setUpdatedAt(now);
|
taskInfo.setUpdatedAt(now);
|
||||||
tasks.put(taskId, taskInfo);
|
tasks.put(taskId, taskInfo);
|
||||||
persistSafely();
|
persistSafely();
|
||||||
|
publishTaskEvent(taskId, "phase", buildPhasePayload(taskInfo));
|
||||||
|
|
||||||
Future<?> future = executor.submit(new Callable<Void>() {
|
Future<?> future = executor.submit(new Callable<Void>() {
|
||||||
@Override
|
@Override
|
||||||
@@ -123,12 +129,44 @@ public class TaskService {
|
|||||||
task.setMessage("任务已取消");
|
task.setMessage("任务已取消");
|
||||||
task.setUpdatedAt(Instant.now());
|
task.setUpdatedAt(Instant.now());
|
||||||
persistSafely();
|
persistSafely();
|
||||||
|
publishTaskEvent(taskId, "error", buildTerminalPayload(task, task.getMessage()));
|
||||||
|
completeTaskStream(taskId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SseEmitter subscribeTaskStream(String taskId) {
|
||||||
|
final TaskInfo task = tasks.get(taskId);
|
||||||
|
if (task == null) {
|
||||||
|
throw new IllegalArgumentException("任务不存在: " + taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final SseEmitter emitter = new SseEmitter(0L);
|
||||||
|
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.computeIfAbsent(
|
||||||
|
taskId, key -> new CopyOnWriteArrayList<SseEmitter>());
|
||||||
|
emitters.add(emitter);
|
||||||
|
|
||||||
|
emitter.onCompletion(() -> removeEmitter(taskId, emitter));
|
||||||
|
emitter.onTimeout(() -> removeEmitter(taskId, emitter));
|
||||||
|
emitter.onError(error -> removeEmitter(taskId, emitter));
|
||||||
|
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("phase").data(buildPhasePayload(task)));
|
||||||
|
} catch (Exception sendException) {
|
||||||
|
removeEmitter(taskId, emitter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTerminal(task.getStatus())) {
|
||||||
|
final String eventName = task.getStatus() == TaskStatus.SUCCESS ? "done" : "error";
|
||||||
|
publishTaskEvent(taskId, eventName, buildTerminalPayload(task, task.getError()));
|
||||||
|
completeTaskStream(taskId);
|
||||||
|
}
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
private void runTaskInternal(TaskInfo taskInfo, TaskRunner runner) {
|
private void runTaskInternal(TaskInfo taskInfo, TaskRunner runner) {
|
||||||
try {
|
try {
|
||||||
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
||||||
|
completeTaskStream(taskInfo.getTaskId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +174,13 @@ public class TaskService {
|
|||||||
taskInfo.setMessage("任务执行中");
|
taskInfo.setMessage("任务执行中");
|
||||||
taskInfo.setUpdatedAt(Instant.now());
|
taskInfo.setUpdatedAt(Instant.now());
|
||||||
persistSafely();
|
persistSafely();
|
||||||
|
publishTaskEvent(taskInfo.getTaskId(), "phase", buildPhasePayload(taskInfo));
|
||||||
|
|
||||||
final TaskContext context = new TaskContext(taskInfo, this::persistSafely);
|
final TaskContext context = new TaskContext(
|
||||||
|
taskInfo,
|
||||||
|
this::persistSafely,
|
||||||
|
(eventName, payload) -> publishTaskEvent(taskInfo.getTaskId(), eventName, payload)
|
||||||
|
);
|
||||||
final TaskResult result = runner.run(context);
|
final TaskResult result = runner.run(context);
|
||||||
taskInfo.setStatus(TaskStatus.SUCCESS);
|
taskInfo.setStatus(TaskStatus.SUCCESS);
|
||||||
taskInfo.setProgress(100);
|
taskInfo.setProgress(100);
|
||||||
@@ -146,24 +189,29 @@ public class TaskService {
|
|||||||
if (result != null && result.getFiles() != null) {
|
if (result != null && result.getFiles() != null) {
|
||||||
taskInfo.getFiles().addAll(result.getFiles());
|
taskInfo.getFiles().addAll(result.getFiles());
|
||||||
}
|
}
|
||||||
|
taskInfo.setUpdatedAt(Instant.now());
|
||||||
|
persistSafely();
|
||||||
|
publishTaskEvent(taskInfo.getTaskId(), "done", buildTerminalPayload(taskInfo, ""));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
||||||
taskInfo.setUpdatedAt(Instant.now());
|
taskInfo.setUpdatedAt(Instant.now());
|
||||||
persistSafely();
|
persistSafely();
|
||||||
|
publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, "任务已取消"));
|
||||||
|
completeTaskStream(taskInfo.getTaskId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
taskInfo.setStatus(TaskStatus.FAILED);
|
taskInfo.setStatus(TaskStatus.FAILED);
|
||||||
taskInfo.setError(e.getMessage());
|
taskInfo.setError(buildErrorMessage(e));
|
||||||
taskInfo.setMessage("执行失败");
|
taskInfo.setMessage("执行失败");
|
||||||
taskInfo.setUpdatedAt(Instant.now());
|
taskInfo.setUpdatedAt(Instant.now());
|
||||||
persistSafely();
|
persistSafely();
|
||||||
|
publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, taskInfo.getError()));
|
||||||
|
completeTaskStream(taskInfo.getTaskId());
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
futures.remove(taskInfo.getTaskId());
|
futures.remove(taskInfo.getTaskId());
|
||||||
}
|
}
|
||||||
|
completeTaskStream(taskInfo.getTaskId());
|
||||||
taskInfo.setUpdatedAt(Instant.now());
|
|
||||||
persistSafely();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadPersistedTasks() {
|
private void loadPersistedTasks() {
|
||||||
@@ -226,6 +274,95 @@ public class TaskService {
|
|||||||
return value != null && value.toLowerCase(Locale.ROOT).contains(keyword);
|
return value != null && value.toLowerCase(Locale.ROOT).contains(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String buildErrorMessage(Throwable throwable) {
|
||||||
|
if (throwable == null) {
|
||||||
|
return "未知异常";
|
||||||
|
}
|
||||||
|
|
||||||
|
final StringBuilder builder = new StringBuilder();
|
||||||
|
Throwable current = throwable;
|
||||||
|
int depth = 0;
|
||||||
|
while (current != null && depth < 5) {
|
||||||
|
if (depth > 0) {
|
||||||
|
builder.append(" | caused by: ");
|
||||||
|
}
|
||||||
|
final String className = current.getClass().getSimpleName();
|
||||||
|
final String message = current.getMessage() != null ? current.getMessage() : "(no message)";
|
||||||
|
builder.append(className).append(": ").append(message);
|
||||||
|
current = current.getCause();
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void publishTaskEvent(String taskId, String eventName, Map<String, Object> payload) {
|
||||||
|
if (taskId == null || eventName == null || eventName.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.get(taskId);
|
||||||
|
if (emitters == null || emitters.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (SseEmitter emitter : emitters) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name(eventName).data(payload == null ? new HashMap<String, Object>() : payload));
|
||||||
|
} catch (Exception sendException) {
|
||||||
|
removeEmitter(taskId, emitter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void completeTaskStream(String taskId) {
|
||||||
|
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.remove(taskId);
|
||||||
|
if (emitters == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (SseEmitter emitter : emitters) {
|
||||||
|
try {
|
||||||
|
emitter.complete();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// ignore completion failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeEmitter(String taskId, SseEmitter emitter) {
|
||||||
|
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.get(taskId);
|
||||||
|
if (emitters == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emitters.remove(emitter);
|
||||||
|
if (emitters.isEmpty()) {
|
||||||
|
taskEmitters.remove(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildPhasePayload(TaskInfo taskInfo) {
|
||||||
|
final Map<String, Object> payload = new HashMap<String, Object>();
|
||||||
|
payload.put("taskId", taskInfo.getTaskId());
|
||||||
|
payload.put("type", taskInfo.getType());
|
||||||
|
payload.put("status", taskInfo.getStatus() == null ? "" : taskInfo.getStatus().name());
|
||||||
|
payload.put("progress", taskInfo.getProgress());
|
||||||
|
payload.put("message", taskInfo.getMessage());
|
||||||
|
payload.put("updatedAt", taskInfo.getUpdatedAt() == null ? "" : taskInfo.getUpdatedAt().toString());
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildTerminalPayload(TaskInfo taskInfo, String detail) {
|
||||||
|
final Map<String, Object> payload = buildPhasePayload(taskInfo);
|
||||||
|
payload.put("files", new ArrayList<String>(taskInfo.getFiles()));
|
||||||
|
if (detail != null && !detail.trim().isEmpty()) {
|
||||||
|
payload.put("detail", detail);
|
||||||
|
}
|
||||||
|
payload.put("error", taskInfo.getError());
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTerminal(TaskStatus status) {
|
||||||
|
return status == TaskStatus.SUCCESS || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED;
|
||||||
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
public void destroy() {
|
public void destroy() {
|
||||||
executor.shutdownNow();
|
executor.shutdownNow();
|
||||||
|
|||||||
23
src/main/resources/application.properties
Normal file
23
src/main/resources/application.properties
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 服务器配置
|
||||||
|
server.port=18088
|
||||||
|
server.servlet.context-path=/
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
spring.servlet.multipart.max-file-size=100MB
|
||||||
|
spring.servlet.multipart.max-request-size=100MB
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logging.level.com.svnlog=INFO
|
||||||
|
logging.level.org.springframework=INFO
|
||||||
|
|
||||||
|
# SVN 预设配置
|
||||||
|
svn.default-preset-id=preset-1
|
||||||
|
svn.presets[0].id=preset-1
|
||||||
|
svn.presets[0].name=PRS-7050场站智慧管控
|
||||||
|
svn.presets[0].url=https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00
|
||||||
|
svn.presets[1].id=preset-2
|
||||||
|
svn.presets[1].name=PRS-7950在线巡视
|
||||||
|
svn.presets[1].url=https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00
|
||||||
|
svn.presets[2].id=preset-3
|
||||||
|
svn.presets[2].name=PRS-7950在线巡视电科院测试版
|
||||||
|
svn.presets[2].url=https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024
|
||||||
@@ -14,8 +14,7 @@ const CUSTOM_PRESET_ID = "custom";
|
|||||||
|
|
||||||
const viewMeta = {
|
const viewMeta = {
|
||||||
dashboard: { title: "工作台", desc: "查看系统状态与最近产物" },
|
dashboard: { title: "工作台", desc: "查看系统状态与最近产物" },
|
||||||
svn: { title: "SVN 日志抓取", desc: "配置 SVN 参数并生成 Markdown" },
|
svn: { title: "SVN 日志抓取", desc: "一键抓取SVN日志并导出工作量Excel" },
|
||||||
ai: { title: "AI 工作量分析", desc: "选择 Markdown 后生成工作量 Excel" },
|
|
||||||
history: { title: "任务历史", desc: "查看任务执行状态、日志与产物" },
|
history: { title: "任务历史", desc: "查看任务执行状态、日志与产物" },
|
||||||
settings: { title: "系统设置", desc: "配置 API Key 与输出目录" },
|
settings: { title: "系统设置", desc: "配置 API Key 与输出目录" },
|
||||||
};
|
};
|
||||||
@@ -27,6 +26,20 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
await refreshAll();
|
await refreshAll();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
|
||||||
|
// 自动填充当月默认值
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
// 月份选择器:YYYY-MM
|
||||||
|
document.querySelector("#version-month").value = `${year}-${month}`;
|
||||||
|
// 工作周期:YYYY年MM月
|
||||||
|
document.querySelector("#svn-form [name='period']").value = `${year}年${month}月`;
|
||||||
|
// 输出文件名:YYYYMM工作量统计.xlsx
|
||||||
|
document.querySelector("#svn-form [name='outputFileName']").value = `${year}${month}工作量统计.xlsx`;
|
||||||
|
|
||||||
|
// 绑定自动填充版本按钮
|
||||||
|
document.querySelector("#btn-auto-fill").addEventListener("click", onAutoFillVersions);
|
||||||
|
|
||||||
state.polling = setInterval(refreshAll, 5000);
|
state.polling = setInterval(refreshAll, 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,15 +59,11 @@ function bindForms() {
|
|||||||
const svnForm = document.querySelector("#svn-form");
|
const svnForm = document.querySelector("#svn-form");
|
||||||
svnForm.addEventListener("submit", onRunSvn);
|
svnForm.addEventListener("submit", onRunSvn);
|
||||||
|
|
||||||
const aiForm = document.querySelector("#ai-form");
|
|
||||||
aiForm.addEventListener("submit", onRunAi);
|
|
||||||
|
|
||||||
const settingsForm = document.querySelector("#settings-form");
|
const settingsForm = document.querySelector("#settings-form");
|
||||||
settingsForm.addEventListener("submit", onSaveSettings);
|
settingsForm.addEventListener("submit", onSaveSettings);
|
||||||
|
|
||||||
const svnPresetSelect = document.querySelector("#svn-preset-select");
|
|
||||||
svnPresetSelect.addEventListener("change", onSvnPresetChange);
|
|
||||||
|
|
||||||
const taskFilterBtn = document.querySelector("#btn-task-filter");
|
const taskFilterBtn = document.querySelector("#btn-task-filter");
|
||||||
if (taskFilterBtn) {
|
if (taskFilterBtn) {
|
||||||
taskFilterBtn.addEventListener("click", onTaskFilterSubmit);
|
taskFilterBtn.addEventListener("click", onTaskFilterSubmit);
|
||||||
@@ -76,9 +85,7 @@ function switchView(view) {
|
|||||||
loadTaskPage();
|
loadTaskPage();
|
||||||
renderFileTable();
|
renderFileTable();
|
||||||
}
|
}
|
||||||
if (view === "ai") {
|
|
||||||
renderMdFilePicker();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiFetch(url, options = {}) {
|
async function apiFetch(url, options = {}) {
|
||||||
@@ -109,9 +116,7 @@ async function refreshAll() {
|
|||||||
loadTaskPage();
|
loadTaskPage();
|
||||||
renderFileTable();
|
renderFileTable();
|
||||||
}
|
}
|
||||||
if (state.activeView === "ai") {
|
|
||||||
renderMdFilePicker();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
@@ -130,60 +135,31 @@ async function loadPresets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPresetSelects() {
|
function renderPresetSelects() {
|
||||||
const svnSelect = document.querySelector("#svn-preset-select");
|
|
||||||
const settingsSelect = document.querySelector("#settings-default-preset");
|
const settingsSelect = document.querySelector("#settings-default-preset");
|
||||||
svnSelect.innerHTML = "";
|
if (!settingsSelect) return;
|
||||||
|
|
||||||
settingsSelect.innerHTML = "";
|
settingsSelect.innerHTML = "";
|
||||||
|
|
||||||
state.presets.forEach((preset) => {
|
state.presets.forEach((preset) => {
|
||||||
const option1 = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option1.value = preset.id;
|
option.value = preset.id;
|
||||||
option1.textContent = `${preset.name}`;
|
option.textContent = `${preset.name}`;
|
||||||
svnSelect.appendChild(option1);
|
settingsSelect.appendChild(option);
|
||||||
|
|
||||||
const option2 = document.createElement("option");
|
|
||||||
option2.value = preset.id;
|
|
||||||
option2.textContent = `${preset.name}`;
|
|
||||||
settingsSelect.appendChild(option2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const customOption = document.createElement("option");
|
const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : "");
|
||||||
customOption.value = CUSTOM_PRESET_ID;
|
if (selected) {
|
||||||
customOption.textContent = "自定义 SVN 地址";
|
settingsSelect.value = selected;
|
||||||
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) {
|
function onSvnPresetChange(event) {
|
||||||
applyPresetToSvnForm(event.target.value);
|
// 保留空实现兼容
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPresetToSvnForm(presetId) {
|
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() {
|
function renderDashboard() {
|
||||||
@@ -229,22 +205,20 @@ function renderDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onTestConnection() {
|
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");
|
const btn = document.querySelector("#btn-test-connection");
|
||||||
setLoading(btn, true);
|
setLoading(btn, true);
|
||||||
try {
|
try {
|
||||||
|
const firstPreset = state.presets && state.presets.length > 0 ? state.presets[0] : null;
|
||||||
|
if (!firstPreset || !firstPreset.id) {
|
||||||
|
throw new Error("未加载到 SVN 预设,请刷新页面后重试");
|
||||||
|
}
|
||||||
|
|
||||||
await apiFetch("/api/svn/test-connection", {
|
await apiFetch("/api/svn/test-connection", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: payload.url,
|
presetId: firstPreset.id,
|
||||||
username: payload.username,
|
username: "liujing2",
|
||||||
password: payload.password,
|
password: "sunri@20230620*#&",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
toast("SVN 连接成功");
|
toast("SVN 连接成功");
|
||||||
@@ -255,93 +229,344 @@ async function onTestConnection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForTaskCompletion(taskId) {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const task = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`);
|
||||||
|
if (task.status === "SUCCESS") {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
if (task.status === "FAILED" || task.status === "CANCELLED") {
|
||||||
|
throw new Error(`任务 ${taskId} 执行失败: ${task.error || task.message}`);
|
||||||
|
}
|
||||||
|
// 等待2秒再查询
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onRunSvn(event) {
|
async function onRunSvn(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
const payload = readForm(form);
|
const payload = readForm(form);
|
||||||
const btn = document.querySelector("#btn-svn-run");
|
const btn = document.querySelector("#btn-svn-run");
|
||||||
|
const logPanel = document.querySelector("#log-panel");
|
||||||
|
let aiStream = null;
|
||||||
|
|
||||||
|
// 显示日志面板,清空日志
|
||||||
|
logPanel.style.display = "block";
|
||||||
|
clearLog();
|
||||||
|
appendLog("任务开始...");
|
||||||
setLoading(btn, true);
|
setLoading(btn, true);
|
||||||
|
form.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch("/api/svn/fetch", {
|
if (!state.presets || state.presets.length < 3) {
|
||||||
|
throw new Error("SVN 预设加载异常,请刷新页面后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionRanges = [
|
||||||
|
{ start: payload.startRevision_1, end: payload.endRevision_1 },
|
||||||
|
{ start: payload.startRevision_2, end: payload.endRevision_2 },
|
||||||
|
{ start: payload.startRevision_3, end: payload.endRevision_3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const projects = revisionRanges
|
||||||
|
.map((range, idx) => ({
|
||||||
|
presetId: state.presets[idx].id,
|
||||||
|
name: state.presets[idx].name,
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
}))
|
||||||
|
.filter((project) => project.start && project.end);
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
appendLog("错误:请至少填写一个项目的开始和结束版本号", true);
|
||||||
|
toast("请至少填写一个项目的开始和结束版本号", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog(`检测到 ${projects.length} 个待处理项目`);
|
||||||
|
|
||||||
|
const mdFiles = [];
|
||||||
|
for (let i = 0; i < projects.length; i++) {
|
||||||
|
const project = projects[i];
|
||||||
|
appendLog(`正在提交 ${project.name} 的抓取任务...`);
|
||||||
|
const data = await apiFetch("/api/svn/fetch", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
presetId: project.presetId,
|
||||||
|
username: "liujing2",
|
||||||
|
password: "sunri@20230620*#&",
|
||||||
|
startRevision: toNumberOrNull(project.start),
|
||||||
|
endRevision: toNumberOrNull(project.end),
|
||||||
|
filterUser: payload.filterUser || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const taskId = data.taskId;
|
||||||
|
appendLog(`已创建抓取任务:${project.name} (任务ID: ${taskId.slice(0,8)})`);
|
||||||
|
appendLog(`正在抓取 ${project.name} 日志...`);
|
||||||
|
|
||||||
|
// 严格串行:当前项目完成后才开始下一个项目
|
||||||
|
while (true) {
|
||||||
|
const task = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`);
|
||||||
|
if (task.status === "SUCCESS") {
|
||||||
|
appendLog(`${project.name} 抓取完成`);
|
||||||
|
if (task.message) appendLog(task.message);
|
||||||
|
if (task.files && task.files.length > 0) {
|
||||||
|
mdFiles.push(...task.files.filter(f => f.endsWith(".md")));
|
||||||
|
appendLog(`生成文件: ${task.files.join(", ")}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (task.status === "FAILED" || task.status === "CANCELLED") {
|
||||||
|
throw new Error(
|
||||||
|
`${project.name} 抓取失败 (任务ID: ${taskId.slice(0,8)}): ${task.error || task.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 显示任务进度
|
||||||
|
if (task.message) appendLog(`[${project.name}] ${task.message} (进度: ${task.progress}%)`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog(`所有SVN抓取任务完成,共生成 ${mdFiles.length} 个Markdown文件`);
|
||||||
|
|
||||||
|
// 调用AI分析接口
|
||||||
|
appendLog("正在提交AI分析任务...");
|
||||||
|
const aiData = await apiFetch("/api/ai/analyze", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
projectName: payload.projectName || "",
|
filePaths: mdFiles,
|
||||||
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 || "",
|
period: payload.period || "",
|
||||||
apiKey: payload.apiKey || "",
|
apiKey: "", // 使用内置API Key
|
||||||
outputFileName: payload.outputFileName || "",
|
outputFileName: payload.outputFileName || "",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
toast(`AI 分析任务已创建:${data.taskId}`);
|
|
||||||
switchView("history");
|
appendSystemLog(`AI分析任务已创建 (任务ID: ${aiData.taskId.slice(0,8)})`);
|
||||||
|
appendSystemLog("正在进行AI分析,请耐心等待...");
|
||||||
|
|
||||||
|
const streamState = {
|
||||||
|
reasoningBuffer: "",
|
||||||
|
answerBuffer: "",
|
||||||
|
streamAvailable: true,
|
||||||
|
};
|
||||||
|
aiStream = openTaskEventStream(aiData.taskId, {
|
||||||
|
onPhase: (payload) => {
|
||||||
|
if (payload && payload.message) {
|
||||||
|
appendSystemLog(payload.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReasoning: (text) => {
|
||||||
|
if (!text) return;
|
||||||
|
streamState.reasoningBuffer += text;
|
||||||
|
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", false);
|
||||||
|
},
|
||||||
|
onAnswer: (text) => {
|
||||||
|
if (!text) return;
|
||||||
|
streamState.answerBuffer += text;
|
||||||
|
flushStreamBuffer(streamState, "answerBuffer", "answer", false);
|
||||||
|
},
|
||||||
|
onUsage: (payload) => {
|
||||||
|
if (!payload) return;
|
||||||
|
appendSystemLog(`Token统计: prompt=${payload.promptTokens || 0}, completion=${payload.completionTokens || 0}, total=${payload.totalTokens || 0}`);
|
||||||
|
},
|
||||||
|
onError: (payload) => {
|
||||||
|
if (payload && payload.detail) {
|
||||||
|
appendSystemLog(`流式错误: ${payload.detail}`, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTransportError: () => {
|
||||||
|
if (streamState.streamAvailable) {
|
||||||
|
streamState.streamAvailable = false;
|
||||||
|
appendSystemLog("实时流中断,已回退到轮询模式");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待AI任务完成,实时显示日志
|
||||||
|
while (true) {
|
||||||
|
const task = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`);
|
||||||
|
if (task.status === "SUCCESS") {
|
||||||
|
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true);
|
||||||
|
flushStreamBuffer(streamState, "answerBuffer", "answer", true);
|
||||||
|
appendSystemLog("AI分析完成");
|
||||||
|
if (task.message) appendSystemLog(task.message);
|
||||||
|
aiStream.close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (task.status === "FAILED" || task.status === "CANCELLED") {
|
||||||
|
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true);
|
||||||
|
flushStreamBuffer(streamState, "answerBuffer", "answer", true);
|
||||||
|
aiStream.close();
|
||||||
|
throw new Error(`AI分析失败: ${task.error || task.message}`);
|
||||||
|
}
|
||||||
|
// 显示AI思考过程
|
||||||
|
if (!streamState.streamAvailable && task.message) {
|
||||||
|
// 避免重复输出相同消息
|
||||||
|
const lastLog = document.querySelector("#system-log-output p:last-child");
|
||||||
|
if (!lastLog || !lastLog.textContent.includes(task.message)) {
|
||||||
|
appendSystemLog(task.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最终任务结果
|
||||||
|
const aiTask = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`);
|
||||||
|
|
||||||
|
// 找到生成的Excel文件并自动下载
|
||||||
|
if (aiTask.files && aiTask.files.length > 0) {
|
||||||
|
const excelFile = aiTask.files.find(f => f.endsWith(".xlsx"));
|
||||||
|
if (excelFile) {
|
||||||
|
appendSystemLog("Excel生成成功,开始下载...");
|
||||||
|
// 触发下载
|
||||||
|
window.open(`/api/files/download?path=${encodeURIComponent(excelFile)}`, "_blank");
|
||||||
|
appendSystemLog("✅ 任务全部完成!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
refreshAll();
|
refreshAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
appendLog(`错误: ${err.message}`, true);
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (aiStream) {
|
||||||
|
aiStream.close();
|
||||||
|
}
|
||||||
setLoading(btn, false);
|
setLoading(btn, false);
|
||||||
|
form.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTaskEventStream(taskId, handlers = {}) {
|
||||||
|
if (!window.EventSource) {
|
||||||
|
handlers.onTransportError && handlers.onTransportError();
|
||||||
|
return { close: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamUrl = `/api/tasks/${encodeURIComponent(taskId)}/stream`;
|
||||||
|
const source = new EventSource(streamUrl);
|
||||||
|
const parse = (event) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(event.data || "{}");
|
||||||
|
} catch (err) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.addEventListener("phase", (event) => {
|
||||||
|
handlers.onPhase && handlers.onPhase(parse(event));
|
||||||
|
});
|
||||||
|
source.addEventListener("reasoning_delta", (event) => {
|
||||||
|
const payload = parse(event);
|
||||||
|
handlers.onReasoning && handlers.onReasoning(payload.text || "");
|
||||||
|
});
|
||||||
|
source.addEventListener("answer_delta", (event) => {
|
||||||
|
const payload = parse(event);
|
||||||
|
handlers.onAnswer && handlers.onAnswer(payload.text || "");
|
||||||
|
});
|
||||||
|
source.addEventListener("usage", (event) => {
|
||||||
|
handlers.onUsage && handlers.onUsage(parse(event));
|
||||||
|
});
|
||||||
|
source.addEventListener("error", (event) => {
|
||||||
|
handlers.onError && handlers.onError(parse(event));
|
||||||
|
});
|
||||||
|
source.onerror = () => {
|
||||||
|
handlers.onTransportError && handlers.onTransportError();
|
||||||
|
source.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: () => {
|
||||||
|
source.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushStreamBuffer(streamState, key, target, force) {
|
||||||
|
const text = streamState[key] || "";
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldFlush = force || text.length >= 64 || /[。!?\n]$/.test(text);
|
||||||
|
if (!shouldFlush) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cleaned = text.replace(/\s+/g, " ").trim();
|
||||||
|
if (target === "reasoning") {
|
||||||
|
appendReasoning(cleaned);
|
||||||
|
} else if (target === "answer") {
|
||||||
|
appendAnswer(cleaned);
|
||||||
|
} else {
|
||||||
|
appendSystemLog(cleaned);
|
||||||
|
}
|
||||||
|
streamState[key] = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSystemLog(message, isError = false) {
|
||||||
|
const logOutput = document.querySelector("#system-log-output");
|
||||||
|
markPanelReady("#system-log-output");
|
||||||
|
const p = document.createElement("p");
|
||||||
|
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||||
|
p.style.margin = "2px 0";
|
||||||
|
if (isError) {
|
||||||
|
p.style.color = "#dc2626";
|
||||||
|
p.textContent = `[${time}] ❌ ${message}`;
|
||||||
|
} else {
|
||||||
|
p.style.color = "#1e293b";
|
||||||
|
p.textContent = `[${time}] ℹ️ ${message}`;
|
||||||
|
}
|
||||||
|
logOutput.appendChild(p);
|
||||||
|
logOutput.scrollTop = logOutput.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendReasoning(message) {
|
||||||
|
appendPane("#reasoning-output", message, "#334155");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendAnswer(message) {
|
||||||
|
appendPane("#answer-output", message, "#166534");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPane(selector, message, color) {
|
||||||
|
const logOutput = document.querySelector(selector);
|
||||||
|
markPanelReady(selector);
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.style.margin = "2px 0";
|
||||||
|
p.style.color = color;
|
||||||
|
p.textContent = message;
|
||||||
|
logOutput.appendChild(p);
|
||||||
|
logOutput.scrollTop = logOutput.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧调用
|
||||||
|
function appendLog(message, isError = false) {
|
||||||
|
appendSystemLog(message, isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
const system = document.querySelector("#system-log-output");
|
||||||
|
const reasoning = document.querySelector("#reasoning-output");
|
||||||
|
const answer = document.querySelector("#answer-output");
|
||||||
|
|
||||||
|
system.innerHTML = "<p class='muted'>等待任务开始...</p>";
|
||||||
|
reasoning.innerHTML = "<p class='muted'>等待思考输出...</p>";
|
||||||
|
answer.innerHTML = "<p class='muted'>等待答案输出...</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPanelReady(selector) {
|
||||||
|
const panel = document.querySelector(selector);
|
||||||
|
if (!panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const muted = panel.querySelector(".muted");
|
||||||
|
if (muted) {
|
||||||
|
muted.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,3 +825,64 @@ function sortByTimeDesc(left, right) {
|
|||||||
const r = right ? new Date(right).getTime() : 0;
|
const r = right ? new Date(right).getTime() : 0;
|
||||||
return r - l;
|
return r - l;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自动填充版本号
|
||||||
|
async function onAutoFillVersions() {
|
||||||
|
const btn = document.querySelector("#btn-auto-fill");
|
||||||
|
const monthInput = document.querySelector("#version-month");
|
||||||
|
const [year, month] = monthInput.value.split("-");
|
||||||
|
|
||||||
|
setLoading(btn, true);
|
||||||
|
appendLog(`开始查询 ${year}年${month}月 的版本范围...`);
|
||||||
|
|
||||||
|
if (!state.presets || state.presets.length < 3) {
|
||||||
|
appendLog("错误:未加载到完整 SVN 预设,请刷新页面后重试", true);
|
||||||
|
setLoading(btn, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = [1, 2, 3].map((index) => {
|
||||||
|
const preset = state.presets[index - 1];
|
||||||
|
return {
|
||||||
|
presetId: preset.id,
|
||||||
|
name: preset.name,
|
||||||
|
startInput: document.querySelector(`#svn-form [name='startRevision_${index}']`),
|
||||||
|
endInput: document.querySelector(`#svn-form [name='endRevision_${index}']`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const project of projects) {
|
||||||
|
appendLog(`正在查询 ${project.name} 的版本范围...`);
|
||||||
|
|
||||||
|
// 调用后端接口获取月份版本范围
|
||||||
|
const data = await apiFetch("/api/svn/version-range", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
presetId: project.presetId,
|
||||||
|
username: "liujing2",
|
||||||
|
password: "sunri@20230620*#&",
|
||||||
|
year: parseInt(year),
|
||||||
|
month: parseInt(month),
|
||||||
|
filterUser: "liujing2@SZNARI" // 只查询该用户的提交(完整用户名)
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.startRevision && data.endRevision) {
|
||||||
|
project.startInput.value = data.startRevision;
|
||||||
|
project.endInput.value = data.endRevision;
|
||||||
|
appendLog(`${project.name} 版本范围: ${data.startRevision} - ${data.endRevision}`);
|
||||||
|
} else {
|
||||||
|
appendLog(`⚠️ ${project.name} 该月份无提交记录`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog("✅ 所有项目版本号填充完成");
|
||||||
|
toast("版本号填充完成");
|
||||||
|
} catch (err) {
|
||||||
|
appendLog(`填充失败: ${err.message}`, true);
|
||||||
|
toast(err.message, true);
|
||||||
|
} finally {
|
||||||
|
setLoading(btn, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,8 @@
|
|||||||
<aside class="sidebar" aria-label="主导航">
|
<aside class="sidebar" aria-label="主导航">
|
||||||
<h1>SVN 工作台</h1>
|
<h1>SVN 工作台</h1>
|
||||||
<nav>
|
<nav>
|
||||||
<button class="nav-item active" data-view="dashboard">工作台</button>
|
<button class="nav-item" data-view="dashboard">工作台</button>
|
||||||
<button class="nav-item" data-view="svn">SVN 日志抓取</button>
|
<button class="nav-item active" 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="history">任务历史</button>
|
||||||
<button class="nav-item" data-view="settings">系统设置</button>
|
<button class="nav-item" data-view="settings">系统设置</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -64,41 +63,87 @@
|
|||||||
|
|
||||||
<section class="view" id="view-svn">
|
<section class="view" id="view-svn">
|
||||||
<article class="card form-card">
|
<article class="card form-card">
|
||||||
<h3>SVN 抓取参数</h3>
|
<h3>SVN 批量抓取参数</h3>
|
||||||
|
<div class="alert info span-2" style="margin-bottom:16px;padding:12px;border-radius:10px;background:#d1f0eb;color:#0f766e">
|
||||||
|
默认已填充3个常用项目路径,可选择月份自动填充版本号,或手动填写
|
||||||
|
</div>
|
||||||
|
<div class="span-2" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:10px;">
|
||||||
|
<div class="grid cols-3" style="gap:10px;align-items:end;">
|
||||||
|
<label>统计月份
|
||||||
|
<input type="month" id="version-month">
|
||||||
|
</label>
|
||||||
|
<div style="grid-column: span 2;">
|
||||||
|
<button type="button" id="btn-auto-fill" class="primary" style="width:100%">一键填充所有项目版本号</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form id="svn-form" class="form-grid">
|
<form id="svn-form" class="form-grid">
|
||||||
<label>预置项目
|
<!-- 项目1 -->
|
||||||
<select name="presetId" id="svn-preset-select" aria-label="预置 SVN 项目"></select>
|
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px">
|
||||||
</label>
|
<h4 style="margin:0 0 10px 0">项目 1:PRS-7050 场站智慧管控</h4>
|
||||||
<label>项目名<input name="projectName" placeholder="如:PRS-7050"></label>
|
<div class="grid cols-2" style="gap:10px">
|
||||||
<label>SVN 地址<input required name="url" placeholder="https://..." aria-label="SVN 地址"></label>
|
<label>开始版本号<input name="startRevision_1" inputmode="numeric" placeholder="请输入开始版本"></label>
|
||||||
<label>账号<input required name="username" placeholder="请输入账号"></label>
|
<label>结束版本号<input name="endRevision_1" inputmode="numeric" placeholder="请输入结束版本"></label>
|
||||||
<label>密码<input required type="password" name="password" placeholder="请输入密码"></label>
|
</div>
|
||||||
<label>开始版本号<input name="startRevision" inputmode="numeric" placeholder="默认最新"></label>
|
</div>
|
||||||
<label>结束版本号<input name="endRevision" inputmode="numeric" placeholder="默认最新"></label>
|
|
||||||
<label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤"></label>
|
<!-- 项目2 -->
|
||||||
|
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px">
|
||||||
|
<h4 style="margin:0 0 10px 0">项目 2:PRS-7950 在线巡视</h4>
|
||||||
|
<div class="grid cols-2" style="gap:10px">
|
||||||
|
<label>开始版本号<input name="startRevision_2" inputmode="numeric" placeholder="请输入开始版本"></label>
|
||||||
|
<label>结束版本号<input name="endRevision_2" inputmode="numeric" placeholder="请输入结束版本"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 项目3 -->
|
||||||
|
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:12px">
|
||||||
|
<h4 style="margin:0 0 10px 0">项目 3:PRS-7950 在线巡视电科院测试版</h4>
|
||||||
|
<div class="grid cols-2" style="gap:10px">
|
||||||
|
<label>开始版本号<input name="startRevision_3" inputmode="numeric" placeholder="请输入开始版本"></label>
|
||||||
|
<label>结束版本号<input name="endRevision_3" inputmode="numeric" placeholder="请输入结束版本"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通用配置 -->
|
||||||
|
<label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤" value="liujing"></label>
|
||||||
|
<label>工作周期<input name="period" placeholder="例如 2026年03月"></label>
|
||||||
|
<label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label>
|
||||||
|
|
||||||
<div class="actions span-2">
|
<div class="actions span-2">
|
||||||
<button type="button" id="btn-test-connection">测试连接</button>
|
<button type="button" id="btn-test-connection">测试连接</button>
|
||||||
<button type="submit" id="btn-svn-run" class="primary">开始抓取并导出</button>
|
<button type="submit" id="btn-svn-run" class="primary">一键抓取并导出 Excel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- 执行日志面板 -->
|
||||||
|
<article class="card" id="log-panel" style="display:none;margin-top:16px;">
|
||||||
|
<h3>执行进度</h3>
|
||||||
|
<div class="live-grid">
|
||||||
|
<section class="live-column reasoning">
|
||||||
|
<header>思考过程</header>
|
||||||
|
<div id="reasoning-output" class="live-output">
|
||||||
|
<p class="muted">等待思考输出...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="live-column answer">
|
||||||
|
<header>最终输出</header>
|
||||||
|
<div id="answer-output" class="live-output">
|
||||||
|
<p class="muted">等待答案输出...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="system-log-wrap">
|
||||||
|
<header>系统日志</header>
|
||||||
|
<div id="system-log-output" class="system-output">
|
||||||
|
<p class="muted">等待任务开始...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</section>
|
</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">
|
<section class="view" id="view-history">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
|
|||||||
@@ -368,6 +368,45 @@ td {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-column header,
|
||||||
|
.system-log-wrap > header {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-output,
|
||||||
|
.system-output {
|
||||||
|
height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-column.reasoning .live-output {
|
||||||
|
background: #f6fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-column.answer .live-output {
|
||||||
|
background: #f5fdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-wrap {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -399,6 +438,12 @@ td {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.live-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user