diff --git a/AGENTS.md b/AGENTS.md index 0dc41f9..02918ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 API(SVN、AI、任务、文件、设置)。 +- `web/service/*`:异步任务与业务编排(SVN 抓取、AI 分析、输出目录管理)。 +- `src/main/resources/static/*`:Web 前端页面与交互脚本。 - 变更原则: - 抓取逻辑改在 `SVNLogFetcher`。 - 交互逻辑改在 `Main`。 diff --git a/docs/README_Web.md b/docs/README_Web.md new file mode 100644 index 0000000..74bd284 --- /dev/null +++ b/docs/README_Web.md @@ -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. 在「任务历史」中下载产物并核验内容 diff --git a/pom.xml b/pom.xml index 863f19f..b536006 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ UTF-8 1.8 1.8 + 2.7.18 @@ -52,6 +53,19 @@ gson 2.10.1 + + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + + + + org.springframework.boot + spring-boot-starter-validation + ${spring-boot.version} + @@ -104,6 +118,12 @@ + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + - \ No newline at end of file + diff --git a/src/main/java/com/svnlog/WebApplication.java b/src/main/java/com/svnlog/WebApplication.java new file mode 100644 index 0000000..c6574c0 --- /dev/null +++ b/src/main/java/com/svnlog/WebApplication.java @@ -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); + } +} diff --git a/src/main/java/com/svnlog/web/controller/AppController.java b/src/main/java/com/svnlog/web/controller/AppController.java new file mode 100644 index 0000000..11db98d --- /dev/null +++ b/src/main/java/com/svnlog/web/controller/AppController.java @@ -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 health() { + final Map response = new HashMap(); + response.put("status", "ok"); + response.put("timestamp", Instant.now().toString()); + return response; + } + + @PostMapping("/svn/test-connection") + public Map testSvnConnection(@Valid @RequestBody SvnConnectionRequest request) throws Exception { + svnWorkflowService.testConnection(request); + final Map response = new HashMap(); + response.put("success", true); + response.put("message", "SVN 连接成功"); + return response; + } + + @PostMapping("/svn/fetch") + public Map fetchSvnLogs(@Valid @RequestBody SvnFetchRequest request) { + final String taskId = taskService.submit("SVN_FETCH", context -> svnWorkflowService.fetchToMarkdown(request, context)); + final Map response = new HashMap(); + response.put("taskId", taskId); + return response; + } + + @GetMapping("/svn/presets") + public Map listSvnPresets() { + final Map response = new HashMap(); + final List presets = svnPresetService.listPresets(); + response.put("presets", presets); + response.put("defaultPresetId", settingsService.getDefaultSvnPresetId()); + return response; + } + + @PostMapping("/ai/analyze") + public Map analyzeLogs(@Valid @RequestBody AiAnalyzeRequest request) { + final String taskId = taskService.submit("AI_ANALYZE", context -> aiWorkflowService.analyzeAndExport(request, context)); + final Map response = new HashMap(); + response.put("taskId", taskId); + return response; + } + + @GetMapping("/tasks") + public List 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 listFiles() throws IOException { + final Map response = new HashMap(); + response.put("files", outputFileService.listOutputFiles()); + response.put("outputDir", outputFileService.getOutputRoot().toString()); + return response; + } + + @GetMapping("/files/download") + public ResponseEntity 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 getSettings() throws IOException { + return settingsService.getSettings(); + } + + @PutMapping("/settings") + public Map updateSettings(@RequestBody SettingsUpdateRequest request) throws IOException { + settingsService.updateSettings(request.getApiKey(), request.getOutputDir(), request.getDefaultSvnPresetId()); + return settingsService.getSettings(); + } +} diff --git a/src/main/java/com/svnlog/web/controller/GlobalExceptionHandler.java b/src/main/java/com/svnlog/web/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..4f81aea --- /dev/null +++ b/src/main/java/com/svnlog/web/controller/GlobalExceptionHandler.java @@ -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> handleIllegalArgument(IllegalArgumentException ex) { + return build(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + return build(HttpStatus.BAD_REQUEST, "请求参数校验失败"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAny(Exception ex) { + return build(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage() == null ? "系统异常" : ex.getMessage()); + } + + private ResponseEntity> build(HttpStatus status, String message) { + final Map response = new HashMap(); + response.put("status", status.value()); + response.put("error", message); + response.put("timestamp", Instant.now().toString()); + return ResponseEntity.status(status).body(response); + } +} diff --git a/src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java b/src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java new file mode 100644 index 0000000..e2baf15 --- /dev/null +++ b/src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java @@ -0,0 +1,47 @@ +package com.svnlog.web.dto; + +import java.util.List; + +import javax.validation.constraints.NotEmpty; + +public class AiAnalyzeRequest { + + @NotEmpty + private List filePaths; + + private String period; + private String apiKey; + private String outputFileName; + + public List getFilePaths() { + return filePaths; + } + + public void setFilePaths(List 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; + } +} diff --git a/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java b/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java new file mode 100644 index 0000000..e7431ae --- /dev/null +++ b/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java @@ -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; + } +} diff --git a/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java b/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java new file mode 100644 index 0000000..0909134 --- /dev/null +++ b/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java @@ -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; + } +} diff --git a/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java b/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java new file mode 100644 index 0000000..6b7f694 --- /dev/null +++ b/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java @@ -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; + } +} diff --git a/src/main/java/com/svnlog/web/model/OutputFileInfo.java b/src/main/java/com/svnlog/web/model/OutputFileInfo.java new file mode 100644 index 0000000..3d0bc11 --- /dev/null +++ b/src/main/java/com/svnlog/web/model/OutputFileInfo.java @@ -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; + } +} diff --git a/src/main/java/com/svnlog/web/model/SvnPreset.java b/src/main/java/com/svnlog/web/model/SvnPreset.java new file mode 100644 index 0000000..8e2d1f0 --- /dev/null +++ b/src/main/java/com/svnlog/web/model/SvnPreset.java @@ -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; + } +} diff --git a/src/main/java/com/svnlog/web/model/TaskInfo.java b/src/main/java/com/svnlog/web/model/TaskInfo.java new file mode 100644 index 0000000..e3edd43 --- /dev/null +++ b/src/main/java/com/svnlog/web/model/TaskInfo.java @@ -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 files = new ArrayList(); + + 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 getFiles() { + return files; + } +} diff --git a/src/main/java/com/svnlog/web/model/TaskResult.java b/src/main/java/com/svnlog/web/model/TaskResult.java new file mode 100644 index 0000000..e8a6287 --- /dev/null +++ b/src/main/java/com/svnlog/web/model/TaskResult.java @@ -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 files = new ArrayList(); + + public TaskResult() { + } + + public TaskResult(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public List getFiles() { + return files; + } + + public void addFile(String file) { + this.files.add(file); + } +} diff --git a/src/main/java/com/svnlog/web/model/TaskStatus.java b/src/main/java/com/svnlog/web/model/TaskStatus.java new file mode 100644 index 0000000..c470c2b --- /dev/null +++ b/src/main/java/com/svnlog/web/model/TaskStatus.java @@ -0,0 +1,8 @@ +package com.svnlog.web.model; + +public enum TaskStatus { + PENDING, + RUNNING, + SUCCESS, + FAILED +} diff --git a/src/main/java/com/svnlog/web/service/AiWorkflowService.java b/src/main/java/com/svnlog/web/service/AiWorkflowService.java new file mode 100644 index 0000000..e7e9b6d --- /dev/null +++ b/src/main/java/com/svnlog/web/service/AiWorkflowService.java @@ -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 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_.-]", "_"); + } +} diff --git a/src/main/java/com/svnlog/web/service/OutputFileService.java b/src/main/java/com/svnlog/web/service/OutputFileService.java new file mode 100644 index 0000000..a7bef2c --- /dev/null +++ b/src/main/java/com/svnlog/web/service/OutputFileService.java @@ -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 listOutputFiles() throws IOException { + final Path root = getOutputRoot(); + if (!Files.exists(root)) { + return new ArrayList(); + } + + final List filePaths = new ArrayList(); + Files.walk(root) + .filter(Files::isRegularFile) + .forEach(filePaths::add); + + filePaths.sort(Comparator.comparingLong(this::lastModified).reversed()); + + final List result = new ArrayList(); + 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; + } + } +} diff --git a/src/main/java/com/svnlog/web/service/SettingsService.java b/src/main/java/com/svnlog/web/service/SettingsService.java new file mode 100644 index 0000000..61484d0 --- /dev/null +++ b/src/main/java/com/svnlog/web/service/SettingsService.java @@ -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 getSettings() throws IOException { + final Map result = new HashMap(); + 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(); + } +} diff --git a/src/main/java/com/svnlog/web/service/SvnPresetService.java b/src/main/java/com/svnlog/web/service/SvnPresetService.java new file mode 100644 index 0000000..4a67d31 --- /dev/null +++ b/src/main/java/com/svnlog/web/service/SvnPresetService.java @@ -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 presets; + + public SvnPresetService() { + List list = new ArrayList(); + 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 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(); + } +} diff --git a/src/main/java/com/svnlog/web/service/SvnWorkflowService.java b/src/main/java/com/svnlog/web/service/SvnWorkflowService.java new file mode 100644 index 0000000..5393cfd --- /dev/null +++ b/src/main/java/com/svnlog/web/service/SvnWorkflowService.java @@ -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 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 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; + } +} diff --git a/src/main/java/com/svnlog/web/service/TaskContext.java b/src/main/java/com/svnlog/web/service/TaskContext.java new file mode 100644 index 0000000..296be4b --- /dev/null +++ b/src/main/java/com/svnlog/web/service/TaskContext.java @@ -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()); + } +} diff --git a/src/main/java/com/svnlog/web/service/TaskService.java b/src/main/java/com/svnlog/web/service/TaskService.java new file mode 100644 index 0000000..9e1a3f1 --- /dev/null +++ b/src/main/java/com/svnlog/web/service/TaskService.java @@ -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 tasks = new ConcurrentHashMap(); + + 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() { + @Override + public Void call() { + runTask(taskInfo, runner); + return null; + } + }); + + return taskId; + } + + public TaskInfo getTask(String taskId) { + return tasks.get(taskId); + } + + public List getTasks() { + return new ArrayList(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(); + } +} diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js new file mode 100644 index 0000000..cbd86e3 --- /dev/null +++ b/src/main/resources/static/app.js @@ -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 = `${task.type} · ${task.status}
${task.message || ""}`; + taskList.appendChild(li); + }); + if (taskList.children.length === 0) { + taskList.innerHTML = "
  • 暂无任务记录
  • "; + } + + 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 = `${escapeHtml(path)}
    ${formatBytes(file.size)}`; + fileList.appendChild(li); + }); + if (fileList.children.length === 0) { + fileList.innerHTML = "
  • 暂无输出文件
  • "; + } +} + +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 = "

    暂无 Markdown 文件,请先执行 SVN 抓取。

    "; + 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 = "

    暂无任务记录

    "; + return; + } + + const rows = state.tasks.map((task) => { + const files = (task.files || []).map((f) => `${escapeHtml(f)}`).join("
    "); + return ` + ${escapeHtml(task.taskId.slice(0, 8))} + ${escapeHtml(task.type)} + ${task.status} + ${task.progress || 0}% + ${escapeHtml(task.message || "")}${task.error ? `
    ${escapeHtml(task.error)}` : ""} + ${files || "-"} + `; + }).join(""); + + container.innerHTML = ` + + ${rows} +
    任务ID类型状态进度说明产物
    `; +} + +function renderFileTable() { + const container = document.querySelector("#file-table"); + if (!state.files.length) { + container.innerHTML = "

    暂无输出文件

    "; + return; + } + const rows = state.files.map((file) => { + const path = file.path; + return ` + + ${escapeHtml(path)} + ${formatBytes(file.size)} + ${formatTime(file.modifiedAt)} + 下载 + + `; + }).join(""); + container.innerHTML = ` + + ${rows} +
    文件路径大小更新时间操作
    `; +} + +async function loadSettings() { + try { + const data = await apiFetch("/api/settings"); + document.querySelector("#settings-form [name='outputDir']").value = data.outputDir || ""; + state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId; + const settingsPreset = document.querySelector("#settings-default-preset"); + if (settingsPreset && state.defaultPresetId) { + settingsPreset.value = state.defaultPresetId; + } + applyPresetToSvnForm(state.defaultPresetId); + document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource})`; + } catch (err) { + toast(err.message, true); + } +} + +async function onSaveSettings(event) { + event.preventDefault(); + const payload = readForm(event.target); + const btn = event.target.querySelector("button[type='submit']"); + setLoading(btn, true); + try { + const data = await apiFetch("/api/settings", { + method: "PUT", + body: JSON.stringify(payload), + }); + state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId; + applyPresetToSvnForm(state.defaultPresetId); + document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource})`; + toast("设置保存成功"); + } catch (err) { + toast(err.message, true); + } finally { + setLoading(btn, false); + } +} + +function readForm(form) { + const data = new FormData(form); + return Object.fromEntries(data.entries()); +} + +function setLoading(button, loading) { + if (!button) { + return; + } + button.disabled = loading; + if (loading) { + button.dataset.originalText = button.textContent; + button.textContent = "处理中..."; + } else { + button.textContent = button.dataset.originalText || button.textContent; + } +} + +function toNumberOrNull(value) { + if (value === null || value === undefined || String(value).trim() === "") { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function toast(message, isError = false) { + const el = document.querySelector("#toast"); + el.textContent = message; + el.classList.add("show"); + el.style.background = isError ? "#7a271a" : "#11343b"; + setTimeout(() => { + el.classList.remove("show"); + }, 2800); +} + +function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); +} + +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; +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..a80f3f5 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,132 @@ + + + + + + SVN 日志工作台 + + + +
    + + +
    +
    +

    工作台

    +

    查看系统状态与最近产物

    +
    + +
    +
    +
    +

    任务总数

    +

    0

    +
    +
    +

    执行中

    +

    0

    +
    +
    +

    失败任务

    +

    0

    +
    +
    + +
    +
    +

    最近任务

    +
      +
      +
      +

      最近文件

      +
        +
        +
        +
        + +
        +
        +

        SVN 抓取参数

        +
        + + + + + + + + +
        + + +
        +
        +
        +
        + +
        +
        +

        AI 分析参数

        +
        + +
        + + + +
        + +
        +
        +
        +
        + +
        +
        +

        任务列表

        +
        +
        +
        +

        输出文件

        +
        +
        +
        + +
        +
        +

        系统设置

        +
        + + + +
        + +
        +
        +

        +
        +
        + +
        +
        +
        + + + + diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css new file mode 100644 index 0000000..cc646d8 --- /dev/null +++ b/src/main/resources/static/styles.css @@ -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; + } +}