feat(web): 新增可视化工作台并支持预置SVN项目

新增 Spring Boot Web 后端与前端页面,打通 SVN 抓取、AI 分析、任务管理、文件下载与系统设置全流程。增加 3 个默认 SVN 预置项目下拉与默认项配置,提升日常使用效率与可维护性。
This commit is contained in:
2026-03-08 23:14:55 +08:00
parent abd375bf64
commit e26fb9cebb
25 changed files with 2458 additions and 2 deletions

View File

@@ -5,7 +5,8 @@
## 1. 项目概览
- 语言与构建Java 8 + Maven`pom.xml`)。
- 打包产物:可执行 fat jar`jar-with-dependencies`)。
- 主入口:`com.svnlog.Main`
- 主入口:`com.svnlog.Main`CLI
- Web 入口:`com.svnlog.WebApplication`(前后端一体,静态页面 + REST API
- 其他入口:`com.svnlog.DeepSeekLogProcessor``com.svnlog.ExcelAnalyzer`
- 核心目录:
- `src/main/java/com/svnlog/`
@@ -37,6 +38,9 @@
### 2.4 Run
- 运行主程序SVN 日志抓取):
- `java -jar target/svn-log-tool-1.0.0-jar-with-dependencies.jar`
- 运行 Web 工作台(推荐):
- `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.WebApplication`
- 启动后访问:`http://localhost:8080`
- 运行 DeepSeek 处理器:
- `java -cp target/svn-log-tool-1.0.0-jar-with-dependencies.jar com.svnlog.DeepSeekLogProcessor`
- Maven 方式运行 DeepSeek
@@ -48,6 +52,9 @@
- `LogEntry.java`日志数据模型POJO
- `DeepSeekLogProcessor.java`:读取 Markdown、调用 DeepSeek API、生成 Excel。
- `ExcelAnalyzer.java`:本地临时分析工具,偏实验性质。
- `web/controller/*`REST APISVN、AI、任务、文件、设置
- `web/service/*`异步任务与业务编排SVN 抓取、AI 分析、输出目录管理)。
- `src/main/resources/static/*`Web 前端页面与交互脚本。
- 变更原则:
- 抓取逻辑改在 `SVNLogFetcher`
- 交互逻辑改在 `Main`

77
docs/README_Web.md Normal file
View File

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

20
pom.xml
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,91 @@
package com.svnlog.web.service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.annotation.PreDestroy;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskResult;
import com.svnlog.web.model.TaskStatus;
@Service
public class TaskService {
public interface TaskRunner {
TaskResult run(TaskContext context) throws Exception;
}
private final ExecutorService executor = Executors.newFixedThreadPool(4);
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
public String submit(String type, TaskRunner runner) {
final String taskId = UUID.randomUUID().toString();
final TaskInfo taskInfo = new TaskInfo();
final Instant now = Instant.now();
taskInfo.setTaskId(taskId);
taskInfo.setType(type);
taskInfo.setStatus(TaskStatus.PENDING);
taskInfo.setProgress(0);
taskInfo.setMessage("任务已创建");
taskInfo.setCreatedAt(now);
taskInfo.setUpdatedAt(now);
tasks.put(taskId, taskInfo);
executor.submit(new Callable<Void>() {
@Override
public Void call() {
runTask(taskInfo, runner);
return null;
}
});
return taskId;
}
public TaskInfo getTask(String taskId) {
return tasks.get(taskId);
}
public List<TaskInfo> getTasks() {
return new ArrayList<TaskInfo>(tasks.values());
}
private void runTask(TaskInfo taskInfo, TaskRunner runner) {
taskInfo.setStatus(TaskStatus.RUNNING);
taskInfo.setMessage("任务执行中");
taskInfo.setUpdatedAt(Instant.now());
final TaskContext context = new TaskContext(taskInfo);
try {
final TaskResult result = runner.run(context);
taskInfo.setStatus(TaskStatus.SUCCESS);
taskInfo.setProgress(100);
taskInfo.setMessage(result != null ? result.getMessage() : "执行完成");
taskInfo.getFiles().clear();
if (result != null && result.getFiles() != null) {
taskInfo.getFiles().addAll(result.getFiles());
}
} catch (Exception e) {
taskInfo.setStatus(TaskStatus.FAILED);
taskInfo.setError(e.getMessage());
taskInfo.setMessage("执行失败");
}
taskInfo.setUpdatedAt(Instant.now());
}
@PreDestroy
public void destroy() {
executor.shutdownNow();
}
}

View File

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

View File

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

View File

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