fix: harden file download flow

This commit is contained in:
liumangmang
2026-04-30 10:30:26 +08:00
parent 3555d19b26
commit aef59e354a
24 changed files with 2316 additions and 2443 deletions
+4
View File
@@ -0,0 +1,4 @@
target/
.git/
.idea/
outputs/
+8 -1
View File
@@ -1,10 +1,17 @@
# syntax=docker/dockerfile:1.7
FROM maven:3.9.6-eclipse-temurin-8 AS builder FROM maven:3.9.6-eclipse-temurin-8 AS builder
WORKDIR /app WORKDIR /app
COPY pom.xml . COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests dependency:go-offline
COPY src ./src COPY src ./src
RUN mvn -DskipTests clean package RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests clean package
FROM eclipse-temurin:8-jre FROM eclipse-temurin:8-jre
WORKDIR /app WORKDIR /app
+4 -3
View File
@@ -1,17 +1,18 @@
.PHONY: up down status .PHONY: up down status
COMPOSE_CMD := $(shell if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi) COMPOSE_CMD := $(shell if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
BUILD_ENV := DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1
up: up:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi @if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) up -d --build @$(BUILD_ENV) $(COMPOSE_CMD) up -d --build
@echo "Application is starting at http://localhost:18088" @echo "Application is starting at http://localhost:18088"
down: down:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi @if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) down @$(BUILD_ENV) $(COMPOSE_CMD) down
status: status:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi @if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) ps @$(BUILD_ENV) $(COMPOSE_CMD) ps
@echo "Access URL: http://localhost:18088" @echo "Access URL: http://localhost:18088"
+7 -1
View File
@@ -9,7 +9,7 @@ SVN 日志抓取与 AI 工作量分析工具,统一使用 Web 工作台入口
## 常用命令 ## 常用命令
```bash ```bash
# 一键启动(Docker # 一键启动(Docker,每次会重新构建镜像并打包最新代码
make up make up
# 查看状态 # 查看状态
@@ -28,6 +28,12 @@ mvn clean package -DskipTests
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
``` ```
## Docker 构建缓存说明
- `make up` 会始终执行 `docker compose up -d --build`,因此每次都会重新打包最新代码。
- Docker 构建默认启用 BuildKit;首次构建会下载 Maven 依赖,后续在 `pom.xml` 未变化时会复用 `/root/.m2` 构建缓存,通常不会重复大规模下载。
- 以下情况会触发依赖重新解析或重新下载:修改 `pom.xml`、执行 `docker builder prune` 清理构建缓存、切换到另一套 Docker Builder / Docker 环境。
## 代码结构 ## 代码结构
- `com.svnlog.web`Web 入口、控制器、DTO、服务 - `com.svnlog.web`Web 入口、控制器、DTO、服务
+21 -4
View File
@@ -8,7 +8,7 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持
2. 异步抓取日志并导出 Markdown 2. 异步抓取日志并导出 Markdown
3. 使用 DeepSeek 或 OpenAI 兼容接口分析 Markdown 并生成 Excel 3. 使用 DeepSeek 或 OpenAI 兼容接口分析 Markdown 并生成 Excel
4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务 4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
5. 下载输出文件、配置 API Key 与输出目录 5. 下载输出文件、配置 API Key、SVN 账号与输出目录
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计) 6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
批量抓取策略:多个项目按顺序执行(前一个项目完成后才开始下一个)。 批量抓取策略:多个项目按顺序执行(前一个项目完成后才开始下一个)。
@@ -18,7 +18,7 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持
在仓库根目录执行: 在仓库根目录执行:
```bash ```bash
# Docker 一键启动(推荐) # Docker 一键启动(推荐;每次会重新构建镜像并打包最新代码
make up make up
``` ```
@@ -34,13 +34,20 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
http://localhost:18088 http://localhost:18088
``` ```
## Docker 构建行为
- `make up` 保持“重新构建并启动”的语义,每次都会执行一次 Maven 打包,确保容器内是最新代码。
- Docker 构建使用 BuildKit 缓存 Maven 本地仓库;首次构建会下载依赖,后续在 `pom.xml` 未变更时会优先命中缓存,不会在每次构建时重复下载全部依赖。
- 如果修改了 `pom.xml`、执行了 `docker builder prune`、或切换到新的 Docker 环境,依赖缓存会失效并重新下载。
- 如果本机 Docker 未启用 BuildKit,可显式设置 `DOCKER_BUILDKIT=1``COMPOSE_DOCKER_CLI_BUILD=1` 后再执行 `make up`
## 页面说明 ## 页面说明
- 工作台:最近任务统计与最近产物 - 工作台:最近任务统计与最近产物
- SVN 日志抓取:SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址) - SVN 日志抓取:版本区间、过滤用户与连接测试;SVN 账号密码统一在系统设置中托管
- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名 - AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
- 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务 - 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务
- 系统设置:AI 提供商、DeepSeek API Key、OpenAI 兼容 Base URL/API Key/阶段模型、输出目录、默认 SVN 预置项目 - 系统设置:AI 提供商、DeepSeek API Key、OpenAI 兼容 Base URL/API Key/阶段模型、SVN 用户名/密码、输出目录、默认 SVN 预置项目
## 输出目录 ## 输出目录
@@ -48,6 +55,8 @@ http://localhost:18088
- Markdown 输出:`outputs/md/*.md` - Markdown 输出:`outputs/md/*.md`
- Excel 输出:`outputs/excel/*.xlsx` - Excel 输出:`outputs/excel/*.xlsx`
- 任务持久化:`outputs/task-history.json`(重启后可恢复历史) - 任务持久化:`outputs/task-history.json`(重启后可恢复历史)
- 设置页保存 `outputDir` 时会自动创建不存在的目录;如果目标路径已被普通文件占用,或当前运行用户对该目录无写权限,`PUT /api/settings` 会明确返回失败。
- 如果本机已有 `outputs/` 但属主或权限异常,可手动修复为当前用户可写后再保存设置,例如调整目录属主或写权限。
## AI 提供商设置 ## AI 提供商设置
@@ -66,6 +75,14 @@ http://localhost:18088
建议在生产环境优先使用环境变量,避免敏感信息暴露。 建议在生产环境优先使用环境变量,避免敏感信息暴露。
## SVN 凭据读取优先级
1. 单次请求显式传入的 `username/password`(兼容旧接口)
2. 设置页保存的运行时 `svnUsername/svnPassword`
3. 环境变量 `SVN_USERNAME` / `SVN_PASSWORD`
`GET /api/settings` 不会回显 `openaiApiKey``svnPassword` 明文,前端通过 `openaiApiKeyConfigured``svnCredentialsConfigured` 展示配置状态。
## SVN 预设来源与调用方式 ## SVN 预设来源与调用方式
- SVN 地址统一维护在 `application.properties``svn.presets[*]` 中。 - SVN 地址统一维护在 `application.properties``svn.presets[*]` 中。
@@ -1,6 +1,8 @@
package com.svnlog.web.controller; package com.svnlog.web.controller;
import java.io.IOException; import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
@@ -27,8 +29,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.svnlog.core.svn.SVNLogFetcher;
import com.svnlog.web.dto.AiAnalyzeRequest; import com.svnlog.web.dto.AiAnalyzeRequest;
import com.svnlog.web.dto.SettingsUpdateRequest; import com.svnlog.web.dto.SettingsUpdateRequest;
import com.svnlog.web.dto.SvnConnectionRequest; import com.svnlog.web.dto.SvnConnectionRequest;
@@ -102,18 +102,26 @@ public class AppController {
final String traceId = safe(request.getClientTraceId()); final String traceId = safe(request.getClientTraceId());
final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final String url = preset.getUrl(); final String url = preset.getUrl();
final String username = request.getUsername(); final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
final String password = request.getPassword(); request.getUsername(),
request.getPassword()
);
final int year = request.getYear().intValue(); final int year = request.getYear().intValue();
final int month = request.getMonth().intValue(); final int month = request.getMonth().intValue();
LOGGER.info( LOGGER.info(
"[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} year={} month={} username={} password={}", "[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} year={} month={} username={} password={}",
traceId, request.getPresetId(), preset.getName(), url, year, month, username, maskPassword(password) traceId,
request.getPresetId(),
preset.getName(),
url,
year,
month,
credentials.getUsername(),
maskPassword(credentials.getPassword())
); );
SVNLogFetcher fetcher = new SVNLogFetcher(url, username, password); final long[] range = svnWorkflowService.getVersionRange(request);
long[] range = fetcher.getVersionRangeByMonth(year, month, traceId);
final Map<String, Object> response = new HashMap<String, Object>(); final Map<String, Object> response = new HashMap<String, Object>();
response.put("presetId", request.getPresetId()); response.put("presetId", request.getPresetId());
@@ -246,11 +254,10 @@ public class AppController {
} }
final HttpHeaders headers = new HttpHeaders(); final HttpHeaders headers = new HttpHeaders();
headers.setContentDispositionFormData("attachment", file.getFileName().toString()); headers.add(HttpHeaders.CONTENT_DISPOSITION, buildAttachmentDisposition(file.getFileName().toString()));
headers.add("X-Content-Type-Options", "nosniff");
final MediaType mediaType = relativePath.toLowerCase().endsWith(".md") final MediaType mediaType = resolveMediaType(file);
? MediaType.parseMediaType("text/markdown")
: MediaType.APPLICATION_OCTET_STREAM;
return ResponseEntity.ok() return ResponseEntity.ok()
.headers(headers) .headers(headers)
@@ -273,6 +280,8 @@ public class AppController {
request.getOpenaiApiKey(), request.getOpenaiApiKey(),
request.getOpenaiStageOneModel(), request.getOpenaiStageOneModel(),
request.getOpenaiStageTwoModel(), request.getOpenaiStageTwoModel(),
request.getSvnUsername(),
request.getSvnPassword(),
request.getOutputDir(), request.getOutputDir(),
request.getDefaultSvnPresetId() request.getDefaultSvnPresetId()
); );
@@ -290,6 +299,50 @@ public class AppController {
return value == null ? "" : value.trim(); return value == null ? "" : value.trim();
} }
private MediaType resolveMediaType(Path file) throws IOException {
final String fileName = file.getFileName().toString().toLowerCase();
if (fileName.endsWith(".md")) {
return MediaType.parseMediaType("text/markdown; charset=UTF-8");
}
if (fileName.endsWith(".xlsx")) {
return MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
}
if (fileName.endsWith(".xls")) {
return MediaType.parseMediaType("application/vnd.ms-excel");
}
if (fileName.endsWith(".txt")) {
return MediaType.parseMediaType("text/plain; charset=UTF-8");
}
if (fileName.endsWith(".json")) {
return MediaType.APPLICATION_JSON;
}
if (fileName.endsWith(".csv")) {
return MediaType.parseMediaType("text/csv; charset=UTF-8");
}
final String detected = Files.probeContentType(file);
if (detected != null && !detected.trim().isEmpty()) {
return MediaType.parseMediaType(detected);
}
return MediaType.APPLICATION_OCTET_STREAM;
}
private String buildAttachmentDisposition(String fileName) {
final String encoded;
try {
encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name())
.replace("+", "%20");
} catch (java.io.UnsupportedEncodingException impossible) {
throw new IllegalStateException("UTF-8 encoding unavailable", impossible);
}
final String asciiFallback = fileName
.replace("\\", "_")
.replace("\"", "_")
.replace("\r", "_")
.replace("\n", "_")
.replaceAll("[^\\x20-\\x7E]", "_");
return "attachment; filename=\"" + asciiFallback + "\"; filename*=UTF-8''" + encoded;
}
private String buildSseErrorPayload(String error, String taskId, int status) { private String buildSseErrorPayload(String error, String taskId, int status) {
final String safeError = sanitize(error); final String safeError = sanitize(error);
final String safeTaskId = sanitize(taskId); final String safeTaskId = sanitize(taskId);
@@ -0,0 +1,26 @@
package com.svnlog.web.controller;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
@GetMapping(value = {"/", "/index.html"})
public ResponseEntity<Resource> index() {
final HttpHeaders headers = new HttpHeaders();
headers.setCacheControl(CacheControl.noStore().mustRevalidate().getHeaderValue());
headers.add(HttpHeaders.PRAGMA, "no-cache");
headers.add(HttpHeaders.EXPIRES, "0");
return ResponseEntity.ok()
.headers(headers)
.contentType(MediaType.TEXT_HTML)
.body(new ClassPathResource("static/index.html"));
}
}
@@ -8,6 +8,8 @@ public class SettingsUpdateRequest {
private String openaiApiKey; private String openaiApiKey;
private String openaiStageOneModel; private String openaiStageOneModel;
private String openaiStageTwoModel; private String openaiStageTwoModel;
private String svnUsername;
private String svnPassword;
private String outputDir; private String outputDir;
private String defaultSvnPresetId; private String defaultSvnPresetId;
@@ -59,6 +61,22 @@ public class SettingsUpdateRequest {
this.openaiStageTwoModel = openaiStageTwoModel; this.openaiStageTwoModel = openaiStageTwoModel;
} }
public String getSvnUsername() {
return svnUsername;
}
public void setSvnUsername(String svnUsername) {
this.svnUsername = svnUsername;
}
public String getSvnPassword() {
return svnPassword;
}
public void setSvnPassword(String svnPassword) {
this.svnPassword = svnPassword;
}
public String getOutputDir() { public String getOutputDir() {
return outputDir; return outputDir;
} }
@@ -7,10 +7,8 @@ public class SvnConnectionRequest {
@NotBlank @NotBlank
private String presetId; private String presetId;
@NotBlank
private String username; private String username;
@NotBlank
private String password; private String password;
public String getPresetId() { public String getPresetId() {
@@ -9,10 +9,8 @@ public class SvnFetchRequest {
private String projectName; private String projectName;
@NotBlank
private String username; private String username;
@NotBlank
private String password; private String password;
private Long startRevision; private Long startRevision;
@@ -8,10 +8,8 @@ public class SvnVersionRangeRequest {
@NotBlank @NotBlank
private String presetId; private String presetId;
@NotBlank
private String username; private String username;
@NotBlank
private String password; private String password;
@NotNull @NotNull
@@ -0,0 +1,94 @@
package com.svnlog.web.model;
public class PersistedSettings {
private String provider;
private String apiKey;
private String openaiBaseUrl;
private String openaiApiKey;
private String openaiStageOneModel;
private String openaiStageTwoModel;
private String svnUsername;
private String svnPassword;
private String outputDir;
private String defaultSvnPresetId;
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
this.provider = provider;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getOpenaiBaseUrl() {
return openaiBaseUrl;
}
public void setOpenaiBaseUrl(String openaiBaseUrl) {
this.openaiBaseUrl = openaiBaseUrl;
}
public String getOpenaiApiKey() {
return openaiApiKey;
}
public void setOpenaiApiKey(String openaiApiKey) {
this.openaiApiKey = openaiApiKey;
}
public String getOpenaiStageOneModel() {
return openaiStageOneModel;
}
public void setOpenaiStageOneModel(String openaiStageOneModel) {
this.openaiStageOneModel = openaiStageOneModel;
}
public String getOpenaiStageTwoModel() {
return openaiStageTwoModel;
}
public void setOpenaiStageTwoModel(String openaiStageTwoModel) {
this.openaiStageTwoModel = openaiStageTwoModel;
}
public String getSvnUsername() {
return svnUsername;
}
public void setSvnUsername(String svnUsername) {
this.svnUsername = svnUsername;
}
public String getSvnPassword() {
return svnPassword;
}
public void setSvnPassword(String svnPassword) {
this.svnPassword = svnPassword;
}
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;
}
}
@@ -28,11 +28,35 @@ public class OutputFileService {
} }
} }
public synchronized Path peekOutputRoot() {
return outputRoot;
}
public synchronized Path getOutputRoot() throws IOException { public synchronized Path getOutputRoot() throws IOException {
Files.createDirectories(outputRoot); Files.createDirectories(outputRoot);
return outputRoot; return outputRoot;
} }
public synchronized Path ensureOutputRootWritable() throws IOException {
final Path root = outputRoot;
if (Files.exists(root)) {
if (!Files.isDirectory(root)) {
throw new IOException("输出路径不是目录: " + root);
}
} else {
try {
Files.createDirectories(root);
} catch (IOException e) {
throw new IOException("无法创建输出目录: " + root + ",请检查父目录是否存在且当前用户具备写权限", e);
}
}
if (!Files.isWritable(root)) {
throw new IOException("输出目录不可写: " + root + ",请调整目录权限或改为当前用户可写的目录");
}
return root;
}
public Path resolveInOutput(String relative) throws IOException { public Path resolveInOutput(String relative) throws IOException {
final Path root = getOutputRoot(); final Path root = getOutputRoot();
final Path resolved = root.resolve(relative).normalize(); final Path resolved = root.resolve(relative).normalize();
@@ -0,0 +1,43 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.stereotype.Service;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.svnlog.web.model.PersistedSettings;
@Service
public class SettingsPersistenceService {
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
public PersistedSettings load(Path storePath) throws IOException {
if (storePath == null || !Files.exists(storePath) || !Files.isRegularFile(storePath)) {
return null;
}
try (Reader reader = Files.newBufferedReader(storePath, StandardCharsets.UTF_8)) {
return gson.fromJson(reader, PersistedSettings.class);
}
}
public void save(Path storePath, PersistedSettings settings) throws IOException {
if (storePath == null || settings == null) {
return;
}
if (storePath.getParent() != null) {
Files.createDirectories(storePath.getParent());
}
try (Writer writer = Files.newBufferedWriter(storePath, StandardCharsets.UTF_8)) {
gson.toJson(settings, writer);
}
}
}
@@ -1,15 +1,24 @@
package com.svnlog.web.service; package com.svnlog.web.service;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.svnlog.web.model.PersistedSettings;
@Service @Service
public class SettingsService { public class SettingsService {
private static final Logger LOGGER = LoggerFactory.getLogger(SettingsService.class);
public static final String PROVIDER_DEEPSEEK = "deepseek"; public static final String PROVIDER_DEEPSEEK = "deepseek";
public static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible"; public static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible";
private static final String SETTINGS_FILE_NAME = "settings.json";
// 启动默认 API Key(仅作为本地默认值,可在设置页覆盖) // 启动默认 API Key(仅作为本地默认值,可在设置页覆盖)
private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7"; private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7";
@@ -17,27 +26,63 @@ public class SettingsService {
private static final String DEFAULT_OPENAI_API_KEY = "sk-f8b3f43e1fdd4f50b287050f08a6c7ed"; private static final String DEFAULT_OPENAI_API_KEY = "sk-f8b3f43e1fdd4f50b287050f08a6c7ed";
private static final String DEFAULT_OPENAI_STAGE_ONE_MODEL = "deepseek-v4-flash"; private static final String DEFAULT_OPENAI_STAGE_ONE_MODEL = "deepseek-v4-flash";
private static final String DEFAULT_OPENAI_STAGE_TWO_MODEL = "deepseek-v4-pro"; private static final String DEFAULT_OPENAI_STAGE_TWO_MODEL = "deepseek-v4-pro";
private static final String ENV_SVN_USERNAME = "SVN_USERNAME";
private static final String ENV_SVN_PASSWORD = "SVN_PASSWORD";
private final OutputFileService outputFileService; private final OutputFileService outputFileService;
private final SettingsPersistenceService settingsPersistenceService;
private final SvnPresetService svnPresetService; private final SvnPresetService svnPresetService;
private final Path bootstrapOutputRoot;
private volatile String runtimeApiKey; private volatile String runtimeApiKey;
private volatile String runtimeProvider; private volatile String runtimeProvider;
private volatile String runtimeOpenaiBaseUrl; private volatile String runtimeOpenaiBaseUrl;
private volatile String runtimeOpenaiApiKey; private volatile String runtimeOpenaiApiKey;
private volatile String runtimeOpenaiStageOneModel; private volatile String runtimeOpenaiStageOneModel;
private volatile String runtimeOpenaiStageTwoModel; private volatile String runtimeOpenaiStageTwoModel;
private volatile String runtimeSvnUsername;
private volatile String runtimeSvnPassword;
private volatile String defaultSvnPresetId; private volatile String defaultSvnPresetId;
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) { public static final class SvnCredentials {
private final String username;
private final String password;
public SvnCredentials(String username, String password) {
this.username = username;
this.password = password;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public boolean isConfigured() {
return !isBlank(username) && !isBlank(password);
}
}
@Autowired
public SettingsService(OutputFileService outputFileService,
SettingsPersistenceService settingsPersistenceService,
SvnPresetService svnPresetService) {
this.outputFileService = outputFileService; this.outputFileService = outputFileService;
this.settingsPersistenceService = settingsPersistenceService;
this.svnPresetService = svnPresetService; this.svnPresetService = svnPresetService;
this.bootstrapOutputRoot = initBootstrapOutputRoot(outputFileService);
this.runtimeApiKey = initStartupApiKey(); this.runtimeApiKey = initStartupApiKey();
this.runtimeProvider = PROVIDER_DEEPSEEK; this.runtimeProvider = PROVIDER_DEEPSEEK;
this.runtimeOpenaiBaseUrl = DEFAULT_OPENAI_BASE_URL; this.runtimeOpenaiBaseUrl = DEFAULT_OPENAI_BASE_URL;
this.runtimeOpenaiApiKey = DEFAULT_OPENAI_API_KEY; this.runtimeOpenaiApiKey = DEFAULT_OPENAI_API_KEY;
this.runtimeOpenaiStageOneModel = DEFAULT_OPENAI_STAGE_ONE_MODEL; this.runtimeOpenaiStageOneModel = DEFAULT_OPENAI_STAGE_ONE_MODEL;
this.runtimeOpenaiStageTwoModel = DEFAULT_OPENAI_STAGE_TWO_MODEL; this.runtimeOpenaiStageTwoModel = DEFAULT_OPENAI_STAGE_TWO_MODEL;
this.runtimeSvnUsername = trim(System.getenv(ENV_SVN_USERNAME));
this.runtimeSvnPassword = trim(System.getenv(ENV_SVN_PASSWORD));
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId(); this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
loadPersistedSettings();
} }
public Map<String, Object> getSettings() throws IOException { public Map<String, Object> getSettings() throws IOException {
@@ -49,23 +94,37 @@ public class SettingsService {
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty()); result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
result.put("apiKeySource", detectApiKeySource(envKey)); result.put("apiKeySource", detectApiKeySource(envKey));
result.put("openaiBaseUrl", getOpenaiBaseUrl()); result.put("openaiBaseUrl", getOpenaiBaseUrl());
result.put("openaiApiKey", getOpenaiApiKey());
result.put("openaiApiKeyConfigured", isConfigured(getOpenaiApiKey())); result.put("openaiApiKeyConfigured", isConfigured(getOpenaiApiKey()));
result.put("openaiStageOneModel", getOpenaiStageOneModel()); result.put("openaiStageOneModel", getOpenaiStageOneModel());
result.put("openaiStageTwoModel", getOpenaiStageTwoModel()); result.put("openaiStageTwoModel", getOpenaiStageTwoModel());
result.put("svnUsername", getSvnUsername());
result.put("svnCredentialsConfigured", getConfiguredSvnCredentials().isConfigured());
result.put("outputDir", outputFileService.getOutputRoot().toString()); result.put("outputDir", outputFileService.getOutputRoot().toString());
result.put("defaultSvnPresetId", getDefaultSvnPresetId()); result.put("defaultSvnPresetId", getDefaultSvnPresetId());
return result; return result;
} }
public void updateSettings(String apiKey, public synchronized void updateSettings(String apiKey,
String provider, String provider,
String openaiBaseUrl, String openaiBaseUrl,
String openaiApiKey, String openaiApiKey,
String openaiStageOneModel, String openaiStageOneModel,
String openaiStageTwoModel, String openaiStageTwoModel,
String svnUsername,
String svnPassword,
String outputDir, String outputDir,
String newDefaultSvnPresetId) { String newDefaultSvnPresetId) {
final String previousRuntimeApiKey = runtimeApiKey;
final String previousRuntimeProvider = runtimeProvider;
final String previousRuntimeOpenaiBaseUrl = runtimeOpenaiBaseUrl;
final String previousRuntimeOpenaiApiKey = runtimeOpenaiApiKey;
final String previousRuntimeOpenaiStageOneModel = runtimeOpenaiStageOneModel;
final String previousRuntimeOpenaiStageTwoModel = runtimeOpenaiStageTwoModel;
final String previousRuntimeSvnUsername = runtimeSvnUsername;
final String previousRuntimeSvnPassword = runtimeSvnPassword;
final String previousDefaultSvnPresetId = defaultSvnPresetId;
final Path previousOutputRoot = outputFileService.peekOutputRoot();
try {
if (apiKey != null && !apiKey.trim().isEmpty()) { if (apiKey != null && !apiKey.trim().isEmpty()) {
this.runtimeApiKey = apiKey.trim(); this.runtimeApiKey = apiKey.trim();
} }
@@ -82,12 +141,74 @@ public class SettingsService {
if (openaiStageTwoModel != null && !openaiStageTwoModel.trim().isEmpty()) { if (openaiStageTwoModel != null && !openaiStageTwoModel.trim().isEmpty()) {
this.runtimeOpenaiStageTwoModel = openaiStageTwoModel.trim(); this.runtimeOpenaiStageTwoModel = openaiStageTwoModel.trim();
} }
if (svnUsername != null && !svnUsername.trim().isEmpty()) {
this.runtimeSvnUsername = svnUsername.trim();
}
if (svnPassword != null && !svnPassword.trim().isEmpty()) {
this.runtimeSvnPassword = svnPassword.trim();
}
if (outputDir != null && !outputDir.trim().isEmpty()) { if (outputDir != null && !outputDir.trim().isEmpty()) {
outputFileService.setOutputRoot(outputDir); outputFileService.setOutputRoot(outputDir);
} }
if (svnPresetService.containsPresetId(newDefaultSvnPresetId)) { if (svnPresetService.containsPresetId(newDefaultSvnPresetId)) {
this.defaultSvnPresetId = newDefaultSvnPresetId; this.defaultSvnPresetId = newDefaultSvnPresetId;
} }
outputFileService.ensureOutputRootWritable();
persistCurrentSettings();
} catch (IOException e) {
rollbackSettings(
previousRuntimeApiKey,
previousRuntimeProvider,
previousRuntimeOpenaiBaseUrl,
previousRuntimeOpenaiApiKey,
previousRuntimeOpenaiStageOneModel,
previousRuntimeOpenaiStageTwoModel,
previousRuntimeSvnUsername,
previousRuntimeSvnPassword,
previousDefaultSvnPresetId,
previousOutputRoot
);
throw new IllegalStateException("保存系统设置失败: " + e.getMessage(), e);
} catch (RuntimeException e) {
rollbackSettings(
previousRuntimeApiKey,
previousRuntimeProvider,
previousRuntimeOpenaiBaseUrl,
previousRuntimeOpenaiApiKey,
previousRuntimeOpenaiStageOneModel,
previousRuntimeOpenaiStageTwoModel,
previousRuntimeSvnUsername,
previousRuntimeSvnPassword,
previousDefaultSvnPresetId,
previousOutputRoot
);
throw e;
}
}
private synchronized void rollbackSettings(String runtimeApiKey,
String runtimeProvider,
String runtimeOpenaiBaseUrl,
String runtimeOpenaiApiKey,
String runtimeOpenaiStageOneModel,
String runtimeOpenaiStageTwoModel,
String runtimeSvnUsername,
String runtimeSvnPassword,
String defaultSvnPresetId,
Path outputRoot) {
this.runtimeApiKey = runtimeApiKey;
this.runtimeProvider = runtimeProvider;
this.runtimeOpenaiBaseUrl = runtimeOpenaiBaseUrl;
this.runtimeOpenaiApiKey = runtimeOpenaiApiKey;
this.runtimeOpenaiStageOneModel = runtimeOpenaiStageOneModel;
this.runtimeOpenaiStageTwoModel = runtimeOpenaiStageTwoModel;
this.runtimeSvnUsername = runtimeSvnUsername;
this.runtimeSvnPassword = runtimeSvnPassword;
this.defaultSvnPresetId = defaultSvnPresetId;
if (outputRoot != null) {
outputFileService.setOutputRoot(outputRoot.toString());
}
} }
public String pickActiveKey(String requestKey) { public String pickActiveKey(String requestKey) {
@@ -116,6 +237,99 @@ public class SettingsService {
return null; return null;
} }
private synchronized void loadPersistedSettings() {
try {
final Path bootstrapStorePath = getBootstrapSettingsPath();
applyPersistedSettings(settingsPersistenceService.load(bootstrapStorePath));
final Path activeStorePath = getCurrentSettingsPath();
if (!activeStorePath.equals(bootstrapStorePath)) {
applyPersistedSettings(settingsPersistenceService.load(activeStorePath));
}
} catch (IOException e) {
LOGGER.warn("Failed to load persisted settings", e);
}
}
private void applyPersistedSettings(PersistedSettings persistedSettings) {
if (persistedSettings == null) {
return;
}
if (!isBlank(persistedSettings.getApiKey())) {
this.runtimeApiKey = persistedSettings.getApiKey().trim();
}
if (!isBlank(persistedSettings.getProvider())) {
this.runtimeProvider = normalizeProvider(persistedSettings.getProvider());
}
if (!isBlank(persistedSettings.getOpenaiBaseUrl())) {
this.runtimeOpenaiBaseUrl = persistedSettings.getOpenaiBaseUrl().trim();
}
if (!isBlank(persistedSettings.getOpenaiApiKey())) {
this.runtimeOpenaiApiKey = persistedSettings.getOpenaiApiKey().trim();
}
if (!isBlank(persistedSettings.getOpenaiStageOneModel())) {
this.runtimeOpenaiStageOneModel = persistedSettings.getOpenaiStageOneModel().trim();
}
if (!isBlank(persistedSettings.getOpenaiStageTwoModel())) {
this.runtimeOpenaiStageTwoModel = persistedSettings.getOpenaiStageTwoModel().trim();
}
if (!isBlank(persistedSettings.getSvnUsername())) {
this.runtimeSvnUsername = persistedSettings.getSvnUsername().trim();
}
if (!isBlank(persistedSettings.getSvnPassword())) {
this.runtimeSvnPassword = persistedSettings.getSvnPassword().trim();
}
if (!isBlank(persistedSettings.getOutputDir())) {
outputFileService.setOutputRoot(persistedSettings.getOutputDir());
}
if (svnPresetService.containsPresetId(persistedSettings.getDefaultSvnPresetId())) {
this.defaultSvnPresetId = persistedSettings.getDefaultSvnPresetId().trim();
}
}
private void persistCurrentSettings() throws IOException {
final PersistedSettings persistedSettings = snapshotCurrentSettings();
final Path activeStorePath = getCurrentSettingsPath();
settingsPersistenceService.save(activeStorePath, persistedSettings);
final Path bootstrapStorePath = getBootstrapSettingsPath();
if (!bootstrapStorePath.equals(activeStorePath)) {
settingsPersistenceService.save(bootstrapStorePath, persistedSettings);
}
}
private PersistedSettings snapshotCurrentSettings() throws IOException {
final PersistedSettings persistedSettings = new PersistedSettings();
persistedSettings.setProvider(getProvider());
persistedSettings.setApiKey(trim(runtimeApiKey));
persistedSettings.setOpenaiBaseUrl(getOpenaiBaseUrl());
persistedSettings.setOpenaiApiKey(trim(runtimeOpenaiApiKey));
persistedSettings.setOpenaiStageOneModel(getOpenaiStageOneModel());
persistedSettings.setOpenaiStageTwoModel(getOpenaiStageTwoModel());
persistedSettings.setSvnUsername(trim(runtimeSvnUsername));
persistedSettings.setSvnPassword(trim(runtimeSvnPassword));
persistedSettings.setOutputDir(outputFileService.getOutputRoot().toString());
persistedSettings.setDefaultSvnPresetId(getDefaultSvnPresetId());
return persistedSettings;
}
private Path getCurrentSettingsPath() throws IOException {
return outputFileService.getOutputRoot().resolve(SETTINGS_FILE_NAME);
}
private Path getBootstrapSettingsPath() {
return bootstrapOutputRoot.resolve(SETTINGS_FILE_NAME);
}
private Path initBootstrapOutputRoot(OutputFileService outputFileService) {
try {
return outputFileService.getOutputRoot();
} catch (IOException e) {
LOGGER.warn("Failed to resolve bootstrap output root, fallback to ./outputs", e);
return Paths.get("outputs").toAbsolutePath().normalize();
}
}
private String detectApiKeySource(String envKey) { private String detectApiKeySource(String envKey) {
if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) { if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) {
if (envKey != null && !envKey.trim().isEmpty() && runtimeApiKey.equals(envKey.trim())) { if (envKey != null && !envKey.trim().isEmpty() && runtimeApiKey.equals(envKey.trim())) {
@@ -148,6 +362,23 @@ public class SettingsService {
return trimOrDefault(runtimeOpenaiApiKey, DEFAULT_OPENAI_API_KEY); return trimOrDefault(runtimeOpenaiApiKey, DEFAULT_OPENAI_API_KEY);
} }
public String getSvnUsername() {
return trim(resolveSvnUsername(null));
}
public SvnCredentials resolveSvnCredentials(String requestUsername, String requestPassword) {
final String username = resolveSvnUsername(requestUsername);
final String password = resolveSvnPassword(requestPassword);
if (isBlank(username) || isBlank(password)) {
throw new IllegalArgumentException("未配置 SVN 账号,请先到系统设置页填写 SVN 用户名和密码");
}
return new SvnCredentials(username, password);
}
public SvnCredentials getConfiguredSvnCredentials() {
return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null));
}
public String getOpenaiStageOneModel() { public String getOpenaiStageOneModel() {
return trimOrDefault(runtimeOpenaiStageOneModel, DEFAULT_OPENAI_STAGE_ONE_MODEL); return trimOrDefault(runtimeOpenaiStageOneModel, DEFAULT_OPENAI_STAGE_ONE_MODEL);
} }
@@ -172,7 +403,35 @@ public class SettingsService {
return value != null && !value.trim().isEmpty(); return value != null && !value.trim().isEmpty();
} }
private String resolveSvnUsername(String requestUsername) {
final String requestValue = trim(requestUsername);
if (!requestValue.isEmpty()) {
return requestValue;
}
final String runtimeValue = trim(runtimeSvnUsername);
if (!runtimeValue.isEmpty()) {
return runtimeValue;
}
return trim(System.getenv(ENV_SVN_USERNAME));
}
private String resolveSvnPassword(String requestPassword) {
final String requestValue = trim(requestPassword);
if (!requestValue.isEmpty()) {
return requestValue;
}
final String runtimeValue = trim(runtimeSvnPassword);
if (!runtimeValue.isEmpty()) {
return runtimeValue;
}
return trim(System.getenv(ENV_SVN_PASSWORD));
}
private String trim(String value) { private String trim(String value) {
return value == null ? "" : value.trim(); return value == null ? "" : value.trim();
} }
private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
} }
@@ -13,6 +13,7 @@ import com.svnlog.core.svn.LogEntry;
import com.svnlog.core.svn.SVNLogFetcher; import com.svnlog.core.svn.SVNLogFetcher;
import com.svnlog.web.dto.SvnConnectionRequest; import com.svnlog.web.dto.SvnConnectionRequest;
import com.svnlog.web.dto.SvnFetchRequest; import com.svnlog.web.dto.SvnFetchRequest;
import com.svnlog.web.dto.SvnVersionRangeRequest;
import com.svnlog.web.model.SvnPreset; import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.TaskResult; import com.svnlog.web.model.TaskResult;
@@ -20,23 +21,61 @@ import com.svnlog.web.model.TaskResult;
public class SvnWorkflowService { public class SvnWorkflowService {
private final OutputFileService outputFileService; private final OutputFileService outputFileService;
private final SettingsService settingsService;
private final SvnPresetService svnPresetService; private final SvnPresetService svnPresetService;
public SvnWorkflowService(OutputFileService outputFileService, SvnPresetService svnPresetService) { public SvnWorkflowService(OutputFileService outputFileService,
SettingsService settingsService,
SvnPresetService svnPresetService) {
this.outputFileService = outputFileService; this.outputFileService = outputFileService;
this.settingsService = settingsService;
this.svnPresetService = svnPresetService; this.svnPresetService = svnPresetService;
} }
public void testConnection(SvnConnectionRequest request) throws SVNException { public void testConnection(SvnConnectionRequest request) throws SVNException {
final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword()); final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
);
final SVNLogFetcher fetcher = new SVNLogFetcher(
preset.getUrl(),
credentials.getUsername(),
credentials.getPassword()
);
fetcher.testConnection(); fetcher.testConnection();
} }
public long[] getVersionRange(SvnVersionRangeRequest request) throws SVNException {
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
);
final SVNLogFetcher fetcher = new SVNLogFetcher(
preset.getUrl(),
credentials.getUsername(),
credentials.getPassword()
);
return fetcher.getVersionRangeByMonth(
request.getYear().intValue(),
request.getMonth().intValue(),
request.getClientTraceId()
);
}
public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception { public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception {
final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
);
context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName()); context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName());
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword()); final SVNLogFetcher fetcher = new SVNLogFetcher(
preset.getUrl(),
credentials.getUsername(),
credentials.getPassword()
);
fetcher.testConnection(); fetcher.testConnection();
context.setProgress(30, "正在拉取 SVN 日志"); context.setProgress(30, "正在拉取 SVN 日志");
@@ -60,7 +99,7 @@ public class SvnWorkflowService {
MarkdownReportWriter.write( MarkdownReportWriter.write(
outputPath, outputPath,
preset.getUrl(), preset.getUrl(),
request.getUsername(), credentials.getUsername(),
start, start,
end, end,
request.getFilterUser(), request.getFilterUser(),
File diff suppressed because it is too large Load Diff
@@ -1,425 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SVN 日志工作台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles-redesign.css">
</head>
<body>
<!-- 主题切换按钮 -->
<button class="theme-toggle" id="theme-toggle" aria-label="切换主题">
<svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<div class="app-container">
<!-- 侧边栏 -->
<aside class="sidebar" role="navigation" aria-label="主导航">
<div class="sidebar-header">
<svg class="logo-icon" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
<h1 class="logo-text">SVN 工作台</h1>
</div>
<nav class="nav-menu">
<button class="nav-item active" data-view="dashboard">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
<span>工作台</span>
</button>
<button class="nav-item" data-view="svn">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>SVN 日志</span>
</button>
<button class="nav-item" data-view="ai">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<span>AI 分析</span>
</button>
<button class="nav-item" data-view="history">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>任务历史</span>
</button>
<button class="nav-item" data-view="settings">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v6m0 6v6m5.2-13.2l-4.2 4.2m-2 2l-4.2 4.2M23 12h-6m-6 0H5m13.2 5.2l-4.2-4.2m-2-2l-4.2-4.2"/>
</svg>
<span>系统设置</span>
</button>
</nav>
<div class="sidebar-footer">
<div class="status-indicator">
<span class="status-dot"></span>
<span class="status-text">系统正常</span>
</div>
</div>
</aside>
<!-- 主内容区 -->
<main class="main-content" id="main-content">
<!-- 页面头部 -->
<header class="page-header">
<div class="header-content">
<h2 class="page-title" id="page-title">工作台</h2>
<p class="page-description" id="page-description">查看系统状态与最近产物</p>
</div>
</header>
<!-- 工作台视图 -->
<section class="view active" id="view-dashboard" aria-live="polite">
<!-- 统计卡片 -->
<div class="stats-grid">
<article class="stat-card">
<div class="stat-icon stat-icon-primary">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-label">任务总数</p>
<p class="stat-value" id="stat-total">0</p>
</div>
</article>
<article class="stat-card">
<div class="stat-icon stat-icon-warning">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-label">执行中</p>
<p class="stat-value" id="stat-running">0</p>
</div>
</article>
<article class="stat-card">
<div class="stat-icon stat-icon-danger">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-label">失败任务</p>
<p class="stat-value" id="stat-failed">0</p>
</div>
</article>
<article class="stat-card">
<div class="stat-icon stat-icon-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-label">系统状态</p>
<p class="stat-value stat-value-small" id="stat-health">-</p>
</div>
</article>
</div>
<!-- 健康检查卡片 -->
<article class="glass-card" id="health-card">
<div class="card-header">
<h3 class="card-title">健康检查</h3>
</div>
<div class="card-body">
<p class="text-muted" id="health-details">加载中...</p>
</div>
</article>
<!-- 最近任务和文件 -->
<div class="grid-2">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">最近任务</h3>
</div>
<div class="card-body">
<ul id="recent-tasks" class="item-list"></ul>
</div>
</article>
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">最近文件</h3>
</div>
<div class="card-body">
<ul id="recent-files" class="item-list"></ul>
</div>
</article>
</div>
</section>
<!-- SVN 日志抓取视图 -->
<section class="view" id="view-svn">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">SVN 抓取参数</h3>
</div>
<div class="card-body">
<form id="svn-form" class="form-layout">
<div class="form-group">
<label for="svn-preset-select" class="form-label">预置项目</label>
<select name="presetId" id="svn-preset-select" class="form-select" aria-label="预置 SVN 项目"></select>
</div>
<div class="form-group">
<label for="project-name" class="form-label">项目名</label>
<input type="text" name="projectName" id="project-name" class="form-input" placeholder="如:PRS-7050">
</div>
<div class="form-group form-group-full">
<label for="svn-url" class="form-label">SVN 地址 <span class="required">*</span></label>
<input type="url" name="url" id="svn-url" class="form-input" placeholder="https://..." required aria-label="SVN 地址">
</div>
<div class="form-group">
<label for="svn-username" class="form-label">账号 <span class="required">*</span></label>
<input type="text" name="username" id="svn-username" class="form-input" placeholder="请输入账号" required>
</div>
<div class="form-group">
<label for="svn-password" class="form-label">密码 <span class="required">*</span></label>
<input type="password" name="password" id="svn-password" class="form-input" placeholder="请输入密码" required>
</div>
<div class="form-group">
<label for="start-revision" class="form-label">开始版本号</label>
<input type="text" name="startRevision" id="start-revision" class="form-input" inputmode="numeric" placeholder="默认最新">
</div>
<div class="form-group">
<label for="end-revision" class="form-label">结束版本号</label>
<input type="text" name="endRevision" id="end-revision" class="form-input" inputmode="numeric" placeholder="默认最新">
</div>
<div class="form-group form-group-full">
<label for="filter-user" class="form-label">过滤用户名</label>
<input type="text" name="filterUser" id="filter-user" class="form-input" placeholder="包含匹配,留空不过滤">
</div>
<div class="form-actions form-group-full">
<button type="button" id="btn-test-connection" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
测试连接
</button>
<button type="submit" id="btn-svn-run" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
开始抓取并导出
</button>
</div>
</form>
</div>
</article>
</section>
<!-- AI 工作量分析视图 -->
<section class="view" id="view-ai">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">AI 分析参数</h3>
</div>
<div class="card-body">
<form id="ai-form" class="form-layout">
<div class="form-group form-group-full">
<label class="form-label">选择 Markdown 输入文件</label>
<div class="file-picker" id="md-file-picker" role="group" aria-label="Markdown 文件选择"></div>
</div>
<div class="form-group">
<label for="work-period" class="form-label">工作周期</label>
<input type="text" name="period" id="work-period" class="form-input" placeholder="例如 2026年03月">
</div>
<div class="form-group">
<label for="output-filename" class="form-label">输出文件名</label>
<input type="text" name="outputFileName" id="output-filename" class="form-input" placeholder="例如 202603工作量统计.xlsx">
</div>
<div class="form-group form-group-full">
<label for="temp-api-key" class="form-label">临时 API Key(可选)</label>
<input type="password" name="apiKey" id="temp-api-key" class="form-input" placeholder="优先使用设置页或环境变量">
</div>
<div class="form-actions form-group-full">
<button type="submit" id="btn-ai-run" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
开始 AI 分析并导出 Excel
</button>
</div>
</form>
</div>
</article>
</section>
<!-- 任务历史视图 -->
<section class="view" id="view-history">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">任务列表</h3>
</div>
<div class="card-body">
<div class="filter-toolbar" id="history-toolbar">
<select id="task-filter-status" class="form-select" aria-label="状态筛选">
<option value="">全部状态</option>
<option value="PENDING">PENDING</option>
<option value="RUNNING">RUNNING</option>
<option value="SUCCESS">SUCCESS</option>
<option value="FAILED">FAILED</option>
<option value="CANCELLED">CANCELLED</option>
</select>
<select id="task-filter-type" class="form-select" aria-label="类型筛选">
<option value="">全部类型</option>
<option value="SVN_FETCH">SVN_FETCH</option>
<option value="AI_ANALYZE">AI_ANALYZE</option>
</select>
<input id="task-filter-keyword" class="form-input" placeholder="搜索任务ID/信息" aria-label="关键词搜索">
<button id="btn-task-filter" type="button" class="btn btn-secondary">查询</button>
</div>
<div id="task-table" class="table-container"></div>
<div class="pagination" id="task-pager"></div>
</div>
</article>
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">输出文件</h3>
</div>
<div class="card-body">
<div id="file-table" class="table-container"></div>
</div>
</article>
</section>
<!-- 系统设置视图 -->
<section class="view" id="view-settings">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">系统设置</h3>
</div>
<div class="card-body">
<form id="settings-form" class="form-layout">
<div class="form-group form-group-full">
<label for="settings-provider" class="form-label">AI 提供商</label>
<select name="provider" id="settings-provider" class="form-select">
<option value="deepseek">DeepSeek</option>
<option value="openai-compatible">OpenAI兼容</option>
</select>
</div>
<div class="form-group form-group-full">
<label for="api-key-input" class="form-label">DeepSeek API Key</label>
<input type="password" name="apiKey" id="api-key-input" class="form-input" placeholder="设置后将保存在当前进程内存">
</div>
<div class="form-group form-group-full" id="openai-settings-group" hidden>
<label for="openai-base-url" class="form-label">OpenAI兼容 Base URL</label>
<input type="text" name="openaiBaseUrl" id="openai-base-url" class="form-input" placeholder="例如 http://127.0.0.1:5001/v1">
</div>
<div class="form-group form-group-full" id="openai-api-key-group" hidden>
<label for="openai-api-key" class="form-label">OpenAI兼容 API Key</label>
<input type="password" name="openaiApiKey" id="openai-api-key" class="form-input" placeholder="设置后将保存在当前进程内存">
</div>
<div class="form-group" id="openai-stage-one-group" hidden>
<label for="openai-stage-one-model" class="form-label">第一阶段模型</label>
<select name="openaiStageOneModel" id="openai-stage-one-model" class="form-select">
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
</select>
</div>
<div class="form-group" id="openai-stage-two-group" hidden>
<label for="openai-stage-two-model" class="form-label">第二阶段模型</label>
<select name="openaiStageTwoModel" id="openai-stage-two-model" class="form-select">
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
</select>
</div>
<div class="form-group form-group-full">
<label for="default-preset" class="form-label">默认 SVN 项目</label>
<select name="defaultSvnPresetId" id="default-preset" class="form-select"></select>
</div>
<div class="form-group form-group-full">
<label for="output-dir" class="form-label">输出目录</label>
<input type="text" name="outputDir" id="output-dir" class="form-input" placeholder="默认 outputs">
</div>
<div class="form-actions form-group-full">
<button type="submit" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
保存设置
</button>
</div>
</form>
<p id="settings-state" class="text-muted"></p>
</div>
</article>
</section>
</main>
</div>
<!-- Toast 通知 -->
<div class="toast-container" id="toast-container" aria-live="assertive" aria-atomic="true"></div>
<!-- 加载指示器 -->
<div class="loading-overlay" id="loading-overlay">
<div class="loading-spinner"></div>
</div>
<script src="/app-redesign.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-746
View File
@@ -1,746 +0,0 @@
:root {
--bg-0: #0b1020;
--bg-1: #121a2f;
--bg-2: #1a2744;
--surface-0: rgba(19, 30, 53, 0.62);
--surface-1: rgba(24, 37, 64, 0.84);
--surface-2: #1f3158;
--text-0: #e8efff;
--text-1: #c4d2f0;
--text-2: #91a3cc;
--accent-0: #6ba6ff;
--accent-1: #8fc0ff;
--accent-soft: rgba(107, 166, 255, 0.16);
--success: #49c28a;
--warning: #f0b85d;
--danger: #ff7f87;
--border-0: rgba(150, 180, 230, 0.24);
--border-1: rgba(150, 180, 230, 0.4);
--shadow-0: 0 22px 54px rgba(4, 8, 20, 0.45);
--shadow-1: 0 10px 30px rgba(8, 14, 32, 0.36);
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 20px;
--space-1: 8px;
--space-2: 12px;
--space-3: 16px;
--space-4: 20px;
--space-5: 24px;
--z-bg: 0;
--z-layout: 5;
--z-toast: 50;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text-0);
background:
radial-gradient(1200px 800px at -15% 130%, #1d2749 0%, transparent 60%),
radial-gradient(900px 700px at 110% -10%, #1f3b67 0%, transparent 62%),
linear-gradient(160deg, var(--bg-0) 0%, var(--bg-1) 52%, #131f38 100%);
position: relative;
overflow-x: hidden;
}
.bg-grid {
position: fixed;
inset: 0;
z-index: var(--z-bg);
background-image:
linear-gradient(rgba(116, 153, 211, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(116, 153, 211, 0.08) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: radial-gradient(circle at 35% 15%, black 0%, transparent 78%);
pointer-events: none;
}
.bg-glow {
position: fixed;
border-radius: 999px;
pointer-events: none;
z-index: var(--z-bg);
}
.bg-glow-1 {
width: 420px;
height: 420px;
left: -120px;
top: 30%;
background: rgba(86, 135, 214, 0.18);
filter: blur(60px);
}
.bg-glow-2 {
width: 360px;
height: 360px;
right: -90px;
top: 80px;
background: rgba(115, 168, 255, 0.12);
filter: blur(58px);
}
.app-shell {
position: relative;
z-index: var(--z-layout);
min-height: 100vh;
display: grid;
grid-template-columns: 280px 1fr;
gap: var(--space-4);
padding: var(--space-4);
}
.sidebar {
background: linear-gradient(165deg, rgba(30, 47, 80, 0.86) 0%, rgba(17, 27, 48, 0.95) 100%);
border: 1px solid var(--border-0);
box-shadow: var(--shadow-0);
border-radius: var(--radius-lg);
padding: var(--space-4);
position: sticky;
top: var(--space-4);
height: calc(100vh - 40px);
display: flex;
flex-direction: column;
gap: var(--space-4);
backdrop-filter: blur(8px);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
}
.brand-dot {
width: 14px;
height: 14px;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent-0), #80dbff);
box-shadow: 0 0 0 6px rgba(107, 166, 255, 0.22);
flex-shrink: 0;
}
.brand h1 {
margin: 0;
font-size: 21px;
letter-spacing: 0.4px;
}
.brand p {
margin: 6px 0 0;
color: var(--text-2);
font-size: 13px;
}
.nav-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.nav-item {
border: 1px solid transparent;
background: transparent;
color: var(--text-1);
text-align: left;
font-size: 15px;
line-height: 1.45;
padding: 12px 14px;
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.nav-item:hover,
.nav-item:focus-visible {
color: var(--text-0);
border-color: var(--border-1);
background: rgba(139, 177, 240, 0.14);
outline: none;
}
.nav-item.active {
color: #031126;
background: linear-gradient(135deg, #8ab6ff 0%, #9ed4ff 100%);
border-color: #acd2ff;
font-weight: 700;
box-shadow: 0 10px 24px rgba(99, 159, 243, 0.34);
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-right: var(--space-2);
}
.main-header {
background: var(--surface-0);
border: 1px solid var(--border-0);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--shadow-1);
backdrop-filter: blur(7px);
}
.main-header h2 {
margin: 0;
font-size: 28px;
letter-spacing: 0.3px;
}
.main-header p {
margin: 8px 0 0;
color: var(--text-1);
}
.view {
display: none;
}
.view.active {
display: block;
}
.grid {
display: grid;
gap: var(--space-3);
}
.grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: var(--space-3);
}
.grid.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card {
background: var(--surface-1);
border: 1px solid var(--border-0);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-1);
padding: var(--space-4);
backdrop-filter: blur(8px);
}
.card + .card {
margin-top: var(--space-3);
}
.card h3 {
margin: 0 0 var(--space-3);
font-size: 18px;
letter-spacing: 0.2px;
}
.stat {
position: relative;
overflow: hidden;
}
.stat::after {
content: "";
position: absolute;
right: -32px;
bottom: -38px;
width: 112px;
height: 112px;
border-radius: 999px;
background: radial-gradient(circle at center, rgba(118, 173, 255, 0.36) 0%, rgba(118, 173, 255, 0) 70%);
pointer-events: none;
}
.stat p {
margin: 0;
font-size: 38px;
font-weight: 700;
color: var(--accent-1);
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.list li {
border-bottom: 1px solid rgba(157, 185, 229, 0.2);
padding: 12px 0;
font-size: 14px;
line-height: 1.6;
}
.list li:last-child {
border-bottom: none;
}
.list a {
color: #afd0ff;
text-decoration: none;
}
.list a:hover,
.list a:focus-visible {
color: #cde4ff;
text-decoration: underline;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
label {
display: block;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
color: var(--text-1);
}
input,
select,
button {
font: inherit;
}
input,
select {
width: 100%;
margin-top: 6px;
min-height: 44px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-0);
color: var(--text-0);
background: rgba(13, 21, 39, 0.64);
padding: 10px 12px;
}
input::placeholder {
color: rgba(173, 192, 228, 0.66);
}
input:focus-visible,
select:focus-visible,
button:focus-visible {
outline: 2px solid rgba(136, 189, 255, 0.92);
outline-offset: 2px;
border-color: rgba(136, 189, 255, 0.92);
}
select {
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, #aacfff 50%),
linear-gradient(135deg, #aacfff 50%, transparent 50%);
background-position:
calc(100% - 16px) 17px,
calc(100% - 11px) 17px;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
padding-right: 34px;
}
.span-2 {
grid-column: span 2;
}
.alert.info {
border-radius: var(--radius-md);
border: 1px solid rgba(130, 174, 255, 0.38);
background: rgba(96, 150, 235, 0.15);
color: #c5dcff;
padding: 12px 14px;
}
.month-panel {
border: 1px solid var(--border-0);
border-radius: var(--radius-md);
background: rgba(21, 34, 61, 0.6);
padding: var(--space-3);
}
.month-grid {
align-items: end;
}
.month-action {
display: flex;
}
.month-action button {
width: 100%;
}
.project-item {
border: 1px solid var(--border-0);
border-radius: var(--radius-md);
background: rgba(20, 31, 55, 0.62);
padding: var(--space-3);
}
.project-item h4 {
margin: 0 0 10px;
color: var(--text-0);
font-size: 15px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
min-height: 44px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-0);
background: rgba(88, 120, 170, 0.22);
color: var(--text-0);
padding: 0 16px;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
button:hover,
button:focus-visible {
background: rgba(122, 160, 221, 0.35);
border-color: rgba(157, 194, 253, 0.72);
}
button:active {
transform: translateY(1px);
}
button.primary {
background: linear-gradient(135deg, #6fafff 0%, #8fcbff 100%);
border-color: #9cd0ff;
color: #04162d;
font-weight: 700;
}
button.primary:hover,
button.primary:focus-visible {
background: linear-gradient(135deg, #85bcff 0%, #a1d7ff 100%);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
#log-panel {
display: none;
margin-top: var(--space-3);
}
.live-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.live-column header,
.system-log-wrap > header {
font-size: 13px;
font-weight: 700;
color: var(--text-1);
margin-bottom: 7px;
letter-spacing: 0.2px;
}
.live-output,
.system-output {
height: 250px;
overflow-y: auto;
border-radius: var(--radius-sm);
padding: 12px;
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 12.5px;
line-height: 1.62;
border: 1px solid var(--border-0);
background: rgba(6, 12, 25, 0.72);
}
.live-output {
color: #d6e5ff;
}
.live-column.reasoning .live-output {
background: rgba(9, 18, 39, 0.82);
}
.live-column.answer .live-output {
background: rgba(12, 24, 37, 0.78);
}
.system-output {
color: #e8f1ff;
background: rgba(6, 12, 24, 0.9);
border-color: rgba(150, 180, 230, 0.34);
}
.live-output .muted,
.system-output .muted {
color: #a9bde3;
}
.log-line {
margin: 2px 0;
}
.system-output .log-line.is-info {
color: #dbe8ff;
}
.system-output .log-line.is-error {
color: #ffb6bc;
}
.live-output .log-line.is-reasoning {
color: #ccdeff;
}
.live-output .log-line.is-answer {
color: #baf2da;
}
.system-log-wrap {
margin-top: var(--space-2);
}
.history-toolbar {
display: grid;
grid-template-columns: 180px 180px minmax(220px, 1fr) 120px;
gap: 10px;
margin-bottom: 12px;
}
.table-wrap {
overflow-x: auto;
border: 1px solid rgba(157, 185, 229, 0.2);
border-radius: var(--radius-md);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
background: rgba(8, 15, 30, 0.35);
}
th,
td {
padding: 10px 8px;
border-bottom: 1px solid rgba(157, 185, 229, 0.16);
text-align: left;
font-size: 14px;
vertical-align: top;
}
th {
color: #d5e4ff;
background: rgba(129, 167, 229, 0.12);
position: sticky;
top: 0;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
color: var(--text-2);
font-size: 14px;
}
.pager .pager-actions {
display: flex;
gap: 8px;
}
.btn-cancel-task {
min-height: 32px;
padding: 0 10px;
font-size: 13px;
}
.tag {
display: inline-block;
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.tag.SUCCESS {
background: rgba(73, 194, 138, 0.16);
border-color: rgba(73, 194, 138, 0.5);
color: #95edc2;
}
.tag.RUNNING,
.tag.PENDING {
background: rgba(240, 184, 93, 0.16);
border-color: rgba(240, 184, 93, 0.45);
color: #ffd99d;
}
.tag.FAILED {
background: rgba(255, 127, 135, 0.16);
border-color: rgba(255, 127, 135, 0.5);
color: #ffb5bc;
}
.tag.CANCELLED {
background: rgba(148, 166, 196, 0.16);
border-color: rgba(148, 166, 196, 0.45);
color: #ced9f1;
}
.muted {
color: var(--text-2);
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: var(--z-toast);
border-radius: var(--radius-md);
padding: 12px 14px;
background: rgba(8, 16, 33, 0.96);
border: 1px solid rgba(132, 171, 235, 0.46);
color: #eef4ff;
min-width: 240px;
max-width: 400px;
display: none;
box-shadow: var(--shadow-0);
}
.toast.show {
display: block;
}
@media (max-width: 1180px) {
.grid.cols-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
gap: var(--space-3);
padding: var(--space-3);
}
.sidebar {
position: sticky;
top: 0;
height: auto;
padding: 14px;
gap: 12px;
border-radius: var(--radius-md);
}
.brand p {
display: none;
}
.nav-list {
flex-direction: row;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
}
.nav-item {
flex: 0 0 auto;
white-space: nowrap;
min-height: 44px;
padding: 10px 13px;
}
.main {
padding-right: 0;
}
.grid.cols-2,
.grid.cols-3,
.form-grid,
.history-toolbar {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: span 1;
}
.month-action {
grid-column: span 1;
}
}
@media (max-width: 760px) {
.grid.cols-4 {
grid-template-columns: 1fr;
}
.main-header h2 {
font-size: 24px;
}
.card {
padding: var(--space-3);
}
.live-grid {
grid-template-columns: 1fr;
}
.toast {
right: 12px;
left: 12px;
bottom: 12px;
min-width: 0;
max-width: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
@@ -0,0 +1,110 @@
package com.svnlog.web.controller;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.svnlog.web.service.AiWorkflowService;
import com.svnlog.web.service.HealthService;
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;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AppControllerDownloadTest {
@TempDir
Path tempDir;
@Test
void shouldDownloadMarkdownWithAttachmentHeaders() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
final Path file = outputFileService.resolveInOutput("nested/报告 2026.md");
Files.createDirectories(file.getParent());
Files.write(file, "# hello".getBytes(StandardCharsets.UTF_8));
buildMockMvc(outputFileService)
.perform(get("/api/files/download").param("path", "nested/报告 2026.md"))
.andExpect(status().isOk())
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().string("Content-Disposition", Matchers.containsString("attachment;")))
.andExpect(header().string("Content-Disposition", Matchers.containsString("filename*=UTF-8''")))
.andExpect(content().contentType("text/markdown;charset=UTF-8"))
.andExpect(content().string("# hello"));
}
@Test
void shouldDownloadExcelWithOfficeContentType() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
final byte[] payload = new byte[] { 0x01, 0x02, 0x03, 0x04 };
Files.write(outputFileService.resolveInOutput("report.xlsx"), payload);
final byte[] response = buildMockMvc(outputFileService)
.perform(get("/api/files/download").param("path", "report.xlsx"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.andReturn()
.getResponse()
.getContentAsByteArray();
Assertions.assertArrayEquals(payload, response);
}
@Test
void shouldReturnStructuredErrorWhenFileMissing() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
buildMockMvc(outputFileService)
.perform(get("/api/files/download").param("path", "missing.md"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.error").value("文件不存在: missing.md"));
}
@Test
void shouldRejectPathTraversal() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
buildMockMvc(outputFileService)
.perform(get("/api/files/download").param("path", "../secret.txt"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.error", Matchers.containsString("非法文件路径")));
}
private OutputFileService buildOutputFileService() {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
return outputFileService;
}
private MockMvc buildMockMvc(OutputFileService outputFileService) {
final AppController controller = new AppController(
Mockito.mock(SvnWorkflowService.class),
Mockito.mock(AiWorkflowService.class),
Mockito.mock(TaskService.class),
outputFileService,
Mockito.mock(SettingsService.class),
Mockito.mock(SvnPresetService.class),
Mockito.mock(HealthService.class)
);
return MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
}
@@ -21,7 +21,11 @@ class AiWorkflowServiceTest {
void shouldResolveDeepSeekProviderByDefault() { void shouldResolveDeepSeekProviderByDefault() {
final AiWorkflowService service = new AiWorkflowService( final AiWorkflowService service = new AiWorkflowService(
buildOutputFileService(), buildOutputFileService(),
new SettingsService(buildOutputFileService(), new SvnPresetService()), new SettingsService(
buildOutputFileService(),
new SettingsPersistenceService(),
new SvnPresetService()
),
new AiInputValidator() new AiInputValidator()
); );
@@ -35,7 +39,11 @@ class AiWorkflowServiceTest {
@Test @Test
void shouldResolveOpenAiCompatibleModelsAndUrl() { void shouldResolveOpenAiCompatibleModelsAndUrl() {
final OutputFileService outputFileService = buildOutputFileService(); final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService()); final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService()
);
settingsService.updateSettings( settingsService.updateSettings(
null, null,
SettingsService.PROVIDER_OPENAI_COMPATIBLE, SettingsService.PROVIDER_OPENAI_COMPATIBLE,
@@ -44,6 +52,8 @@ class AiWorkflowServiceTest {
"deepseek-v4-flash", "deepseek-v4-flash",
"deepseek-v4-pro", "deepseek-v4-pro",
null, null,
null,
null,
null null
); );
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator()); final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
@@ -75,7 +85,11 @@ class AiWorkflowServiceTest {
@Test @Test
void shouldParseCompatibleStreamWhenOnlyContentIsReturned() throws Exception { void shouldParseCompatibleStreamWhenOnlyContentIsReturned() throws Exception {
final OutputFileService outputFileService = buildOutputFileService(); final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService()); final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService()
);
settingsService.updateSettings( settingsService.updateSettings(
null, null,
SettingsService.PROVIDER_OPENAI_COMPATIBLE, SettingsService.PROVIDER_OPENAI_COMPATIBLE,
@@ -84,6 +98,8 @@ class AiWorkflowServiceTest {
"deepseek-v4-flash", "deepseek-v4-flash",
"deepseek-v4-pro", "deepseek-v4-pro",
null, null,
null,
null,
null null
); );
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator()); final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
@@ -127,7 +143,7 @@ class AiWorkflowServiceTest {
private final String openaiApiKey; private final String openaiApiKey;
private StubSettingsService(OutputFileService outputFileService, String openaiBaseUrl, String openaiApiKey) { private StubSettingsService(OutputFileService outputFileService, String openaiBaseUrl, String openaiApiKey) {
super(outputFileService, new SvnPresetService()); super(outputFileService, new SettingsPersistenceService(), new SvnPresetService());
this.openaiBaseUrl = openaiBaseUrl; this.openaiBaseUrl = openaiBaseUrl;
this.openaiApiKey = openaiApiKey; this.openaiApiKey = openaiApiKey;
} }
@@ -26,7 +26,11 @@ class HealthServiceTest {
void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception { void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception {
final OutputFileService outputFileService = new OutputFileService(); final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString()); outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService()); final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService()
);
taskService = new TaskService(new TaskPersistenceService(), outputFileService); taskService = new TaskService(new TaskPersistenceService(), outputFileService);
final HealthService healthService = new HealthService(outputFileService, settingsService, taskService); final HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
@@ -1,59 +1,210 @@
package com.svnlog.web.service; package com.svnlog.web.service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.EnumSet;
import java.util.Map; import java.util.Map;
import java.nio.file.Paths; import java.util.Set;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SettingsServiceTest { class SettingsServiceTest {
@TempDir @TempDir
Path tempDir; Path tempDir;
@Test private String originalUserDir;
void shouldReturnDefaultProviderAndOpenAiDefaults() throws Exception {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
final Map<String, Object> settings = settingsService.getSettings(); @AfterEach
void restoreUserDir() {
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, settings.get("provider")); if (originalUserDir != null) {
Assertions.assertEquals("http://127.0.0.1:5001/v1", settings.get("openaiBaseUrl")); System.setProperty("user.dir", originalUserDir);
Assertions.assertEquals("sk-f8b3f43e1fdd4f50b287050f08a6c7ed", settings.get("openaiApiKey")); }
Assertions.assertEquals("deepseek-v4-flash", settings.get("openaiStageOneModel"));
Assertions.assertEquals("deepseek-v4-pro", settings.get("openaiStageTwoModel"));
} }
@Test @Test
void shouldUpdateProviderAndOpenAiSettings() throws Exception { void shouldPersistAndReloadSettingsFromOutputDirectory() throws IOException {
final OutputFileService outputFileService = new OutputFileService(); useTempWorkingDirectory();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString()); final Path customOutputDir = tempDir.resolve("custom-output");
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
final String newOutputDir = tempDir.resolve("custom-output").toString();
final SettingsService settingsService = newSettingsService();
settingsService.updateSettings( settingsService.updateSettings(
"sk-deepseek-runtime", "deepseek-key",
SettingsService.PROVIDER_OPENAI_COMPATIBLE, SettingsService.PROVIDER_OPENAI_COMPATIBLE,
"http://localhost:5001/v1/", "http://localhost:9000/v1",
"sk-openai-runtime", "openai-key",
"deepseek-v4-flash", "model-stage-1",
"deepseek-v4-pro", "model-stage-2",
newOutputDir, "svn-user",
"svn-pass",
customOutputDir.toString(),
"preset-2" "preset-2"
); );
final Map<String, Object> settings = settingsService.getSettings(); assertTrue(Files.exists(customOutputDir.resolve("settings.json")));
assertTrue(Files.exists(tempDir.resolve("outputs").resolve("settings.json")));
Assertions.assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, settings.get("provider")); final SettingsService reloadedService = newSettingsService();
Assertions.assertEquals("http://localhost:5001/v1/", settings.get("openaiBaseUrl")); final Map<String, Object> settings = reloadedService.getSettings();
Assertions.assertEquals("sk-openai-runtime", settings.get("openaiApiKey"));
Assertions.assertEquals("deepseek-v4-flash", settings.get("openaiStageOneModel")); assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, settings.get("provider"));
Assertions.assertEquals("deepseek-v4-pro", settings.get("openaiStageTwoModel")); assertEquals("http://localhost:9000/v1", settings.get("openaiBaseUrl"));
Assertions.assertEquals("preset-2", settings.get("defaultSvnPresetId")); assertEquals("model-stage-1", settings.get("openaiStageOneModel"));
Assertions.assertEquals(Paths.get(newOutputDir).toAbsolutePath().normalize().toString(), settings.get("outputDir")); assertEquals("model-stage-2", settings.get("openaiStageTwoModel"));
assertEquals("svn-user", settings.get("svnUsername"));
assertEquals(customOutputDir.toAbsolutePath().normalize().toString(), settings.get("outputDir"));
assertEquals("preset-2", settings.get("defaultSvnPresetId"));
assertEquals("openai-key", reloadedService.getOpenaiApiKey());
assertEquals("deepseek-key", reloadedService.pickActiveKey(null));
assertEquals("svn-pass", reloadedService.resolveSvnCredentials(null, null).getPassword());
}
@Test
void shouldCreateMissingOutputDirectoryWhenSavingSettings() throws IOException {
useTempWorkingDirectory();
final Path missingOutputDir = tempDir.resolve("missing-output");
final SettingsService settingsService = newSettingsService();
settingsService.updateSettings(
null,
SettingsService.PROVIDER_DEEPSEEK,
null,
null,
null,
null,
null,
null,
missingOutputDir.toString(),
"preset-1"
);
assertTrue(Files.isDirectory(missingOutputDir));
assertTrue(Files.exists(missingOutputDir.resolve("settings.json")));
}
@Test
void shouldFailWhenOutputPathIsAFile() throws IOException {
useTempWorkingDirectory();
final Path occupiedPath = tempDir.resolve("occupied-output");
Files.write(occupiedPath, "not-a-directory".getBytes(StandardCharsets.UTF_8));
final SettingsService settingsService = newSettingsService();
final IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> settingsService.updateSettings(
null,
SettingsService.PROVIDER_DEEPSEEK,
null,
null,
null,
null,
null,
null,
occupiedPath.toString(),
"preset-1"
)
);
assertTrue(exception.getMessage().contains("输出路径不是目录"));
assertEquals(tempDir.resolve("outputs").toAbsolutePath().normalize().toString(),
settingsService.getSettings().get("outputDir"));
}
@Test
void shouldFailWhenOutputDirectoryIsNotWritable() throws IOException {
useTempWorkingDirectory();
final Path readOnlyDir = tempDir.resolve("read-only-output");
Files.createDirectories(readOnlyDir);
final Set<PosixFilePermission> originalPermissions = Files.getPosixFilePermissions(readOnlyDir);
Files.setPosixFilePermissions(readOnlyDir, EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_EXECUTE
));
Assumptions.assumeFalse(Files.isWritable(readOnlyDir), "Test requires a non-writable directory");
final SettingsService settingsService = newSettingsService();
try {
final IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> settingsService.updateSettings(
null,
SettingsService.PROVIDER_DEEPSEEK,
null,
null,
null,
null,
null,
null,
readOnlyDir.toString(),
"preset-1"
)
);
assertTrue(exception.getMessage().contains("输出目录不可写"));
assertEquals(tempDir.resolve("outputs").toAbsolutePath().normalize().toString(),
settingsService.getSettings().get("outputDir"));
} finally {
Files.setPosixFilePermissions(readOnlyDir, originalPermissions);
}
}
@Test
void shouldReturnSanitizedSettingsWhileKeepingRecoveredSecretsInRuntime() throws IOException {
useTempWorkingDirectory();
final SettingsService settingsService = newSettingsService();
settingsService.updateSettings(
"deepseek-key",
SettingsService.PROVIDER_DEEPSEEK,
"http://localhost:5001/v1",
"openai-key",
"stage-1",
"stage-2",
"svn-user",
"svn-pass",
tempDir.resolve("runtime-output").toString(),
"preset-1"
);
final SettingsService reloadedService = newSettingsService();
final Map<String, Object> settings = reloadedService.getSettings();
assertFalse(settings.containsKey("apiKey"));
assertFalse(settings.containsKey("openaiApiKey"));
assertFalse(settings.containsKey("svnPassword"));
assertEquals(Boolean.TRUE, settings.get("apiKeyConfigured"));
assertEquals(Boolean.TRUE, settings.get("openaiApiKeyConfigured"));
assertEquals(Boolean.TRUE, settings.get("svnCredentialsConfigured"));
assertEquals("deepseek-key", reloadedService.pickActiveKey(null));
assertEquals("openai-key", reloadedService.getOpenaiApiKey());
assertEquals("svn-pass", reloadedService.resolveSvnCredentials(null, null).getPassword());
assertNotNull(settings.get("apiKeySource"));
}
private SettingsService newSettingsService() {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsPersistenceService settingsPersistenceService = new SettingsPersistenceService();
final SvnPresetService svnPresetService = new SvnPresetService();
return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService);
}
private void useTempWorkingDirectory() {
originalUserDir = System.getProperty("user.dir");
System.setProperty("user.dir", tempDir.toAbsolutePath().normalize().toString());
} }
} }