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

@@ -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_.-]", "_");
}
}