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
|
||||
*.ipr
|
||||
|
||||
# Agent / local assistant artifacts
|
||||
.claude/
|
||||
.codex
|
||||
|
||||
# Compiled class files
|
||||
*.class
|
||||
|
||||
@@ -34,6 +38,7 @@ buildNumber.properties
|
||||
# Generated files
|
||||
md/
|
||||
*.xlsx
|
||||
outputs/
|
||||
|
||||
# OS generated files
|
||||
.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. 项目概览
|
||||
- 语言与构建:Java 8 + Maven(`pom.xml`)。
|
||||
- 打包产物:可执行 fat jar(`jar-with-dependencies`)。
|
||||
- 主入口:`com.svnlog.Main`(CLI)。
|
||||
- Web 入口:`com.svnlog.WebApplication`(前后端一体,静态页面 + REST API)。
|
||||
- 其他入口:`com.svnlog.DeepSeekLogProcessor`、`com.svnlog.ExcelAnalyzer`。
|
||||
- 统一入口:`com.svnlog.web.WebApplication`(前后端一体,静态页面 + REST API)。
|
||||
- 核心目录:
|
||||
- `src/main/java/com/svnlog/`
|
||||
- `docs/`
|
||||
- SVN 预设地址:`src/main/resources/application.properties`(`svn.presets[*]`)
|
||||
|
||||
## 2. 常用命令(Build / Lint / Test / Run)
|
||||
以下命令默认在仓库根目录执行。
|
||||
@@ -36,29 +35,19 @@
|
||||
- 说明:当前 `src/test/java` 为空;新增测试时采用 Surefire 默认约定。
|
||||
|
||||
### 2.4 Run
|
||||
- 运行主程序(SVN 日志抓取):
|
||||
- `java -jar target/svn-log-tool-1.0.0-jar-with-dependencies.jar`
|
||||
- 运行 Web 工作台(推荐):
|
||||
- `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.WebApplication`
|
||||
- `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication`
|
||||
- 启动后访问:`http://localhost:8080`
|
||||
- 运行 DeepSeek 处理器:
|
||||
- `java -cp target/svn-log-tool-1.0.0-jar-with-dependencies.jar com.svnlog.DeepSeekLogProcessor`
|
||||
- Maven 方式运行 DeepSeek:
|
||||
- `mvn exec:java -Dexec.mainClass="com.svnlog.DeepSeekLogProcessor"`
|
||||
|
||||
## 3. 代码结构与职责边界
|
||||
- `Main.java`:CLI 交互、读取输入、调用 `SVNLogFetcher`、输出 Markdown。
|
||||
- `SVNLogFetcher.java`:SVN 连接、版本区间处理、日志抓取、用户过滤。
|
||||
- `LogEntry.java`:日志数据模型(POJO)。
|
||||
- `DeepSeekLogProcessor.java`:读取 Markdown、调用 DeepSeek API、生成 Excel。
|
||||
- `ExcelAnalyzer.java`:本地临时分析工具,偏实验性质。
|
||||
- `web/controller/*`:REST API(SVN、AI、任务、文件、设置)。
|
||||
- `web/service/*`:异步任务与业务编排(SVN 抓取、AI 分析、输出目录管理)。
|
||||
- `src/main/resources/static/*`:Web 前端页面与交互脚本。
|
||||
- 变更原则:
|
||||
- 抓取逻辑改在 `SVNLogFetcher`。
|
||||
- 交互逻辑改在 `Main`。
|
||||
- AI/Excel 逻辑改在 `DeepSeekLogProcessor`。
|
||||
- AI/Excel 逻辑改在 `web/service/AiWorkflowService`。
|
||||
- 不把多种职责混入同一方法。
|
||||
|
||||
## 4. 代码风格规范(必读)
|
||||
@@ -112,7 +101,7 @@
|
||||
|
||||
## 5. 安全与敏感信息
|
||||
- 严禁提交真实密钥、口令、Token、内网敏感地址。
|
||||
- `DeepSeekLogProcessor` 存在硬编码 API Key 风险;新增改动时应:
|
||||
- Web 端 AI 分析涉及 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文件:
|
||||
```bash
|
||||
java -jar svn-log-tool-1.0.0-jar-with-dependencies.jar
|
||||
```
|
||||
## API Key 读取优先级
|
||||
|
||||
按照提示输入SVN仓库地址、账号、密码等信息,生成markdown格式的日志文件。
|
||||
|
||||
### 2. 运行DeepSeek日志处理工具
|
||||
|
||||
```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. 请求中的 `apiKey`
|
||||
2. 设置页保存的运行时 `apiKey`
|
||||
3. 环境变量 `DEEPSEEK_API_KEY`
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API Key安全**:请妥善保管您的DeepSeek API Key,不要将其提交到代码仓库中
|
||||
2. **网络连接**:需要能够访问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处理
|
||||
|
||||
## 许可证
|
||||
|
||||
本工具仅供内部使用。
|
||||
- 不要在源码和日志中写入真实密钥
|
||||
- 需要可访问 DeepSeek API 的网络环境
|
||||
- 接口调用可能产生费用,建议控制调用频率
|
||||
|
||||
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 与输出目录
|
||||
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
|
||||
|
||||
批量抓取策略:多个项目按顺序执行(前一个项目完成后才开始下一个)。
|
||||
|
||||
## 启动方式
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```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
|
||||
|
||||
- `POST /api/svn/test-connection`
|
||||
@@ -57,6 +65,7 @@ http://localhost:8080
|
||||
- `GET /api/tasks`
|
||||
- `GET /api/tasks/query?status=&type=&keyword=&page=1&size=10`
|
||||
- `GET /api/tasks/{taskId}`
|
||||
- `GET /api/tasks/{taskId}/stream`(SSE 实时输出)
|
||||
- `POST /api/tasks/{taskId}/cancel`
|
||||
- `GET /api/health`
|
||||
- `GET /api/health/details`
|
||||
|
||||
4
pom.xml
4
pom.xml
@@ -95,7 +95,7 @@
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>com.svnlog.Main</mainClass>
|
||||
<mainClass>com.svnlog.web.WebApplication</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
@@ -111,7 +111,7 @@
|
||||
</descriptorRefs>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>com.svnlog.Main</mainClass>
|
||||
<mainClass>com.svnlog.web.WebApplication</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
</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;
|
||||
|
||||
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.RequestParam;
|
||||
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.SettingsUpdateRequest;
|
||||
import com.svnlog.web.dto.SvnConnectionRequest;
|
||||
import com.svnlog.web.dto.SvnFetchRequest;
|
||||
import com.svnlog.web.dto.SvnVersionRangeRequest;
|
||||
import com.svnlog.web.model.SvnPreset;
|
||||
import com.svnlog.web.model.SvnPresetSummary;
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
import com.svnlog.web.model.TaskPageResult;
|
||||
import com.svnlog.web.service.AiWorkflowService;
|
||||
@@ -85,6 +90,29 @@ public class AppController {
|
||||
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")
|
||||
public Map<String, String> fetchSvnLogs(@Valid @RequestBody SvnFetchRequest request) {
|
||||
final String taskId = taskService.submit("SVN_FETCH", context -> svnWorkflowService.fetchToMarkdown(request, context));
|
||||
@@ -96,7 +124,7 @@ public class AppController {
|
||||
@GetMapping("/svn/presets")
|
||||
public Map<String, Object> listSvnPresets() {
|
||||
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("defaultPresetId", settingsService.getDefaultSvnPresetId());
|
||||
return response;
|
||||
@@ -135,6 +163,11 @@ public class AppController {
|
||||
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")
|
||||
public Map<String, Object> cancelTask(@PathVariable("taskId") String taskId) {
|
||||
final boolean cancelled = taskService.cancelTask(taskId);
|
||||
|
||||
@@ -5,7 +5,7 @@ import javax.validation.constraints.NotBlank;
|
||||
public class SvnConnectionRequest {
|
||||
|
||||
@NotBlank
|
||||
private String url;
|
||||
private String presetId;
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
@@ -13,12 +13,12 @@ public class SvnConnectionRequest {
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
public String getPresetId() {
|
||||
return presetId;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
public void setPresetId(String presetId) {
|
||||
this.presetId = presetId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
|
||||
@@ -4,10 +4,10 @@ import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class SvnFetchRequest {
|
||||
|
||||
private String projectName;
|
||||
|
||||
@NotBlank
|
||||
private String url;
|
||||
private String presetId;
|
||||
|
||||
private String projectName;
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
@@ -19,6 +19,14 @@ public class SvnFetchRequest {
|
||||
private Long endRevision;
|
||||
private String filterUser;
|
||||
|
||||
public String getPresetId() {
|
||||
return presetId;
|
||||
}
|
||||
|
||||
public void setPresetId(String presetId) {
|
||||
this.presetId = presetId;
|
||||
}
|
||||
|
||||
public String getProjectName() {
|
||||
return projectName;
|
||||
}
|
||||
@@ -27,14 +35,6 @@ public class SvnFetchRequest {
|
||||
this.projectName = projectName;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
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.Paths;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.Cell;
|
||||
@@ -42,6 +48,19 @@ import okhttp3.Response;
|
||||
public class AiWorkflowService {
|
||||
|
||||
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()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
@@ -63,8 +82,14 @@ public class AiWorkflowService {
|
||||
}
|
||||
|
||||
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());
|
||||
context.setProgress(18, "路径解析完成: " + joinResolvedPaths(markdownFiles));
|
||||
aiInputValidator.validate(markdownFiles);
|
||||
final String content = readMarkdownFiles(markdownFiles);
|
||||
|
||||
@@ -79,7 +104,7 @@ public class AiWorkflowService {
|
||||
}
|
||||
|
||||
final String prompt = buildPrompt(content, period);
|
||||
final String aiResponse = callDeepSeek(apiKey, prompt);
|
||||
final String aiResponse = callDeepSeek(apiKey, prompt, context);
|
||||
final JsonObject payload = extractJson(aiResponse);
|
||||
|
||||
context.setProgress(75, "正在生成 Excel 文件");
|
||||
@@ -121,39 +146,101 @@ public class AiWorkflowService {
|
||||
throw new IllegalArgumentException("文件路径不能为空");
|
||||
}
|
||||
|
||||
final String normalizedInput = userPath.trim();
|
||||
final Path outputRoot = outputFileService.getOutputRoot();
|
||||
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)) {
|
||||
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) {
|
||||
return "你是项目管理助手,请根据以下 SVN Markdown 日志生成工作量统计 JSON。\n"
|
||||
+ "工作周期: " + period + "\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"
|
||||
+ "{\n"
|
||||
+ " \"team\": \"所属班组\",\n"
|
||||
+ " \"contact\": \"技术对接人\",\n"
|
||||
+ " \"developer\": \"开发人员\",\n"
|
||||
+ " \"team\": \"系统部\",\n"
|
||||
+ " \"contact\": \"杨志强\\n(系统平台组)\",\n"
|
||||
+ " \"developer\": \"刘靖\",\n"
|
||||
+ " \"period\": \"" + period + "\",\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" + markdownContent;
|
||||
}
|
||||
|
||||
private String callDeepSeek(String apiKey, String prompt) throws IOException {
|
||||
private String callDeepSeek(String apiKey, String prompt, TaskContext context) throws IOException {
|
||||
try {
|
||||
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt), 3, 1000L);
|
||||
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt, context), 3, 1000L);
|
||||
} catch (IOException e) {
|
||||
throw 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();
|
||||
message.addProperty("role", "user");
|
||||
message.addProperty("content", prompt);
|
||||
@@ -173,7 +260,13 @@ public class AiWorkflowService {
|
||||
body.addProperty("model", "deepseek-reasoner");
|
||||
body.add("messages", messages);
|
||||
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()
|
||||
.url(DEEPSEEK_API_URL)
|
||||
@@ -198,19 +291,96 @@ public class AiWorkflowService {
|
||||
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
|
||||
}
|
||||
|
||||
final String raw = response.body().string();
|
||||
final JsonObject data = JsonParser.parseString(raw).getAsJsonObject();
|
||||
final StringBuilder answerBuilder = new StringBuilder();
|
||||
final okhttp3.ResponseBody responseBody = response.body();
|
||||
final okio.BufferedSource source = responseBody.source();
|
||||
String finishReason = "";
|
||||
|
||||
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) {
|
||||
throw new IllegalStateException("DeepSeek API 未返回可用结果");
|
||||
continue;
|
||||
}
|
||||
|
||||
final JsonObject first = choices.get(0).getAsJsonObject();
|
||||
final JsonObject messageObj = first.getAsJsonObject("message");
|
||||
if (messageObj == null || !messageObj.has("content")) {
|
||||
throw new IllegalStateException("DeepSeek API 响应缺少 content 字段");
|
||||
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));
|
||||
}
|
||||
return messageObj.get("content").getAsString();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
if ("length".equalsIgnoreCase(finishReason)) {
|
||||
throw new IllegalStateException("DeepSeek 输出被截断(finish_reason=length),请增大 max_tokens 或缩短输入");
|
||||
}
|
||||
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 {
|
||||
final String team = optString(payload, "team");
|
||||
final String contact = optString(payload, "contact");
|
||||
final String developer = optString(payload, "developer");
|
||||
final String period = payload.has("period") ? optString(payload, "period") : defaultPeriod;
|
||||
final String period = payload.has("period")
|
||||
? firstNonBlank(optString(payload, "period"), defaultPeriod)
|
||||
: 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()) {
|
||||
final Sheet sheet = workbook.createSheet("工作量统计");
|
||||
@@ -259,19 +433,15 @@ public class AiWorkflowService {
|
||||
cell.setCellStyle(headerStyle);
|
||||
}
|
||||
|
||||
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
final JsonObject record = records.get(i).getAsJsonObject();
|
||||
final Row row = sheet.createRow(i + 1);
|
||||
|
||||
createCell(row, 0, getAsInt(record.get("sequence"), i + 1), textStyle);
|
||||
final Row row = sheet.createRow(1);
|
||||
row.setHeightInPoints(calculateRowHeight(content));
|
||||
createCell(row, 0, 1, textStyle);
|
||||
createCell(row, 1, team, textStyle);
|
||||
createCell(row, 2, contact, textStyle);
|
||||
createCell(row, 3, developer, textStyle);
|
||||
createCell(row, 4, period, textStyle);
|
||||
createCell(row, 5, optString(record, "project"), textStyle);
|
||||
createCell(row, 6, optString(record, "content"), contentStyle);
|
||||
}
|
||||
createCell(row, 5, project, textStyle);
|
||||
createCell(row, 6, content, contentStyle);
|
||||
|
||||
sheet.setColumnWidth(0, 2200);
|
||||
sheet.setColumnWidth(1, 4200);
|
||||
@@ -360,6 +530,164 @@ public class AiWorkflowService {
|
||||
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) {
|
||||
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_");
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ public class OutputFileService {
|
||||
final Path root = getOutputRoot();
|
||||
final Path resolved = root.resolve(relative).normalize();
|
||||
if (!resolved.startsWith(root)) {
|
||||
throw new IllegalArgumentException("非法文件路径");
|
||||
throw new IllegalArgumentException(
|
||||
"非法文件路径: relative=" + relative + ", resolved=" + resolved + ", outputRoot=" + root
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class SettingsService {
|
||||
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
||||
this.outputFileService = outputFileService;
|
||||
this.svnPresetService = svnPresetService;
|
||||
this.defaultSvnPresetId = svnPresetService.firstPresetId();
|
||||
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
|
||||
}
|
||||
|
||||
public Map<String, Object> getSettings() throws IOException {
|
||||
@@ -26,7 +26,7 @@ public class SettingsService {
|
||||
final String activeKey = pickActiveKey(null);
|
||||
|
||||
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
|
||||
result.put("apiKeySource", runtimeApiKey != null ? "runtime" : (envKey != null ? "env" : "none"));
|
||||
result.put("apiKeySource", detectApiKeySource(envKey));
|
||||
result.put("outputDir", outputFileService.getOutputRoot().toString());
|
||||
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
|
||||
return result;
|
||||
@@ -58,6 +58,16 @@ public class SettingsService {
|
||||
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() {
|
||||
if (svnPresetService.containsPresetId(defaultSvnPresetId)) {
|
||||
return defaultSvnPresetId;
|
||||
|
||||
@@ -2,41 +2,66 @@ package com.svnlog.web.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.svnlog.web.config.SvnPresetProperties;
|
||||
import com.svnlog.web.model.SvnPreset;
|
||||
import com.svnlog.web.model.SvnPresetSummary;
|
||||
|
||||
@Service
|
||||
public class SvnPresetService {
|
||||
|
||||
private final List<SvnPreset> presets;
|
||||
private final String configuredDefaultPresetId;
|
||||
|
||||
public SvnPresetService() {
|
||||
List<SvnPreset> list = new ArrayList<SvnPreset>();
|
||||
list.add(new SvnPreset(
|
||||
"preset-1",
|
||||
"PRS-7050场站智慧管控",
|
||||
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00/src_java"
|
||||
));
|
||||
list.add(new SvnPreset(
|
||||
"preset-2",
|
||||
"PRS-7950在线巡视",
|
||||
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00/src_java"
|
||||
));
|
||||
list.add(new SvnPreset(
|
||||
"preset-3",
|
||||
"PRS-7950在线巡视电科院测试版",
|
||||
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024/src_java"
|
||||
));
|
||||
public SvnPresetService(SvnPresetProperties properties) {
|
||||
final List<SvnPreset> source = properties.getPresets() == null
|
||||
? Collections.<SvnPreset>emptyList()
|
||||
: properties.getPresets();
|
||||
if (source.isEmpty()) {
|
||||
throw new IllegalStateException("SVN 预设未配置,请检查 application.properties 中的 svn.presets");
|
||||
}
|
||||
|
||||
final List<SvnPreset> list = new ArrayList<SvnPreset>();
|
||||
final Set<String> ids = new HashSet<String>();
|
||||
for (SvnPreset preset : source) {
|
||||
final String id = trim(preset.getId());
|
||||
final String name = trim(preset.getName());
|
||||
final String url = trim(preset.getUrl());
|
||||
if (id.isEmpty() || name.isEmpty() || url.isEmpty()) {
|
||||
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);
|
||||
|
||||
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() {
|
||||
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) {
|
||||
if (presetId == null || presetId.trim().isEmpty()) {
|
||||
return false;
|
||||
@@ -49,7 +74,25 @@ public class SvnPresetService {
|
||||
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() {
|
||||
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;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
@@ -12,29 +8,35 @@ import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.tmatesoft.svn.core.SVNException;
|
||||
|
||||
import com.svnlog.LogEntry;
|
||||
import com.svnlog.SVNLogFetcher;
|
||||
import com.svnlog.core.report.MarkdownReportWriter;
|
||||
import com.svnlog.core.svn.LogEntry;
|
||||
import com.svnlog.core.svn.SVNLogFetcher;
|
||||
import com.svnlog.web.dto.SvnConnectionRequest;
|
||||
import com.svnlog.web.dto.SvnFetchRequest;
|
||||
import com.svnlog.web.model.SvnPreset;
|
||||
import com.svnlog.web.model.TaskResult;
|
||||
|
||||
@Service
|
||||
public class SvnWorkflowService {
|
||||
|
||||
private final OutputFileService outputFileService;
|
||||
private final SvnPresetService svnPresetService;
|
||||
|
||||
public SvnWorkflowService(OutputFileService outputFileService) {
|
||||
public SvnWorkflowService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
||||
this.outputFileService = outputFileService;
|
||||
this.svnPresetService = svnPresetService;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception {
|
||||
context.setProgress(10, "正在连接 SVN 仓库");
|
||||
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
|
||||
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||
context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName());
|
||||
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword());
|
||||
fetcher.testConnection();
|
||||
|
||||
context.setProgress(30, "正在拉取 SVN 日志");
|
||||
@@ -50,13 +52,25 @@ public class SvnWorkflowService {
|
||||
context.setProgress(70, "正在生成 Markdown 文件");
|
||||
final String projectName = request.getProjectName() != null && !request.getProjectName().trim().isEmpty()
|
||||
? request.getProjectName().trim()
|
||||
: "custom";
|
||||
: preset.getName();
|
||||
final String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
|
||||
final String fileName = "md/svn_log_" + sanitize(projectName) + "_" + timestamp + ".md";
|
||||
final Path outputPath = outputFileService.resolveInOutput(fileName);
|
||||
|
||||
Files.createDirectories(outputPath.getParent());
|
||||
writeMarkdown(outputPath, request, start, end, logs, fetcher);
|
||||
MarkdownReportWriter.write(
|
||||
outputPath,
|
||||
preset.getUrl(),
|
||||
request.getUsername(),
|
||||
start,
|
||||
end,
|
||||
request.getFilterUser(),
|
||||
logs,
|
||||
fetcher,
|
||||
new MarkdownReportWriter.Options()
|
||||
.includeAccount(true)
|
||||
.includeGeneratedTime(true)
|
||||
.includeStatistics(true)
|
||||
);
|
||||
|
||||
context.setProgress(100, "SVN 日志导出完成");
|
||||
final TaskResult result = new TaskResult("成功导出 " + logs.size() + " 条日志");
|
||||
@@ -64,39 +78,6 @@ public class SvnWorkflowService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private void writeMarkdown(Path path, SvnFetchRequest request, long startRevision, long endRevision,
|
||||
List<LogEntry> logs, SVNLogFetcher fetcher) throws IOException {
|
||||
final StringBuilder markdown = new StringBuilder();
|
||||
|
||||
markdown.append("# SVN 日志报告\n\n");
|
||||
markdown.append("## 查询条件\n\n");
|
||||
markdown.append("- **SVN地址**: `").append(request.getUrl()).append("`\n");
|
||||
markdown.append("- **账号**: `").append(request.getUsername()).append("`\n");
|
||||
markdown.append("- **版本范围**: r").append(startRevision).append(" - r").append(endRevision).append("\n");
|
||||
if (!safe(request.getFilterUser()).isEmpty()) {
|
||||
markdown.append("- **过滤用户**: `").append(request.getFilterUser()).append("`\n");
|
||||
}
|
||||
markdown.append("- **生成时间**: ").append(fetcher.formatDate(new Date())).append("\n\n");
|
||||
|
||||
markdown.append("## 统计信息\n\n");
|
||||
markdown.append("- **总记录数**: ").append(logs.size()).append(" 条\n\n");
|
||||
markdown.append("## 日志详情\n\n");
|
||||
|
||||
for (LogEntry entry : logs) {
|
||||
markdown.append("### r").append(entry.getRevision()).append("\n\n");
|
||||
markdown.append("**作者**: `").append(entry.getAuthor()).append("` \n");
|
||||
markdown.append("**时间**: ").append(fetcher.formatDate(entry.getDate())).append(" \n");
|
||||
markdown.append("**版本**: r").append(entry.getRevision()).append("\n\n");
|
||||
markdown.append("**提交信息**:\n\n");
|
||||
markdown.append("```\n").append(safe(entry.getMessage())).append("\n```\n\n");
|
||||
markdown.append("---\n\n");
|
||||
}
|
||||
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
|
||||
writer.write(markdown.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitize(String value) {
|
||||
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
|
||||
public class TaskContext {
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EventPublisher {
|
||||
void publish(String eventName, Map<String, Object> payload);
|
||||
}
|
||||
|
||||
private final TaskInfo taskInfo;
|
||||
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.onUpdate = onUpdate;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
public void setProgress(int progress, String message) {
|
||||
@@ -20,5 +30,17 @@ public class TaskContext {
|
||||
if (onUpdate != null) {
|
||||
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.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
@@ -17,6 +19,7 @@ import java.util.concurrent.Future;
|
||||
import javax.annotation.PreDestroy;
|
||||
|
||||
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.TaskPageResult;
|
||||
@@ -33,6 +36,8 @@ public class TaskService {
|
||||
private final ExecutorService executor = Executors.newFixedThreadPool(4);
|
||||
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
|
||||
private final Map<String, Future<?>> futures = new ConcurrentHashMap<String, Future<?>>();
|
||||
private final Map<String, CopyOnWriteArrayList<SseEmitter>> taskEmitters =
|
||||
new ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>>();
|
||||
private final TaskPersistenceService persistenceService;
|
||||
private final OutputFileService outputFileService;
|
||||
|
||||
@@ -56,6 +61,7 @@ public class TaskService {
|
||||
taskInfo.setUpdatedAt(now);
|
||||
tasks.put(taskId, taskInfo);
|
||||
persistSafely();
|
||||
publishTaskEvent(taskId, "phase", buildPhasePayload(taskInfo));
|
||||
|
||||
Future<?> future = executor.submit(new Callable<Void>() {
|
||||
@Override
|
||||
@@ -123,12 +129,44 @@ public class TaskService {
|
||||
task.setMessage("任务已取消");
|
||||
task.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
publishTaskEvent(taskId, "error", buildTerminalPayload(task, task.getMessage()));
|
||||
completeTaskStream(taskId);
|
||||
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) {
|
||||
try {
|
||||
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
||||
completeTaskStream(taskInfo.getTaskId());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,8 +174,13 @@ public class TaskService {
|
||||
taskInfo.setMessage("任务执行中");
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
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);
|
||||
taskInfo.setStatus(TaskStatus.SUCCESS);
|
||||
taskInfo.setProgress(100);
|
||||
@@ -146,24 +189,29 @@ public class TaskService {
|
||||
if (result != null && result.getFiles() != null) {
|
||||
taskInfo.getFiles().addAll(result.getFiles());
|
||||
}
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
publishTaskEvent(taskInfo.getTaskId(), "done", buildTerminalPayload(taskInfo, ""));
|
||||
} catch (Exception e) {
|
||||
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, "任务已取消"));
|
||||
completeTaskStream(taskInfo.getTaskId());
|
||||
return;
|
||||
}
|
||||
taskInfo.setStatus(TaskStatus.FAILED);
|
||||
taskInfo.setError(e.getMessage());
|
||||
taskInfo.setError(buildErrorMessage(e));
|
||||
taskInfo.setMessage("执行失败");
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, taskInfo.getError()));
|
||||
completeTaskStream(taskInfo.getTaskId());
|
||||
return;
|
||||
} finally {
|
||||
futures.remove(taskInfo.getTaskId());
|
||||
}
|
||||
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
completeTaskStream(taskInfo.getTaskId());
|
||||
}
|
||||
|
||||
private void loadPersistedTasks() {
|
||||
@@ -226,6 +274,95 @@ public class TaskService {
|
||||
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
|
||||
public void destroy() {
|
||||
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 = {
|
||||
dashboard: { title: "工作台", desc: "查看系统状态与最近产物" },
|
||||
svn: { title: "SVN 日志抓取", desc: "配置 SVN 参数并生成 Markdown" },
|
||||
ai: { title: "AI 工作量分析", desc: "选择 Markdown 后生成工作量 Excel" },
|
||||
svn: { title: "SVN 日志抓取", desc: "一键抓取SVN日志并导出工作量Excel" },
|
||||
history: { title: "任务历史", desc: "查看任务执行状态、日志与产物" },
|
||||
settings: { title: "系统设置", desc: "配置 API Key 与输出目录" },
|
||||
};
|
||||
@@ -27,6 +26,20 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
await refreshAll();
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -46,15 +59,11 @@ function bindForms() {
|
||||
const svnForm = document.querySelector("#svn-form");
|
||||
svnForm.addEventListener("submit", onRunSvn);
|
||||
|
||||
const aiForm = document.querySelector("#ai-form");
|
||||
aiForm.addEventListener("submit", onRunAi);
|
||||
|
||||
|
||||
const settingsForm = document.querySelector("#settings-form");
|
||||
settingsForm.addEventListener("submit", onSaveSettings);
|
||||
|
||||
const svnPresetSelect = document.querySelector("#svn-preset-select");
|
||||
svnPresetSelect.addEventListener("change", onSvnPresetChange);
|
||||
|
||||
const taskFilterBtn = document.querySelector("#btn-task-filter");
|
||||
if (taskFilterBtn) {
|
||||
taskFilterBtn.addEventListener("click", onTaskFilterSubmit);
|
||||
@@ -76,9 +85,7 @@ function switchView(view) {
|
||||
loadTaskPage();
|
||||
renderFileTable();
|
||||
}
|
||||
if (view === "ai") {
|
||||
renderMdFilePicker();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
@@ -109,9 +116,7 @@ async function refreshAll() {
|
||||
loadTaskPage();
|
||||
renderFileTable();
|
||||
}
|
||||
if (state.activeView === "ai") {
|
||||
renderMdFilePicker();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
@@ -130,60 +135,31 @@ async function loadPresets() {
|
||||
}
|
||||
|
||||
function renderPresetSelects() {
|
||||
const svnSelect = document.querySelector("#svn-preset-select");
|
||||
const settingsSelect = document.querySelector("#settings-default-preset");
|
||||
svnSelect.innerHTML = "";
|
||||
if (!settingsSelect) return;
|
||||
|
||||
settingsSelect.innerHTML = "";
|
||||
|
||||
state.presets.forEach((preset) => {
|
||||
const option1 = document.createElement("option");
|
||||
option1.value = preset.id;
|
||||
option1.textContent = `${preset.name}`;
|
||||
svnSelect.appendChild(option1);
|
||||
|
||||
const option2 = document.createElement("option");
|
||||
option2.value = preset.id;
|
||||
option2.textContent = `${preset.name}`;
|
||||
settingsSelect.appendChild(option2);
|
||||
const option = document.createElement("option");
|
||||
option.value = preset.id;
|
||||
option.textContent = `${preset.name}`;
|
||||
settingsSelect.appendChild(option);
|
||||
});
|
||||
|
||||
const customOption = document.createElement("option");
|
||||
customOption.value = CUSTOM_PRESET_ID;
|
||||
customOption.textContent = "自定义 SVN 地址";
|
||||
svnSelect.appendChild(customOption);
|
||||
|
||||
const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : CUSTOM_PRESET_ID);
|
||||
svnSelect.value = selected;
|
||||
const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : "");
|
||||
if (selected) {
|
||||
settingsSelect.value = selected;
|
||||
}
|
||||
}
|
||||
|
||||
// 已移除单个项目预设功能,使用固定三个项目配置
|
||||
function onSvnPresetChange(event) {
|
||||
applyPresetToSvnForm(event.target.value);
|
||||
// 保留空实现兼容
|
||||
}
|
||||
|
||||
function applyPresetToSvnForm(presetId) {
|
||||
const form = document.querySelector("#svn-form");
|
||||
const select = document.querySelector("#svn-preset-select");
|
||||
const projectInput = form.querySelector("[name='projectName']");
|
||||
const urlInput = form.querySelector("[name='url']");
|
||||
|
||||
if (presetId === CUSTOM_PRESET_ID) {
|
||||
select.value = CUSTOM_PRESET_ID;
|
||||
projectInput.readOnly = false;
|
||||
urlInput.readOnly = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = state.presets.find((item) => item.id === presetId);
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
select.value = preset.id;
|
||||
projectInput.value = preset.name;
|
||||
urlInput.value = preset.url;
|
||||
projectInput.readOnly = true;
|
||||
urlInput.readOnly = true;
|
||||
// 保留空实现兼容
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
@@ -229,22 +205,20 @@ function renderDashboard() {
|
||||
}
|
||||
|
||||
async function onTestConnection() {
|
||||
const form = document.querySelector("#svn-form");
|
||||
const payload = readForm(form);
|
||||
if (!payload.url || !payload.username || !payload.password) {
|
||||
toast("请先填写 SVN 地址、账号和密码", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.querySelector("#btn-test-connection");
|
||||
setLoading(btn, true);
|
||||
try {
|
||||
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", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
url: payload.url,
|
||||
username: payload.username,
|
||||
password: payload.password,
|
||||
presetId: firstPreset.id,
|
||||
username: "liujing2",
|
||||
password: "sunri@20230620*#&",
|
||||
}),
|
||||
});
|
||||
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) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const payload = readForm(form);
|
||||
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);
|
||||
form.disabled = true;
|
||||
|
||||
try {
|
||||
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({
|
||||
projectName: payload.projectName || "",
|
||||
url: payload.url,
|
||||
username: payload.username,
|
||||
password: payload.password,
|
||||
startRevision: toNumberOrNull(payload.startRevision),
|
||||
endRevision: toNumberOrNull(payload.endRevision),
|
||||
presetId: project.presetId,
|
||||
username: "liujing2",
|
||||
password: "sunri@20230620*#&",
|
||||
startRevision: toNumberOrNull(project.start),
|
||||
endRevision: toNumberOrNull(project.end),
|
||||
filterUser: payload.filterUser || "",
|
||||
}),
|
||||
});
|
||||
toast(`SVN 抓取任务已创建:${data.taskId}`);
|
||||
switchView("history");
|
||||
refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
setLoading(btn, false);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
appendLog(`所有SVN抓取任务完成,共生成 ${mdFiles.length} 个Markdown文件`);
|
||||
|
||||
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", {
|
||||
// 调用AI分析接口
|
||||
appendLog("正在提交AI分析任务...");
|
||||
const aiData = await apiFetch("/api/ai/analyze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
filePaths: checked,
|
||||
filePaths: mdFiles,
|
||||
period: payload.period || "",
|
||||
apiKey: payload.apiKey || "",
|
||||
apiKey: "", // 使用内置API Key
|
||||
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();
|
||||
} catch (err) {
|
||||
appendLog(`错误: ${err.message}`, true);
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
if (aiStream) {
|
||||
aiStream.close();
|
||||
}
|
||||
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;
|
||||
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="主导航">
|
||||
<h1>SVN 工作台</h1>
|
||||
<nav>
|
||||
<button class="nav-item active" data-view="dashboard">工作台</button>
|
||||
<button class="nav-item" data-view="svn">SVN 日志抓取</button>
|
||||
<button class="nav-item" data-view="ai">AI 工作量分析</button>
|
||||
<button class="nav-item" data-view="dashboard">工作台</button>
|
||||
<button class="nav-item active" data-view="svn">SVN 日志抓取</button>
|
||||
<button class="nav-item" data-view="history">任务历史</button>
|
||||
<button class="nav-item" data-view="settings">系统设置</button>
|
||||
</nav>
|
||||
@@ -64,41 +63,87 @@
|
||||
|
||||
<section class="view" id="view-svn">
|
||||
<article class="card form-card">
|
||||
<h3>SVN 抓取参数</h3>
|
||||
<form id="svn-form" class="form-grid">
|
||||
<label>预置项目
|
||||
<select name="presetId" id="svn-preset-select" aria-label="预置 SVN 项目"></select>
|
||||
<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>
|
||||
<label>项目名<input name="projectName" placeholder="如:PRS-7050"></label>
|
||||
<label>SVN 地址<input required name="url" placeholder="https://..." aria-label="SVN 地址"></label>
|
||||
<label>账号<input required name="username" placeholder="请输入账号"></label>
|
||||
<label>密码<input required type="password" name="password" placeholder="请输入密码"></label>
|
||||
<label>开始版本号<input name="startRevision" inputmode="numeric" placeholder="默认最新"></label>
|
||||
<label>结束版本号<input name="endRevision" inputmode="numeric" placeholder="默认最新"></label>
|
||||
<label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤"></label>
|
||||
<div 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">
|
||||
<!-- 项目1 -->
|
||||
<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">项目 1:PRS-7050 场站智慧管控</h4>
|
||||
<div class="grid cols-2" style="gap:10px">
|
||||
<label>开始版本号<input name="startRevision_1" inputmode="numeric" placeholder="请输入开始版本"></label>
|
||||
<label>结束版本号<input name="endRevision_1" inputmode="numeric" placeholder="请输入结束版本"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目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">
|
||||
<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>
|
||||
</form>
|
||||
</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 class="view" id="view-ai">
|
||||
<article class="card form-card">
|
||||
<h3>AI 分析参数</h3>
|
||||
<form id="ai-form" class="form-grid">
|
||||
<label class="span-2">选择 Markdown 输入文件</label>
|
||||
<div class="span-2 file-picker" id="md-file-picker" role="group" aria-label="Markdown 文件选择"></div>
|
||||
<label>工作周期<input name="period" placeholder="例如 2026年03月"></label>
|
||||
<label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label>
|
||||
<label class="span-2">临时 API Key(可选)<input type="password" name="apiKey" placeholder="优先使用设置页或环境变量"></label>
|
||||
<div class="actions span-2">
|
||||
<button type="submit" id="btn-ai-run" class="primary">开始 AI 分析并导出 Excel</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="view" id="view-history">
|
||||
<article class="card">
|
||||
|
||||
@@ -368,6 +368,45 @@ td {
|
||||
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) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -399,6 +438,12 @@ td {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.live-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
|
||||
Reference in New Issue
Block a user