feat: update web ui, docker make commands, and related docs/config

This commit is contained in:
liumangmang
2026-04-09 11:56:19 +08:00
parent 51be434f2a
commit 4ac755a7fe
27 changed files with 2718 additions and 507 deletions

View File

@@ -35,9 +35,13 @@
- 说明:当前 `src/test/java` 为空;新增测试时采用 Surefire 默认约定。 - 说明:当前 `src/test/java` 为空;新增测试时采用 Surefire 默认约定。
### 2.4 Run ### 2.4 Run
- 运行 Web 工作台(推荐): - 运行 Web 工作台(Docker推荐):
- `make up`
- `make status`
- `make down`
- 启动后访问:`http://localhost:18088`
- 运行 Web 工作台(本机 Java + Maven备用
- `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication` - `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication`
- 启动后访问:`http://localhost:8080`
## 3. 代码结构与职责边界 ## 3. 代码结构与职责边界
- `SVNLogFetcher.java`SVN 连接、版本区间处理、日志抓取、用户过滤。 - `SVNLogFetcher.java`SVN 连接、版本区间处理、日志抓取、用户过滤。

154
CLAUDE.md Normal file
View File

@@ -0,0 +1,154 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
SVN Log Tool is a Java-based application that fetches SVN logs, exports them to Markdown, and uses DeepSeek AI to analyze logs and generate Excel workload reports.
**Tech Stack**: Java 8, Maven, Spring Boot 2.7.18, SVNKit, Apache POI, OkHttp
**Entry Points**:
- CLI: `com.svnlog.Main` - Interactive SVN log fetcher
- Web: `com.svnlog.WebApplication` - Web workbench with REST API (port 8080)
- AI Processor: `com.svnlog.DeepSeekLogProcessor` - Standalone AI analysis tool
## Build & Run Commands
### Build
```bash
# Compile only (fastest validation)
mvn clean compile
# Package with tests
mvn clean package
# Package without tests
mvn clean package -DskipTests
```
### Run
```bash
# Web workbench (recommended)
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.WebApplication
# Access at http://localhost:8080
# CLI tool
java -jar target/svn-log-tool-1.0.0-jar-with-dependencies.jar
# AI processor
java -cp target/svn-log-tool-1.0.0-jar-with-dependencies.jar com.svnlog.DeepSeekLogProcessor
```
### Test
```bash
# Run all tests
mvn test
# Run single test class
mvn -Dtest=ClassName test
# Run single test method
mvn -Dtest=ClassName#methodName test
```
## Architecture
### Core Components
**SVN Layer** (`com.svnlog`):
- `SVNLogFetcher` - SVN connection, log fetching, user filtering
- `LogEntry` - Log data model
- `Main` - CLI interaction and Markdown generation
**AI Layer** (`com.svnlog`):
- `DeepSeekLogProcessor` - Calls DeepSeek API, generates Excel workload reports
**Web Layer** (`com.svnlog.web`):
- `controller/AppController` - REST API endpoints
- `controller/GlobalExceptionHandler` - Centralized error handling
- `service/TaskService` - Async task orchestration
- `service/SvnWorkflowService` - SVN fetch workflow
- `service/AiWorkflowService` - AI analysis workflow
- `service/TaskPersistenceService` - Task history persistence
- `service/HealthService` - System health checks
- `service/SettingsService` - API key and output directory management
- `service/SvnPresetService` - Preset SVN projects
- `service/OutputFileService` - File listing and downloads
- `service/AiInputValidator` - Input validation (max 20 files, 2MB each)
- `service/RetrySupport` - Retry logic for external calls
**Frontend** (`src/main/resources/static`):
- `index.html` - Main web interface
- `app.js` - Frontend logic
- `styles.css` - Styling
### Data Flow
1. **SVN Fetch**: User input → `SVNLogFetcher` → Markdown file (`outputs/md/*.md`)
2. **AI Analysis**: Markdown → `DeepSeekLogProcessor` → Excel file (`outputs/excel/*.xlsx`)
3. **Web Mode**: REST API → Async tasks → Task history (`outputs/task-history.json`)
### Output Directory Structure
```
outputs/
├── md/ # SVN log Markdown files
├── excel/ # AI-generated Excel reports
└── task-history.json # Task persistence (survives restarts)
```
## Key REST API Endpoints
- `POST /api/svn/test-connection` - Test SVN connection
- `POST /api/svn/fetch` - Async SVN log fetch
- `GET /api/svn/presets` - List preset SVN projects
- `POST /api/ai/analyze` - Async AI analysis
- `GET /api/tasks` - List all tasks
- `GET /api/tasks/query?status=&type=&keyword=&page=1&size=10` - Query tasks
- `POST /api/tasks/{taskId}/cancel` - Cancel running task
- `GET /api/health` - System health status
- `GET /api/files` - List output files
- `GET /api/files/download?path=...` - Download file
- `GET /api/settings` - Get settings
- `PUT /api/settings` - Update settings
## DeepSeek API Configuration
**API Key Priority** (highest to lowest):
1. Request-level `apiKey` parameter
2. Runtime settings (via `/api/settings`)
3. Environment variable `DEEPSEEK_API_KEY`
**Security**: Never commit real API keys. Use environment variables in production.
## Code Style Requirements
- **Java 8 compatibility**: No Java 9+ APIs
- **Imports**: No wildcards, explicit imports only, grouped by: `java.*` / third-party / `com.svnlog.*`
- **Formatting**: 4 spaces, max 120 chars/line, always use braces for `if/for/while`
- **Naming**: `UpperCamelCase` for classes, `lowerCamelCase` for methods/variables, `UPPER_SNAKE_CASE` for constants
- **Resources**: Use try-with-resources for I/O, explicit UTF-8 encoding
- **Error handling**: Never swallow exceptions, provide actionable error messages
## Testing Strategy
- Test directory: `src/test/java/com/svnlog/`
- Naming: `<ClassName>Test`, methods: `should<Behavior>When<Condition>`
- Minimal validation: `mvn clean compile` must pass
## Important Notes
- **Preset SVN projects** are hardcoded in `Main.java` (lines 14-18)
- **Task persistence** enables history recovery after restart
- **AI input limits**: Max 20 files, 2MB each, `.md` only
- **Async tasks** run in background, tracked via `TaskService`
- **Web frontend** is a single-page app with vanilla JS
## When Modifying Code
- SVN logic → `SVNLogFetcher`
- CLI interaction → `Main`
- AI/Excel logic → `DeepSeekLogProcessor`
- Web API → `AppController` + services
- Always update `docs/` if behavior changes
- Run `mvn clean compile` before committing

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM maven:3.9.6-eclipse-temurin-8 AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn -DskipTests clean package
FROM eclipse-temurin:8-jre
WORKDIR /app
COPY --from=builder /app/target/svn-log-tool-1.0.0-jar-with-dependencies.jar app.jar
EXPOSE 18088
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

17
Makefile Normal file
View File

@@ -0,0 +1,17 @@
.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)
up:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) up -d --build
@echo "Application is starting at http://localhost:18088"
down:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) down
status:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) ps
@echo "Access URL: http://localhost:18088"

View File

@@ -9,13 +9,22 @@ SVN 日志抓取与 AI 工作量分析工具,统一使用 Web 工作台入口
## 常用命令 ## 常用命令
```bash ```bash
# 一键启动Docker
make up
# 查看状态
make status
# 一键关闭
make down
# 编译 # 编译
mvn clean compile mvn clean compile
# 打包 # 打包
mvn clean package -DskipTests mvn clean package -DskipTests
# 启动 Web # 启动 Web(非 Docker 备用方式)
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
``` ```

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
svn-log-tool:
build:
context: .
dockerfile: Dockerfile
container_name: svn-log-tool
ports:
- "18088:18088"
volumes:
- ./outputs:/app/outputs
restart: unless-stopped

View File

@@ -6,11 +6,18 @@
## 启动 ## 启动
```bash
# Docker 一键启动(推荐)
make up
```
备用方式(本机 Java + Maven
```bash ```bash
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
``` ```
访问:`http://localhost:8080` 访问:`http://localhost:18088`
## 使用步骤 ## 使用步骤

View File

@@ -17,6 +17,13 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API支持
在仓库根目录执行: 在仓库根目录执行:
```bash
# Docker 一键启动(推荐)
make up
```
备用方式(本机 Java + Maven
```bash ```bash
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
``` ```
@@ -24,7 +31,7 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
启动后访问: 启动后访问:
```text ```text
http://localhost:8080 http://localhost:18088
``` ```
## 页面说明 ## 页面说明

View File

@@ -7,7 +7,10 @@ import java.util.Collection;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tmatesoft.svn.core.SVNException; import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNLogEntry; import org.tmatesoft.svn.core.SVNLogEntry;
import org.tmatesoft.svn.core.SVNLogEntryPath; import org.tmatesoft.svn.core.SVNLogEntryPath;
@@ -20,6 +23,11 @@ import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
import org.tmatesoft.svn.core.wc.SVNWCUtil; import org.tmatesoft.svn.core.wc.SVNWCUtil;
public class SVNLogFetcher { public class SVNLogFetcher {
private static final Logger LOGGER = LoggerFactory.getLogger(SVNLogFetcher.class);
private static final TimeZone MONTH_TIME_ZONE = TimeZone.getTimeZone("Asia/Shanghai");
private static final long DEFAULT_BOUNDARY_PADDING = 50L;
private static final long FALLBACK_SCAN_PADDING = 2000L;
private final SVNRepository repository; private final SVNRepository repository;
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@@ -56,7 +64,7 @@ public class SVNLogFetcher {
} }
public List<LogEntry> fetchLogs(long startRevision, long endRevision, String filterUser) throws SVNException { public List<LogEntry> fetchLogs(long startRevision, long endRevision, String filterUser) throws SVNException {
List<LogEntry> entries = new ArrayList<>(); List<LogEntry> entries = new ArrayList<LogEntry>();
if (startRevision < 0) { if (startRevision < 0) {
startRevision = repository.getLatestRevision(); startRevision = repository.getLatestRevision();
@@ -90,7 +98,7 @@ public class SVNLogFetcher {
// 获取变更的文件路径 // 获取变更的文件路径
if (logEntry.getChangedPaths() != null) { if (logEntry.getChangedPaths() != null) {
List<String> paths = new ArrayList<>(); List<String> paths = new ArrayList<String>();
for (Map.Entry<String, SVNLogEntryPath> pathEntry : logEntry.getChangedPaths().entrySet()) { for (Map.Entry<String, SVNLogEntryPath> pathEntry : logEntry.getChangedPaths().entrySet()) {
paths.add(pathEntry.getKey()); paths.add(pathEntry.getKey());
} }
@@ -119,127 +127,144 @@ public class SVNLogFetcher {
} }
/** /**
* 获取指定年月的版本范围(采样估算法,不过滤用户) * 获取指定年月的版本范围(基于时间边界,不过滤用户)
* @param year 年份 * @param year 年份
* @param month 月份1-12 * @param month 月份1-12
* @return 数组 [startRevision, endRevision]如果该月无提交返回null * @return 数组 [startRevision, endRevision]如果该月无提交返回null
* @throws SVNException SVN异常 * @throws SVNException SVN异常
*/ */
public long[] getVersionRangeByMonth(int year, int month) throws SVNException { public long[] getVersionRangeByMonth(int year, int month) throws SVNException {
// 计算目标月份的时间范围 return getVersionRangeByMonth(year, month, "");
Calendar startCal = Calendar.getInstance(); }
public long[] getVersionRangeByMonth(int year, int month, String traceId) throws SVNException {
final String trace = traceId == null ? "" : traceId;
final Calendar startCal = Calendar.getInstance(MONTH_TIME_ZONE);
startCal.set(year, month - 1, 1, 0, 0, 0); startCal.set(year, month - 1, 1, 0, 0, 0);
startCal.set(Calendar.MILLISECOND, 0); startCal.set(Calendar.MILLISECOND, 0);
long targetStartTime = startCal.getTimeInMillis(); final long monthStart = startCal.getTimeInMillis();
Calendar endCal = Calendar.getInstance(); final Calendar nextMonthCal = (Calendar) startCal.clone();
endCal.set(year, month - 1, 1, 23, 59, 59); nextMonthCal.add(Calendar.MONTH, 1);
endCal.set(Calendar.MILLISECOND, 999); final long nextMonthStart = nextMonthCal.getTimeInMillis();
endCal.set(Calendar.DAY_OF_MONTH, endCal.getActualMaximum(Calendar.DAY_OF_MONTH));
long targetEndTime = endCal.getTimeInMillis();
long latestRevision = getLatestRevision(); final long latestRevision = getLatestRevision();
System.out.println("查询 " + year + "" + month + "月,最新版本: " + latestRevision); if (latestRevision < 1L) {
LOGGER.info("[SVN_VERSION_RANGE][FETCHER] traceId={} latestRevision={} noRepositoryRevision=true", trace, latestRevision);
// 采样策略每隔20个版本采样一次
long sampleInterval = 20;
long firstRevisionInMonth = -1; // 第一个在目标月份内的版本
long lastRevisionBeforeMonth = -1; // 最后一个在目标月份之前的版本
int samplesChecked = 0;
int maxSamples = 10000;
// 从最新版本往旧版本采样
for (long rev = latestRevision; rev >= 1 && samplesChecked < maxSamples; rev -= sampleInterval) {
samplesChecked++;
try {
Collection<SVNLogEntry> sample = repository.log(new String[]{""}, (Collection) null, rev, rev, false, false);
if (sample.isEmpty()) continue;
SVNLogEntry entry = sample.iterator().next();
Date logDate = entry.getDate();
if (logDate == null) continue;
long logTime = logDate.getTime();
// 找到第一个在目标月份内的版本(从新到旧方向)
if (logTime >= targetStartTime && logTime <= targetEndTime) {
firstRevisionInMonth = rev;
System.out.println("找到月份内的版本: " + rev + ", 日期: " + formatDate(logDate));
}
// 找到第一个在目标月份之前的版本
if (logTime < targetStartTime) {
lastRevisionBeforeMonth = rev;
System.out.println("找到月份之前的版本: " + rev + ", 日期: " + formatDate(logDate));
break; // 已经超出目标月份,停止采样
}
} catch (SVNException e) {
continue;
}
}
// 确定粗略范围
long roughStart;
long roughEnd;
if (firstRevisionInMonth == -1) {
// 没有找到目标月份内的版本,可能该月无提交
System.out.println("采样未找到目标月份内的版本");
return null; return null;
} }
// 粗略起始从最后一个月份之前的版本开始向前扩展1000个版本 final long startAnchor = repository.getDatedRevision(new Date(monthStart));
if (lastRevisionBeforeMonth != -1) { final long endAnchor = repository.getDatedRevision(new Date(nextMonthStart - 1L));
roughStart = Math.max(1, lastRevisionBeforeMonth - 1000);
} else { LOGGER.info(
// 如果没找到月份之前的版本说明目标月份很早从版本1开始 "[SVN_VERSION_RANGE][FETCHER] traceId={} queryMonth={}-{} tz={} monthStart={} nextMonthStart={} latestRevision={} startAnchor={} endAnchor={}",
roughStart = 1; trace,
year,
month,
MONTH_TIME_ZONE.getID(),
formatDate(new Date(monthStart)),
formatDate(new Date(nextMonthStart)),
latestRevision,
startAnchor,
endAnchor
);
if (endAnchor < 1L) {
LOGGER.info("[SVN_VERSION_RANGE][FETCHER] traceId={} noAnchorBeforeMonthEnd=true", trace);
return null;
} }
// 粗略结束从第一个月份内的版本开始向后扩展1000个版本 long[] exactRange = findRangeInWindow(
roughEnd = Math.min(latestRevision, firstRevisionInMonth + 1000); Math.max(1L, startAnchor - DEFAULT_BOUNDARY_PADDING),
Math.min(latestRevision, endAnchor + DEFAULT_BOUNDARY_PADDING),
monthStart,
nextMonthStart,
trace,
"primary"
);
System.out.println("粗略范围: " + roughStart + " - " + roughEnd); if (exactRange != null) {
LOGGER.info("[SVN_VERSION_RANGE][FETCHER] traceId={} finalRangeStart={} finalRangeEnd={} strategy=primary", trace, exactRange[0], exactRange[1]);
return exactRange;
}
LOGGER.info("[SVN_VERSION_RANGE][FETCHER] traceId={} primaryNoMatch=true fallbackScan=true", trace);
exactRange = findRangeInWindow(
Math.max(1L, startAnchor - FALLBACK_SCAN_PADDING),
Math.min(latestRevision, endAnchor + FALLBACK_SCAN_PADDING),
monthStart,
nextMonthStart,
trace,
"fallback"
);
if (exactRange == null) {
LOGGER.info("[SVN_VERSION_RANGE][FETCHER] traceId={} noMatchedRevisionInTargetMonth=true", trace);
return null;
}
LOGGER.info("[SVN_VERSION_RANGE][FETCHER] traceId={} finalRangeStart={} finalRangeEnd={} strategy=fallback", trace, exactRange[0], exactRange[1]);
return exactRange;
}
private long[] findRangeInWindow(long fromRevision,
long toRevision,
long monthStart,
long nextMonthStart,
String trace,
String strategyTag) throws SVNException {
if (fromRevision > toRevision) {
return null;
}
LOGGER.info("[SVN_VERSION_RANGE][FETCHER] traceId={} strategy={} scanFrom={} scanTo={}",
trace, strategyTag, fromRevision, toRevision);
// 在粗略范围内精确查询
Collection<SVNLogEntry> entries = repository.log( Collection<SVNLogEntry> entries = repository.log(
new String[]{""}, new String[]{""},
(Collection) null, null,
roughStart, fromRevision,
roughEnd, toRevision,
false, false,
false false
); );
if (entries == null || entries.isEmpty()) { if (entries == null || entries.isEmpty()) {
System.out.println("粗略范围内无日志记录"); LOGGER.info("[SVN_VERSION_RANGE][FETCHER] traceId={} strategy={} emptyWindow=true", trace, strategyTag);
return null; return null;
} }
System.out.println("粗略范围内共 " + entries.size() + " 条记录");
long minRevision = Long.MAX_VALUE; long minRevision = Long.MAX_VALUE;
long maxRevision = Long.MIN_VALUE; long maxRevision = Long.MIN_VALUE;
int matchedCount = 0;
// 过滤出目标月份的版本(不过滤用户)
for (SVNLogEntry entry : entries) { for (SVNLogEntry entry : entries) {
Date logDate = entry.getDate(); Date logDate = entry.getDate();
if (logDate == null) continue; if (logDate == null) {
continue;
}
long logTime = logDate.getTime(); long logTime = logDate.getTime();
if (logTime >= targetStartTime && logTime <= targetEndTime) { if (logTime >= monthStart && logTime < nextMonthStart) {
long revision = entry.getRevision(); long revision = entry.getRevision();
if (revision < minRevision) minRevision = revision; if (revision < minRevision) {
if (revision > maxRevision) maxRevision = revision; minRevision = revision;
}
if (revision > maxRevision) {
maxRevision = revision;
}
matchedCount++;
} }
} }
if (minRevision == Long.MAX_VALUE || maxRevision == Long.MIN_VALUE) { LOGGER.info("[SVN_VERSION_RANGE][FETCHER] traceId={} strategy={} windowEntryCount={} matchedCount={}",
System.out.println("目标月份无匹配记录"); trace, strategyTag, entries.size(), matchedCount);
if (matchedCount == 0) {
return null; return null;
} }
System.out.println("找到版本范围: " + minRevision + " - " + maxRevision);
return new long[]{minRevision, maxRevision}; return new long[]{minRevision, maxRevision};
} }
} }

View File

@@ -1,33 +0,0 @@
package com.svnlog.web.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import com.svnlog.web.model.SvnPreset;
@Component
@ConfigurationProperties(prefix = "svn")
public class SvnPresetProperties {
private String defaultPresetId;
private List<SvnPreset> presets = new ArrayList<SvnPreset>();
public String getDefaultPresetId() {
return defaultPresetId;
}
public void setDefaultPresetId(String defaultPresetId) {
this.defaultPresetId = defaultPresetId;
}
public List<SvnPreset> getPresets() {
return presets;
}
public void setPresets(List<SvnPreset> presets) {
this.presets = presets;
}
}

View File

@@ -8,12 +8,15 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid; import javax.validation.Valid;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -46,6 +49,7 @@ import com.svnlog.web.service.TaskService;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
public class AppController { public class AppController {
private static final Logger LOGGER = LoggerFactory.getLogger(AppController.class);
private final SvnWorkflowService svnWorkflowService; private final SvnWorkflowService svnWorkflowService;
private final AiWorkflowService aiWorkflowService; private final AiWorkflowService aiWorkflowService;
@@ -95,6 +99,7 @@ public class AppController {
*/ */
@PostMapping("/svn/version-range") @PostMapping("/svn/version-range")
public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception { public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception {
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 String username = request.getUsername();
@@ -102,13 +107,33 @@ public class AppController {
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(
"[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} year={} month={} username={} password={}",
traceId, request.getPresetId(), preset.getName(), url, year, month, username, maskPassword(password)
);
SVNLogFetcher fetcher = new SVNLogFetcher(url, username, password); SVNLogFetcher fetcher = new SVNLogFetcher(url, username, password);
long[] range = fetcher.getVersionRangeByMonth(year, month); 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("presetName", preset.getName());
response.put("resolvedSvnUrl", url);
response.put("year", year);
response.put("month", month);
response.put("traceId", traceId);
if (range != null) { if (range != null) {
response.put("startRevision", range[0]); response.put("startRevision", range[0]);
response.put("endRevision", range[1]); response.put("endRevision", range[1]);
LOGGER.info(
"[SVN_VERSION_RANGE][RESPONSE] traceId={} presetId={} url={} startRevision={} endRevision={}",
traceId, request.getPresetId(), url, range[0], range[1]
);
} else {
LOGGER.info(
"[SVN_VERSION_RANGE][RESPONSE] traceId={} presetId={} url={} noRangeFound=true",
traceId, request.getPresetId(), url
);
} }
return response; return response;
} }
@@ -164,8 +189,24 @@ public class AppController {
} }
@GetMapping(value = "/tasks/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @GetMapping(value = "/tasks/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamTask(@PathVariable("taskId") String taskId) { public SseEmitter streamTask(@PathVariable("taskId") String taskId, HttpServletResponse response) {
return taskService.subscribeTaskStream(taskId); if (response != null) {
response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache");
response.setHeader("X-Accel-Buffering", "no");
}
LOGGER.info("SSE subscribe request received: taskId={}", taskId);
if (!taskService.hasTask(taskId)) {
LOGGER.warn("SSE subscribe rejected: task not found, taskId={}", taskId);
return createErrorEmitter("任务不存在: " + taskId, taskId, 404);
}
try {
final SseEmitter emitter = taskService.subscribeTaskStream(taskId);
LOGGER.info("SSE subscribe established: taskId={}", taskId);
return emitter;
} catch (Exception subscribeException) {
LOGGER.warn("Failed to subscribe task stream for taskId={}", taskId, subscribeException);
return createErrorEmitter("流订阅失败: " + subscribeException.getMessage(), taskId, 500);
}
} }
@PostMapping("/tasks/{taskId}/cancel") @PostMapping("/tasks/{taskId}/cancel")
@@ -183,10 +224,17 @@ public class AppController {
} }
@GetMapping("/files") @GetMapping("/files")
public Map<String, Object> listFiles() throws IOException { public Map<String, Object> listFiles() {
final Map<String, Object> response = new HashMap<String, Object>(); final Map<String, Object> response = new HashMap<String, Object>();
try {
response.put("files", outputFileService.listOutputFiles()); response.put("files", outputFileService.listOutputFiles());
response.put("outputDir", outputFileService.getOutputRoot().toString()); response.put("outputDir", outputFileService.getOutputRoot().toString());
} catch (Exception ioException) {
response.put("files", java.util.Collections.emptyList());
response.put("outputDir", "(unavailable)");
response.put("error", "输出目录不可用: " + ioException.getMessage());
LOGGER.warn("Failed to list output files", ioException);
}
return response; return response;
} }
@@ -221,4 +269,54 @@ public class AppController {
settingsService.updateSettings(request.getApiKey(), request.getOutputDir(), request.getDefaultSvnPresetId()); settingsService.updateSettings(request.getApiKey(), request.getOutputDir(), request.getDefaultSvnPresetId());
return settingsService.getSettings(); return settingsService.getSettings();
} }
private String maskPassword(String password) {
if (password == null || password.isEmpty()) {
return "(empty)";
}
return "***len=" + password.length();
}
private String safe(String value) {
return value == null ? "" : value.trim();
}
private String buildSseErrorPayload(String error, String taskId, int status) {
final String safeError = sanitize(error);
final String safeTaskId = sanitize(taskId);
final String timestamp = Instant.now().toString();
return "event: error\n"
+ "data: {\"status\":" + status
+ ",\"error\":\"" + safeError
+ "\",\"taskId\":\"" + safeTaskId
+ "\",\"timestamp\":\"" + timestamp + "\"}\n\n";
}
private String sanitize(String value) {
if (value == null) {
return "";
}
return value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\r", " ")
.replace("\n", " ");
}
private SseEmitter createErrorEmitter(String error, String taskId, int status) {
final SseEmitter emitter = new SseEmitter(0L);
final Map<String, Object> payload = new HashMap<String, Object>();
payload.put("status", status);
payload.put("error", error == null ? "" : error);
payload.put("taskId", taskId == null ? "" : taskId);
payload.put("timestamp", Instant.now().toString());
try {
emitter.send(SseEmitter.event().name("error").data(payload));
} catch (Exception sendException) {
LOGGER.warn("Failed to send SSE error payload: taskId={}", taskId, sendException);
} finally {
emitter.complete();
}
return emitter;
}
} }

View File

@@ -4,6 +4,11 @@ import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -12,27 +17,66 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(IllegalArgumentException.class) @ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException ex) { public ResponseEntity<?> handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) {
return build(HttpStatus.BAD_REQUEST, ex.getMessage()); return build(HttpStatus.BAD_REQUEST, ex.getMessage(), request);
} }
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) { public ResponseEntity<?> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
return build(HttpStatus.BAD_REQUEST, "请求参数校验失败"); return build(HttpStatus.BAD_REQUEST, "请求参数校验失败", request);
} }
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleAny(Exception ex) { public ResponseEntity<?> handleAny(Exception ex, HttpServletRequest request) {
return build(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage() == null ? "系统异常" : ex.getMessage()); return build(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage() == null ? "系统异常" : ex.getMessage(), request);
}
private ResponseEntity<?> build(HttpStatus status, String message, HttpServletRequest request) {
if (isSseRequest(request)) {
final String safeMessage = sanitize(message);
LOGGER.error("SSE request failed: status={} uri={} message={}",
status.value(),
request == null ? "" : request.getRequestURI(),
safeMessage);
final String payload = "event: error\ndata: {\"status\":" + status.value()
+ ",\"error\":\"" + safeMessage + "\",\"timestamp\":\"" + Instant.now().toString() + "\"}\n\n";
return ResponseEntity.ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(payload);
} }
private ResponseEntity<Map<String, Object>> build(HttpStatus status, String message) {
final Map<String, Object> response = new HashMap<String, Object>(); final Map<String, Object> response = new HashMap<String, Object>();
response.put("status", status.value()); response.put("status", status.value());
response.put("error", message); response.put("error", message);
response.put("timestamp", Instant.now().toString()); response.put("timestamp", Instant.now().toString());
return ResponseEntity.status(status).body(response); return ResponseEntity.status(status).body(response);
} }
private boolean isSseRequest(HttpServletRequest request) {
if (request == null) {
return false;
}
final String accept = request.getHeader("Accept");
if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
return true;
}
final String uri = request.getRequestURI();
return uri != null && uri.endsWith("/stream");
}
private String sanitize(String message) {
if (message == null) {
return "系统异常";
}
return message
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\r", " ")
.replace("\n", " ");
}
} }

View File

@@ -19,6 +19,7 @@ public class SvnVersionRangeRequest {
@NotNull @NotNull
private Integer month; private Integer month;
private String clientTraceId;
public String getPresetId() { public String getPresetId() {
return presetId; return presetId;
@@ -59,4 +60,12 @@ public class SvnVersionRangeRequest {
public void setMonth(Integer month) { public void setMonth(Integer month) {
this.month = month; this.month = month;
} }
public String getClientTraceId() {
return clientTraceId;
}
public void setClientTraceId(String clientTraceId) {
this.clientTraceId = clientTraceId;
}
} }

View File

@@ -12,6 +12,9 @@ public class TaskInfo {
private int progress; private int progress;
private String message; private String message;
private String error; private String error;
private String aiReasoningText;
private String aiAnswerText;
private String aiStreamStatus;
private Instant createdAt; private Instant createdAt;
private Instant updatedAt; private Instant updatedAt;
private final List<String> files = new ArrayList<String>(); private final List<String> files = new ArrayList<String>();
@@ -64,6 +67,30 @@ public class TaskInfo {
this.error = error; this.error = error;
} }
public String getAiReasoningText() {
return aiReasoningText;
}
public void setAiReasoningText(String aiReasoningText) {
this.aiReasoningText = aiReasoningText;
}
public String getAiAnswerText() {
return aiAnswerText;
}
public void setAiAnswerText(String aiAnswerText) {
this.aiAnswerText = aiAnswerText;
}
public String getAiStreamStatus() {
return aiStreamStatus;
}
public void setAiStreamStatus(String aiStreamStatus) {
this.aiStreamStatus = aiStreamStatus;
}
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -70,10 +70,7 @@ public class HealthService {
private boolean ensureWritable(Path outputRoot) { private boolean ensureWritable(Path outputRoot) {
try { try {
Files.createDirectories(outputRoot); Files.createDirectories(outputRoot);
final Path probe = outputRoot.resolve(".health-probe"); return Files.isWritable(outputRoot);
Files.write(probe, "ok".getBytes("UTF-8"));
Files.deleteIfExists(probe);
return true;
} catch (Exception e) { } catch (Exception e) {
return false; return false;
} }

View File

@@ -3,6 +3,7 @@ package com.svnlog.web.service;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
@@ -10,6 +11,7 @@ import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -49,19 +51,25 @@ public class OutputFileService {
} }
final List<Path> filePaths = new ArrayList<Path>(); final List<Path> filePaths = new ArrayList<Path>();
Files.walk(root) try (Stream<Path> stream = Files.walk(root)) {
.filter(Files::isRegularFile) stream.filter(Files::isRegularFile).forEach(filePaths::add);
.forEach(filePaths::add); } catch (NoSuchFileException ignored) {
return new ArrayList<OutputFileInfo>();
}
filePaths.sort(Comparator.comparingLong(this::lastModified).reversed()); filePaths.sort(Comparator.comparingLong(this::lastModified).reversed());
final List<OutputFileInfo> result = new ArrayList<OutputFileInfo>(); final List<OutputFileInfo> result = new ArrayList<OutputFileInfo>();
for (Path path : filePaths) { for (Path path : filePaths) {
try {
final OutputFileInfo info = new OutputFileInfo(); final OutputFileInfo info = new OutputFileInfo();
info.setPath(root.relativize(path).toString().replace(File.separatorChar, '/')); info.setPath(root.relativize(path).toString().replace(File.separatorChar, '/'));
info.setSize(Files.size(path)); info.setSize(Files.size(path));
info.setModifiedAt(Instant.ofEpochMilli(lastModified(path))); info.setModifiedAt(Instant.ofEpochMilli(lastModified(path)));
result.add(info); result.add(info);
} catch (NoSuchFileException ignored) {
// file may disappear between walk and stat under concurrent updates
}
} }
return result; return result;
} }

View File

@@ -8,6 +8,8 @@ import org.springframework.stereotype.Service;
@Service @Service
public class SettingsService { public class SettingsService {
// 启动默认 API Key仅作为本地默认值可在设置页覆盖
private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7";
private final OutputFileService outputFileService; private final OutputFileService outputFileService;
private final SvnPresetService svnPresetService; private final SvnPresetService svnPresetService;
@@ -17,6 +19,7 @@ public class SettingsService {
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) { public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
this.outputFileService = outputFileService; this.outputFileService = outputFileService;
this.svnPresetService = svnPresetService; this.svnPresetService = svnPresetService;
this.runtimeApiKey = initStartupApiKey();
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId(); this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
} }
@@ -58,8 +61,23 @@ public class SettingsService {
return null; return null;
} }
private String initStartupApiKey() {
final String envKey = System.getenv("DEEPSEEK_API_KEY");
if (envKey != null && !envKey.trim().isEmpty()) {
return envKey.trim();
}
if (BOOTSTRAP_API_KEY != null && !BOOTSTRAP_API_KEY.trim().isEmpty()
&& !BOOTSTRAP_API_KEY.startsWith("REPLACE_WITH_")) {
return BOOTSTRAP_API_KEY.trim();
}
return null;
}
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())) {
return "env";
}
return "runtime"; return "runtime";
} }
if (envKey != null && !envKey.trim().isEmpty()) { if (envKey != null && !envKey.trim().isEmpty()) {

View File

@@ -2,13 +2,10 @@ package com.svnlog.web.service;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.svnlog.web.config.SvnPresetProperties;
import com.svnlog.web.model.SvnPreset; import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.SvnPresetSummary; import com.svnlog.web.model.SvnPresetSummary;
@@ -16,38 +13,25 @@ import com.svnlog.web.model.SvnPresetSummary;
public class SvnPresetService { public class SvnPresetService {
private final List<SvnPreset> presets; private final List<SvnPreset> presets;
private final String configuredDefaultPresetId;
public SvnPresetService(SvnPresetProperties properties) {
final List<SvnPreset> source = properties.getPresets() == null
? Collections.<SvnPreset>emptyList()
: properties.getPresets();
if (source.isEmpty()) {
throw new IllegalStateException("SVN 预设未配置,请检查 application.properties 中的 svn.presets");
}
public SvnPresetService() {
final List<SvnPreset> list = new ArrayList<SvnPreset>(); final List<SvnPreset> list = new ArrayList<SvnPreset>();
final Set<String> ids = new HashSet<String>(); list.add(new SvnPreset(
for (SvnPreset preset : source) { "preset-1",
final String id = trim(preset.getId()); "PRS-7050场站智慧管控",
final String name = trim(preset.getName()); "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00"
final String url = trim(preset.getUrl()); ));
if (id.isEmpty() || name.isEmpty() || url.isEmpty()) { list.add(new SvnPreset(
throw new IllegalStateException("SVN 预设配置不完整id/name/url 均不能为空"); "preset-2",
} "PRS-7950在线巡视",
if (!ids.add(id)) { "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00"
throw new IllegalStateException("SVN 预设 id 重复: " + id); ));
} list.add(new SvnPreset(
list.add(new SvnPreset(id, name, url)); "preset-3",
} "PRS-7950在线巡视电科院测试版",
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024"
));
this.presets = Collections.unmodifiableList(list); this.presets = Collections.unmodifiableList(list);
final String configured = trim(properties.getDefaultPresetId());
if (!configured.isEmpty() && containsPresetId(configured)) {
this.configuredDefaultPresetId = configured;
} else {
this.configuredDefaultPresetId = this.presets.get(0).getId();
}
} }
public List<SvnPreset> listPresets() { public List<SvnPreset> listPresets() {
@@ -89,7 +73,7 @@ public class SvnPresetService {
} }
public String configuredDefaultPresetId() { public String configuredDefaultPresetId() {
return configuredDefaultPresetId; return firstPresetId();
} }
private String trim(String value) { private String trim(String value) {

View File

@@ -38,6 +38,23 @@ public class TaskContext {
emitEvent("phase", payload); emitEvent("phase", payload);
} }
public void updateAiOutput(String reasoning, String answer) {
taskInfo.setAiReasoningText(reasoning);
taskInfo.setAiAnswerText(answer);
taskInfo.setUpdatedAt(java.time.Instant.now());
if (onUpdate != null) {
onUpdate.run();
}
}
public void setAiStreamStatus(String status) {
taskInfo.setAiStreamStatus(status);
taskInfo.setUpdatedAt(java.time.Instant.now());
if (onUpdate != null) {
onUpdate.run();
}
}
public void emitEvent(String eventName, Map<String, Object> payload) { public void emitEvent(String eventName, Map<String, Object> payload) {
if (eventPublisher != null && eventName != null && !eventName.trim().isEmpty()) { if (eventPublisher != null && eventName != null && !eventName.trim().isEmpty()) {
eventPublisher.publish(eventName, payload == null ? new HashMap<String, Object>() : payload); eventPublisher.publish(eventName, payload == null ? new HashMap<String, Object>() : payload);

View File

@@ -76,6 +76,9 @@ public class TaskPersistenceService {
info.progress = task.getProgress(); info.progress = task.getProgress();
info.message = task.getMessage(); info.message = task.getMessage();
info.error = task.getError(); info.error = task.getError();
info.aiReasoningText = task.getAiReasoningText();
info.aiAnswerText = task.getAiAnswerText();
info.aiStreamStatus = task.getAiStreamStatus();
info.createdAt = toString(task.getCreatedAt()); info.createdAt = toString(task.getCreatedAt());
info.updatedAt = toString(task.getUpdatedAt()); info.updatedAt = toString(task.getUpdatedAt());
info.files = new ArrayList<String>(task.getFiles()); info.files = new ArrayList<String>(task.getFiles());
@@ -90,6 +93,9 @@ public class TaskPersistenceService {
task.setProgress(persisted.progress); task.setProgress(persisted.progress);
task.setMessage(persisted.message); task.setMessage(persisted.message);
task.setError(persisted.error); task.setError(persisted.error);
task.setAiReasoningText(persisted.aiReasoningText);
task.setAiAnswerText(persisted.aiAnswerText);
task.setAiStreamStatus(persisted.aiStreamStatus);
task.setCreatedAt(parseInstant(persisted.createdAt)); task.setCreatedAt(parseInstant(persisted.createdAt));
task.setUpdatedAt(parseInstant(persisted.updatedAt)); task.setUpdatedAt(parseInstant(persisted.updatedAt));
if (persisted.files != null) { if (persisted.files != null) {
@@ -131,6 +137,9 @@ public class TaskPersistenceService {
private int progress; private int progress;
private String message; private String message;
private String error; private String error;
private String aiReasoningText;
private String aiAnswerText;
private String aiStreamStatus;
private String createdAt; private String createdAt;
private String updatedAt; private String updatedAt;
private List<String> files; private List<String> files;

View File

@@ -18,6 +18,8 @@ import java.util.concurrent.Future;
import javax.annotation.PreDestroy; import javax.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -28,6 +30,7 @@ import com.svnlog.web.model.TaskStatus;
@Service @Service
public class TaskService { public class TaskService {
private static final Logger LOGGER = LoggerFactory.getLogger(TaskService.class);
public interface TaskRunner { public interface TaskRunner {
TaskResult run(TaskContext context) throws Exception; TaskResult run(TaskContext context) throws Exception;
@@ -57,6 +60,9 @@ public class TaskService {
taskInfo.setStatus(TaskStatus.PENDING); taskInfo.setStatus(TaskStatus.PENDING);
taskInfo.setProgress(0); taskInfo.setProgress(0);
taskInfo.setMessage("任务已创建"); taskInfo.setMessage("任务已创建");
taskInfo.setAiReasoningText("");
taskInfo.setAiAnswerText("");
taskInfo.setAiStreamStatus("IDLE");
taskInfo.setCreatedAt(now); taskInfo.setCreatedAt(now);
taskInfo.setUpdatedAt(now); taskInfo.setUpdatedAt(now);
tasks.put(taskId, taskInfo); tasks.put(taskId, taskInfo);
@@ -79,6 +85,10 @@ public class TaskService {
return tasks.get(taskId); return tasks.get(taskId);
} }
public boolean hasTask(String taskId) {
return taskId != null && tasks.containsKey(taskId);
}
public List<TaskInfo> getTasks() { public List<TaskInfo> getTasks() {
return new ArrayList<TaskInfo>(tasks.values()).stream() return new ArrayList<TaskInfo>(tasks.values()).stream()
.sorted(Comparator.comparing(TaskInfo::getCreatedAt).reversed()) .sorted(Comparator.comparing(TaskInfo::getCreatedAt).reversed())
@@ -152,7 +162,9 @@ public class TaskService {
try { try {
emitter.send(SseEmitter.event().name("phase").data(buildPhasePayload(task))); emitter.send(SseEmitter.event().name("phase").data(buildPhasePayload(task)));
} catch (Exception sendException) { } catch (Exception sendException) {
LOGGER.warn("Initial SSE phase send failed: taskId={}", taskId, sendException);
removeEmitter(taskId, emitter); removeEmitter(taskId, emitter);
throw new IllegalStateException("SSE 初始事件发送失败: " + sendException.getMessage(), sendException);
} }
if (isTerminal(task.getStatus())) { if (isTerminal(task.getStatus())) {
@@ -172,6 +184,7 @@ public class TaskService {
taskInfo.setStatus(TaskStatus.RUNNING); taskInfo.setStatus(TaskStatus.RUNNING);
taskInfo.setMessage("任务执行中"); taskInfo.setMessage("任务执行中");
taskInfo.setAiStreamStatus("RUNNING");
taskInfo.setUpdatedAt(Instant.now()); taskInfo.setUpdatedAt(Instant.now());
persistSafely(); persistSafely();
publishTaskEvent(taskInfo.getTaskId(), "phase", buildPhasePayload(taskInfo)); publishTaskEvent(taskInfo.getTaskId(), "phase", buildPhasePayload(taskInfo));
@@ -185,6 +198,7 @@ public class TaskService {
taskInfo.setStatus(TaskStatus.SUCCESS); taskInfo.setStatus(TaskStatus.SUCCESS);
taskInfo.setProgress(100); taskInfo.setProgress(100);
taskInfo.setMessage(result != null ? result.getMessage() : "执行完成"); taskInfo.setMessage(result != null ? result.getMessage() : "执行完成");
taskInfo.setAiStreamStatus("DONE");
taskInfo.getFiles().clear(); taskInfo.getFiles().clear();
if (result != null && result.getFiles() != null) { if (result != null && result.getFiles() != null) {
taskInfo.getFiles().addAll(result.getFiles()); taskInfo.getFiles().addAll(result.getFiles());
@@ -203,6 +217,7 @@ public class TaskService {
taskInfo.setStatus(TaskStatus.FAILED); taskInfo.setStatus(TaskStatus.FAILED);
taskInfo.setError(buildErrorMessage(e)); taskInfo.setError(buildErrorMessage(e));
taskInfo.setMessage("执行失败"); taskInfo.setMessage("执行失败");
taskInfo.setAiStreamStatus("ERROR");
taskInfo.setUpdatedAt(Instant.now()); taskInfo.setUpdatedAt(Instant.now());
persistSafely(); persistSafely();
publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, taskInfo.getError())); publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, taskInfo.getError()));
@@ -345,6 +360,9 @@ public class TaskService {
payload.put("status", taskInfo.getStatus() == null ? "" : taskInfo.getStatus().name()); payload.put("status", taskInfo.getStatus() == null ? "" : taskInfo.getStatus().name());
payload.put("progress", taskInfo.getProgress()); payload.put("progress", taskInfo.getProgress());
payload.put("message", taskInfo.getMessage()); payload.put("message", taskInfo.getMessage());
payload.put("aiReasoningText", taskInfo.getAiReasoningText());
payload.put("aiAnswerText", taskInfo.getAiAnswerText());
payload.put("aiStreamStatus", taskInfo.getAiStreamStatus());
payload.put("updatedAt", taskInfo.getUpdatedAt() == null ? "" : taskInfo.getUpdatedAt().toString()); payload.put("updatedAt", taskInfo.getUpdatedAt() == null ? "" : taskInfo.getUpdatedAt().toString());
return payload; return payload;
} }

View File

@@ -9,16 +9,3 @@ spring.servlet.multipart.max-request-size=100MB
# Logging settings # Logging settings
logging.level.com.svnlog=INFO logging.level.com.svnlog=INFO
logging.level.org.springframework=INFO logging.level.org.springframework=INFO
# SVN preset settings
svn.default-preset-id=preset-1
svn.presets[0].id=preset-1
svn.presets[0].name=PRS-7050??????
svn.presets[0].url=https://10.6.221.149:48080/svn/houtai/001_????/PRS-7050??????/01_???/V1.00
svn.presets[1].id=preset-2
svn.presets[1].name=PRS-7950????
svn.presets[1].url=https://10.6.221.149:48080/svn/houtai/001_????/PRS-7950????/01_???/V2.00
svn.presets[2].id=preset-3
svn.presets[2].name=PRS-7950??????????
svn.presets[2].url=https://10.6.221.149:48080/svn/houtai/001_????/PRS-7950????/01_???/V1.00_20\
24

View File

@@ -102,24 +102,42 @@ async function apiFetch(url, options = {}) {
} }
async function refreshAll() { async function refreshAll() {
try { const [tasksResult, filesResult, healthResult] = await Promise.allSettled([
const [tasksResp, filesResp, healthResp] = await Promise.all([
apiFetch("/api/tasks"), apiFetch("/api/tasks"),
apiFetch("/api/files"), apiFetch("/api/files"),
apiFetch("/api/health/details"), apiFetch("/api/health/details"),
]); ]);
if (tasksResult.status === "fulfilled") {
const tasksResp = tasksResult.value;
state.tasks = (tasksResp || []).slice().sort((a, b) => sortByTimeDesc(a.createdAt, b.createdAt)); state.tasks = (tasksResp || []).slice().sort((a, b) => sortByTimeDesc(a.createdAt, b.createdAt));
} else {
console.warn("refresh tasks failed:", tasksResult.reason);
}
if (filesResult.status === "fulfilled") {
const filesResp = filesResult.value || {};
state.files = (filesResp.files || []).slice().sort((a, b) => sortByTimeDesc(a.modifiedAt, b.modifiedAt)); state.files = (filesResp.files || []).slice().sort((a, b) => sortByTimeDesc(a.modifiedAt, b.modifiedAt));
state.health = healthResp || null; if (filesResp.error) {
console.warn("files api degraded:", filesResp.error);
}
} else {
console.warn("refresh files failed:", filesResult.reason);
state.files = [];
}
if (healthResult.status === "fulfilled") {
state.health = healthResult.value || null;
} else {
console.warn("refresh health failed:", healthResult.reason);
state.health = null;
}
renderDashboard(); renderDashboard();
if (state.activeView === "history") { if (state.activeView === "history") {
loadTaskPage(); loadTaskPage();
renderFileTable(); renderFileTable();
} }
} catch (err) {
toast(err.message, true);
}
} }
async function loadPresets() { async function loadPresets() {
@@ -247,6 +265,40 @@ async function waitForTaskCompletion(taskId) {
} }
} }
async function waitForStreamCompletion(streamState, timeoutMs) {
const start = Date.now();
while (!streamState.streamCompleted && (Date.now() - start) < timeoutMs) {
await new Promise(resolve => setTimeout(resolve, 100));
}
return streamState.streamCompleted;
}
function syncTaskAiOutput(task, streamState) {
if (!task || !streamState) {
return;
}
const reasoning = task.aiReasoningText || "";
const answer = task.aiAnswerText || "";
if (reasoning.length > streamState.reasoningRenderedLength) {
const delta = reasoning.slice(streamState.reasoningRenderedLength);
if (delta) {
appendReasoning(delta);
streamState.reasoningRenderedLength = reasoning.length;
streamState.firstDeltaReceived = true;
}
}
if (answer.length > streamState.answerRenderedLength) {
const delta = answer.slice(streamState.answerRenderedLength);
if (delta) {
appendAnswer(delta);
streamState.answerRenderedLength = answer.length;
streamState.firstDeltaReceived = true;
}
}
}
async function onRunSvn(event) { async function onRunSvn(event) {
event.preventDefault(); event.preventDefault();
const form = event.target; const form = event.target;
@@ -353,8 +405,23 @@ async function onRunSvn(event) {
reasoningBuffer: "", reasoningBuffer: "",
answerBuffer: "", answerBuffer: "",
streamAvailable: true, streamAvailable: true,
streamConnected: false,
firstEventReceived: false,
firstDeltaReceived: false,
streamCompleted: false,
taskTerminal: false,
reasoningRenderedLength: 0,
answerRenderedLength: 0,
lastAiStatusLogged: "",
}; };
aiStream = openTaskEventStream(aiData.taskId, { aiStream = openTaskEventStream(aiData.taskId, {
onOpen: () => {
streamState.streamConnected = true;
appendSystemLog("实时流连接已建立");
},
onFirstEvent: () => {
streamState.firstEventReceived = true;
},
onPhase: (payload) => { onPhase: (payload) => {
if (payload && payload.message) { if (payload && payload.message) {
appendSystemLog(payload.message); appendSystemLog(payload.message);
@@ -362,11 +429,21 @@ async function onRunSvn(event) {
}, },
onReasoning: (text) => { onReasoning: (text) => {
if (!text) return; if (!text) return;
if (!streamState.firstDeltaReceived) {
streamState.firstDeltaReceived = true;
appendSystemLog("已接收 AI 流式输出");
}
streamState.reasoningRenderedLength += text.length;
streamState.reasoningBuffer += text; streamState.reasoningBuffer += text;
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", false); flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", false);
}, },
onAnswer: (text) => { onAnswer: (text) => {
if (!text) return; if (!text) return;
if (!streamState.firstDeltaReceived) {
streamState.firstDeltaReceived = true;
appendSystemLog("已接收 AI 流式输出");
}
streamState.answerRenderedLength += text.length;
streamState.answerBuffer += text; streamState.answerBuffer += text;
flushStreamBuffer(streamState, "answerBuffer", "answer", false); flushStreamBuffer(streamState, "answerBuffer", "answer", false);
}, },
@@ -375,14 +452,28 @@ async function onRunSvn(event) {
appendSystemLog(`Token统计: prompt=${payload.promptTokens || 0}, completion=${payload.completionTokens || 0}, total=${payload.totalTokens || 0}`); appendSystemLog(`Token统计: prompt=${payload.promptTokens || 0}, completion=${payload.completionTokens || 0}, total=${payload.totalTokens || 0}`);
}, },
onError: (payload) => { onError: (payload) => {
if (payload && payload.detail) { const detail = payload && (payload.detail || payload.error || payload.message);
appendSystemLog(`流式错误: ${payload.detail}`, true); if (detail) {
appendSystemLog(`流式错误: ${detail}`, true);
} }
}, },
onTransportError: () => { onDone: () => {
streamState.streamCompleted = true;
appendSystemLog("流式输出已结束");
},
onTransportError: (meta) => {
if (streamState.taskTerminal || streamState.streamCompleted) {
return;
}
if (streamState.streamAvailable) { if (streamState.streamAvailable) {
streamState.streamAvailable = false; streamState.streamAvailable = false;
appendSystemLog("实时流中断,已回退到轮询模式"); if (meta && !meta.opened) {
appendSystemLog("实时流连接失败,已回退到轮询模式", true);
} else if (meta && !meta.firstEventReceived) {
appendSystemLog("实时流未收到事件即中断,已回退到轮询模式", true);
} else {
appendSystemLog("实时流中断,已回退到轮询模式", true);
}
} }
}, },
}); });
@@ -390,17 +481,32 @@ async function onRunSvn(event) {
// 等待AI任务完成实时显示日志 // 等待AI任务完成实时显示日志
while (true) { while (true) {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`); const task = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`);
syncTaskAiOutput(task, streamState);
if (!streamState.streamAvailable && task.aiStreamStatus && task.aiStreamStatus !== streamState.lastAiStatusLogged) {
streamState.lastAiStatusLogged = task.aiStreamStatus;
appendSystemLog(`轮询回显状态: ${task.aiStreamStatus}`);
}
if (task.status === "SUCCESS") { if (task.status === "SUCCESS") {
streamState.taskTerminal = true;
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true); flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true);
flushStreamBuffer(streamState, "answerBuffer", "answer", true); flushStreamBuffer(streamState, "answerBuffer", "answer", true);
syncTaskAiOutput(task, streamState);
appendSystemLog("AI分析完成"); appendSystemLog("AI分析完成");
if (task.message) appendSystemLog(task.message); if (task.message) appendSystemLog(task.message);
if (!streamState.streamCompleted) {
const completed = await waitForStreamCompletion(streamState, 2500);
if (!completed) {
appendSystemLog("流式收尾超时,已使用轮询结果完成任务");
}
}
aiStream.close(); aiStream.close();
break; break;
} }
if (task.status === "FAILED" || task.status === "CANCELLED") { if (task.status === "FAILED" || task.status === "CANCELLED") {
streamState.taskTerminal = true;
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true); flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true);
flushStreamBuffer(streamState, "answerBuffer", "answer", true); flushStreamBuffer(streamState, "answerBuffer", "answer", true);
syncTaskAiOutput(task, streamState);
aiStream.close(); aiStream.close();
throw new Error(`AI分析失败: ${task.error || task.message}`); throw new Error(`AI分析失败: ${task.error || task.message}`);
} }
@@ -450,6 +556,11 @@ function openTaskEventStream(taskId, handlers = {}) {
const streamUrl = `/api/tasks/${encodeURIComponent(taskId)}/stream`; const streamUrl = `/api/tasks/${encodeURIComponent(taskId)}/stream`;
const source = new EventSource(streamUrl); const source = new EventSource(streamUrl);
const meta = {
opened: false,
done: false,
firstEventReceived: false,
};
const parse = (event) => { const parse = (event) => {
try { try {
return JSON.parse(event.data || "{}"); return JSON.parse(event.data || "{}");
@@ -457,26 +568,56 @@ function openTaskEventStream(taskId, handlers = {}) {
return {}; return {};
} }
}; };
const markFirstEvent = () => {
if (meta.firstEventReceived) {
return;
}
meta.firstEventReceived = true;
handlers.onFirstEvent && handlers.onFirstEvent();
};
source.onopen = () => {
meta.opened = true;
handlers.onOpen && handlers.onOpen();
};
source.addEventListener("phase", (event) => { source.addEventListener("phase", (event) => {
markFirstEvent();
handlers.onPhase && handlers.onPhase(parse(event)); handlers.onPhase && handlers.onPhase(parse(event));
}); });
source.addEventListener("reasoning_delta", (event) => { source.addEventListener("reasoning_delta", (event) => {
markFirstEvent();
const payload = parse(event); const payload = parse(event);
handlers.onReasoning && handlers.onReasoning(payload.text || ""); handlers.onReasoning && handlers.onReasoning(payload.text || "");
}); });
source.addEventListener("answer_delta", (event) => { source.addEventListener("answer_delta", (event) => {
markFirstEvent();
const payload = parse(event); const payload = parse(event);
handlers.onAnswer && handlers.onAnswer(payload.text || ""); handlers.onAnswer && handlers.onAnswer(payload.text || "");
}); });
source.addEventListener("usage", (event) => { source.addEventListener("usage", (event) => {
markFirstEvent();
handlers.onUsage && handlers.onUsage(parse(event)); handlers.onUsage && handlers.onUsage(parse(event));
}); });
source.addEventListener("error", (event) => { source.addEventListener("error", (event) => {
markFirstEvent();
handlers.onError && handlers.onError(parse(event)); handlers.onError && handlers.onError(parse(event));
}); });
source.addEventListener("done", (event) => {
markFirstEvent();
meta.done = true;
handlers.onDone && handlers.onDone(parse(event));
source.close();
});
source.onerror = () => { source.onerror = () => {
handlers.onTransportError && handlers.onTransportError(); if (meta.done) {
return;
}
handlers.onTransportError && handlers.onTransportError({
opened: meta.opened,
closed: source.readyState === EventSource.CLOSED,
firstEventReceived: meta.firstEventReceived,
});
source.close(); source.close();
}; };
@@ -497,7 +638,15 @@ function flushStreamBuffer(streamState, key, target, force) {
if (!shouldFlush) { if (!shouldFlush) {
return; return;
} }
const cleaned = text.replace(/\s+/g, " ").trim(); const cleaned = text
.replace(/\r/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!cleaned) {
streamState[key] = "";
return;
}
if (target === "reasoning") { if (target === "reasoning") {
appendReasoning(cleaned); appendReasoning(cleaned);
} else if (target === "answer") { } else if (target === "answer") {
@@ -510,15 +659,17 @@ function flushStreamBuffer(streamState, key, target, force) {
function appendSystemLog(message, isError = false) { function appendSystemLog(message, isError = false) {
const logOutput = document.querySelector("#system-log-output"); const logOutput = document.querySelector("#system-log-output");
if (!logOutput) {
console.warn("system-log-output not found:", message);
return;
}
markPanelReady("#system-log-output"); markPanelReady("#system-log-output");
const p = document.createElement("p"); const p = document.createElement("p");
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
p.style.margin = "2px 0"; p.className = isError ? "log-line is-error" : "log-line is-info";
if (isError) { if (isError) {
p.style.color = "#dc2626";
p.textContent = `[${time}] ❌ ${message}`; p.textContent = `[${time}] ❌ ${message}`;
} else { } else {
p.style.color = "#1e293b";
p.textContent = `[${time}] ${message}`; p.textContent = `[${time}] ${message}`;
} }
logOutput.appendChild(p); logOutput.appendChild(p);
@@ -526,19 +677,22 @@ function appendSystemLog(message, isError = false) {
} }
function appendReasoning(message) { function appendReasoning(message) {
appendPane("#reasoning-output", message, "#334155"); appendPane("#reasoning-output", message, "is-reasoning");
} }
function appendAnswer(message) { function appendAnswer(message) {
appendPane("#answer-output", message, "#166534"); appendPane("#answer-output", message, "is-answer");
} }
function appendPane(selector, message, color) { function appendPane(selector, message, toneClass) {
const logOutput = document.querySelector(selector); const logOutput = document.querySelector(selector);
if (!logOutput) {
console.warn(`${selector} not found:`, message);
return;
}
markPanelReady(selector); markPanelReady(selector);
const p = document.createElement("p"); const p = document.createElement("p");
p.style.margin = "2px 0"; p.className = `log-line ${toneClass}`.trim();
p.style.color = color;
p.textContent = message; p.textContent = message;
logOutput.appendChild(p); logOutput.appendChild(p);
logOutput.scrollTop = logOutput.scrollHeight; logOutput.scrollTop = logOutput.scrollHeight;
@@ -854,19 +1008,34 @@ async function onAutoFillVersions() {
try { try {
for (const project of projects) { for (const project of projects) {
appendLog(`正在查询 ${project.name} 的版本范围...`); appendLog(`正在查询 ${project.name} 的版本范围...`);
const traceId = `autofill-${project.presetId}-${Date.now()}`;
// 调用后端接口获取月份版本范围 const requestPayload = {
const data = await apiFetch("/api/svn/version-range", {
method: "POST",
body: JSON.stringify({
presetId: project.presetId, presetId: project.presetId,
username: "liujing2", username: "liujing2",
password: "sunri@20230620*#&", password: "sunri@20230620*#&",
year: parseInt(year), year: parseInt(year),
month: parseInt(month), month: parseInt(month),
filterUser: "liujing2@SZNARI" // 只查询该用户的提交(完整用户名) clientTraceId: traceId,
}), };
const requestPayloadForLog = {
presetId: requestPayload.presetId,
username: requestPayload.username,
password: maskSecret(requestPayload.password),
year: requestPayload.year,
month: requestPayload.month,
clientTraceId: requestPayload.clientTraceId,
};
appendLog(`[AutoFill][Request] project=${project.name} payload=${JSON.stringify(requestPayloadForLog)}`);
// 调用后端接口获取月份版本范围
const data = await apiFetch("/api/svn/version-range", {
method: "POST",
body: JSON.stringify(requestPayload),
}); });
appendLog(`[AutoFill][Response] traceId=${traceId} payload=${safeJsonStringify(data)}`);
if (data && data.resolvedSvnUrl) {
appendLog(`[AutoFill][SVN] traceId=${traceId} actualUrl=${data.resolvedSvnUrl}`);
}
if (data.startRevision && data.endRevision) { if (data.startRevision && data.endRevision) {
project.startInput.value = data.startRevision; project.startInput.value = data.startRevision;
@@ -881,8 +1050,28 @@ async function onAutoFillVersions() {
toast("版本号填充完成"); toast("版本号填充完成");
} catch (err) { } catch (err) {
appendLog(`填充失败: ${err.message}`, true); appendLog(`填充失败: ${err.message}`, true);
appendLog(`[AutoFill][Error] detail=${err.message}`, true);
toast(err.message, true); toast(err.message, true);
} finally { } finally {
setLoading(btn, false); setLoading(btn, false);
} }
} }
function maskSecret(secret) {
if (!secret) {
return "(empty)";
}
return `***len=${String(secret).length}`;
}
function safeJsonStringify(value) {
try {
const raw = JSON.stringify(value);
if (!raw) {
return "{}";
}
return raw.length > 600 ? `${raw.slice(0, 600)}...(truncated)` : raw;
} catch (err) {
return `\"<invalid-json:${err.message}>\"`;
}
}

View File

@@ -0,0 +1,391 @@
<!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="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">
<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>

View File

@@ -7,12 +7,22 @@
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
</head> </head>
<body> <body>
<div class="bg-grid" aria-hidden="true"></div>
<div class="bg-glow bg-glow-1" aria-hidden="true"></div>
<div class="bg-glow bg-glow-2" aria-hidden="true"></div>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar" aria-label="主导航"> <aside class="sidebar" aria-label="主导航">
<div class="brand">
<span class="brand-dot" aria-hidden="true"></span>
<div>
<h1>SVN 工作台</h1> <h1>SVN 工作台</h1>
<nav> <p>日志抓取与统计分析</p>
<button class="nav-item" data-view="dashboard">工作台</button> </div>
<button class="nav-item active" data-view="svn">SVN 日志抓取</button> </div>
<nav class="nav-list">
<button class="nav-item active" data-view="dashboard">工作台</button>
<button class="nav-item" data-view="svn">SVN 日志抓取</button>
<button class="nav-item" data-view="history">任务历史</button> <button class="nav-item" data-view="history">任务历史</button>
<button class="nav-item" data-view="settings">系统设置</button> <button class="nav-item" data-view="settings">系统设置</button>
</nav> </nav>
@@ -64,48 +74,47 @@
<section class="view" id="view-svn"> <section class="view" id="view-svn">
<article class="card form-card"> <article class="card form-card">
<h3>SVN 批量抓取参数</h3> <h3>SVN 批量抓取参数</h3>
<div class="alert info span-2" style="margin-bottom:16px;padding:12px;border-radius:10px;background:#d1f0eb;color:#0f766e">
默认已填充3个常用项目路径可选择月份自动填充版本号或手动填写 <div class="alert info span-2">
默认已填充 3 个常用项目路径,可选择月份自动填充版本号,或手动填写。
</div> </div>
<div class="span-2" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:10px;">
<div class="grid cols-3" style="gap:10px;align-items:end;"> <div class="month-panel span-2">
<div class="grid cols-3 month-grid">
<label>统计月份 <label>统计月份
<input type="month" id="version-month"> <input type="month" id="version-month">
</label> </label>
<div style="grid-column: span 2;"> <div class="span-2 month-action">
<button type="button" id="btn-auto-fill" class="primary" style="width:100%">一键填充所有项目版本号</button> <button type="button" id="btn-auto-fill" class="primary">一键填充所有项目版本号</button>
</div> </div>
</div> </div>
</div> </div>
<form id="svn-form" class="form-grid"> <form id="svn-form" class="form-grid">
<!-- 项目1 --> <div class="span-2 project-item">
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px"> <h4>项目 1PRS-7050 场站智慧管控</h4>
<h4 style="margin:0 0 10px 0">项目 1PRS-7050 场站智慧管控</h4> <div class="grid cols-2">
<div class="grid cols-2" style="gap:10px">
<label>开始版本号<input name="startRevision_1" inputmode="numeric" placeholder="请输入开始版本"></label> <label>开始版本号<input name="startRevision_1" inputmode="numeric" placeholder="请输入开始版本"></label>
<label>结束版本号<input name="endRevision_1" inputmode="numeric" placeholder="请输入结束版本"></label> <label>结束版本号<input name="endRevision_1" inputmode="numeric" placeholder="请输入结束版本"></label>
</div> </div>
</div> </div>
<!-- 项目2 --> <div class="span-2 project-item">
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px"> <h4>项目 2PRS-7950 在线巡视</h4>
<h4 style="margin:0 0 10px 0">项目 2PRS-7950 在线巡视</h4> <div class="grid cols-2">
<div class="grid cols-2" style="gap:10px">
<label>开始版本号<input name="startRevision_2" inputmode="numeric" placeholder="请输入开始版本"></label> <label>开始版本号<input name="startRevision_2" inputmode="numeric" placeholder="请输入开始版本"></label>
<label>结束版本号<input name="endRevision_2" inputmode="numeric" placeholder="请输入结束版本"></label> <label>结束版本号<input name="endRevision_2" inputmode="numeric" placeholder="请输入结束版本"></label>
</div> </div>
</div> </div>
<!-- 项目3 --> <div class="span-2 project-item">
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:12px"> <h4>项目 3PRS-7950 在线巡视电科院测试版</h4>
<h4 style="margin:0 0 10px 0">项目 3PRS-7950 在线巡视电科院测试版</h4> <div class="grid cols-2">
<div class="grid cols-2" style="gap:10px">
<label>开始版本号<input name="startRevision_3" inputmode="numeric" placeholder="请输入开始版本"></label> <label>开始版本号<input name="startRevision_3" inputmode="numeric" placeholder="请输入开始版本"></label>
<label>结束版本号<input name="endRevision_3" inputmode="numeric" placeholder="请输入结束版本"></label> <label>结束版本号<input name="endRevision_3" inputmode="numeric" placeholder="请输入结束版本"></label>
</div> </div>
</div> </div>
<!-- 通用配置 -->
<label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤" value="liujing"></label> <label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤" value="liujing"></label>
<label>工作周期<input name="period" placeholder="例如 2026年03月"></label> <label>工作周期<input name="period" placeholder="例如 2026年03月"></label>
<label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label> <label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label>
@@ -117,8 +126,7 @@
</form> </form>
</article> </article>
<!-- 执行日志面板 --> <article class="card" id="log-panel">
<article class="card" id="log-panel" style="display:none;margin-top:16px;">
<h3>执行进度</h3> <h3>执行进度</h3>
<div class="live-grid"> <div class="live-grid">
<section class="live-column reasoning"> <section class="live-column reasoning">
@@ -143,8 +151,6 @@
</article> </article>
</section> </section>
<section class="view" id="view-history"> <section class="view" id="view-history">
<article class="card"> <article class="card">
<h3>任务列表</h3> <h3>任务列表</h3>
@@ -199,6 +205,6 @@
</main> </main>
</div> </div>
<script src="/app.js" defer></script> <script src="/app.js?v=20260407_1811" defer></script>
</body> </body>
</html> </html>

View File

@@ -1,100 +1,216 @@
:root { :root {
--bg: #eef2f5; --bg-0: #0b1020;
--panel: #ffffff; --bg-1: #121a2f;
--text: #122126; --bg-2: #1a2744;
--muted: #4b5f66;
--primary: #0f766e; --surface-0: rgba(19, 30, 53, 0.62);
--primary-soft: #d1f0eb; --surface-1: rgba(24, 37, 64, 0.84);
--danger: #b42318; --surface-2: #1f3158;
--warning: #b54708;
--success: #067647; --text-0: #e8efff;
--border: #d6e0e4; --text-1: #c4d2f0;
--shadow: 0 10px 24px rgba(12, 41, 49, 0.08); --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; box-sizing: border-box;
} }
html,
body {
height: 100%;
}
body { body {
margin: 0; margin: 0;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text); color: var(--text-0);
background: radial-gradient(circle at top right, #dff4ef 0%, var(--bg) 42%, #edf1f7 100%); 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 { .app-shell {
position: relative;
z-index: var(--z-layout);
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: 260px 1fr; grid-template-columns: 280px 1fr;
gap: var(--space-4);
padding: var(--space-4);
} }
.sidebar { .sidebar {
border-right: 1px solid var(--border); background: linear-gradient(165deg, rgba(30, 47, 80, 0.86) 0%, rgba(17, 27, 48, 0.95) 100%);
background: linear-gradient(180deg, #0d645d 0%, #13454f 100%); border: 1px solid var(--border-0);
color: #f8fffd; box-shadow: var(--shadow-0);
padding: 24px 18px; border-radius: var(--radius-lg);
padding: var(--space-4);
position: sticky; position: sticky;
top: 0; top: var(--space-4);
height: 100vh; height: calc(100vh - 40px);
display: flex;
flex-direction: column;
gap: var(--space-4);
backdrop-filter: blur(8px);
} }
.sidebar h1 { .brand {
font-size: 22px; display: flex;
margin: 0 0 20px; 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; letter-spacing: 0.4px;
} }
.sidebar nav { .brand p {
margin: 6px 0 0;
color: var(--text-2);
font-size: 13px;
}
.nav-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: var(--space-2);
} }
.nav-item { .nav-item {
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
color: #ecf8f5; color: var(--text-1);
text-align: left; text-align: left;
font-size: 16px; font-size: 15px;
line-height: 1.5; line-height: 1.45;
padding: 10px 12px; padding: 12px 14px;
border-radius: 10px; border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease; transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
} }
.nav-item:hover, .nav-item:hover,
.nav-item:focus-visible { .nav-item:focus-visible {
background: rgba(255, 255, 255, 0.15); color: var(--text-0);
border-color: rgba(255, 255, 255, 0.25); border-color: var(--border-1);
outline: 2px solid rgba(255, 255, 255, 0.4); background: rgba(139, 177, 240, 0.14);
outline-offset: 2px; outline: none;
} }
.nav-item.active { .nav-item.active {
background: #effaf7; color: #031126;
border-color: #bbebe2; background: linear-gradient(135deg, #8ab6ff 0%, #9ed4ff 100%);
color: #114549; border-color: #acd2ff;
font-weight: 700; font-weight: 700;
box-shadow: 0 10px 24px rgba(99, 159, 243, 0.34);
} }
.main { .main {
padding: 24px; min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-right: var(--space-2);
} }
.main-header { .main-header {
margin-bottom: 18px; 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 { .main-header h2 {
margin: 0; margin: 0;
font-size: 28px; font-size: 28px;
letter-spacing: 0.3px;
} }
.main-header p { .main-header p {
margin: 6px 0 0; margin: 8px 0 0;
color: var(--muted); color: var(--text-1);
} }
.view { .view {
@@ -107,17 +223,16 @@ body {
.grid { .grid {
display: grid; display: grid;
gap: 16px; gap: var(--space-3);
}
.grid.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 16px;
} }
.grid.cols-4 { .grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 16px; margin-bottom: var(--space-3);
}
.grid.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.grid.cols-2 { .grid.cols-2 {
@@ -125,23 +240,46 @@ body {
} }
.card { .card {
background: var(--panel); background: var(--surface-1);
border: 1px solid var(--border); border: 1px solid var(--border-0);
border-radius: 14px; border-radius: var(--radius-lg);
box-shadow: var(--shadow); box-shadow: var(--shadow-1);
padding: 16px; padding: var(--space-4);
backdrop-filter: blur(8px);
}
.card + .card {
margin-top: var(--space-3);
} }
.card h3 { .card h3 {
margin-top: 0; margin: 0 0 var(--space-3);
margin-bottom: 12px; 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 { .stat p {
font-size: 40px;
margin: 0; margin: 0;
font-size: 38px;
font-weight: 700; font-weight: 700;
color: var(--primary); color: var(--accent-1);
} }
.list { .list {
@@ -151,20 +289,31 @@ body {
} }
.list li { .list li {
border-bottom: 1px solid #edf2f4; border-bottom: 1px solid rgba(157, 185, 229, 0.2);
padding: 10px 0; padding: 12px 0;
font-size: 15px; font-size: 14px;
line-height: 1.5; line-height: 1.6;
} }
.list li:last-child { .list li:last-child {
border-bottom: none; border-bottom: none;
} }
.list a {
color: #afd0ff;
text-decoration: none;
}
.list a:hover,
.list a:focus-visible {
color: #cde4ff;
text-decoration: underline;
}
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: var(--space-3);
} }
label { label {
@@ -172,6 +321,7 @@ label {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
line-height: 1.6; line-height: 1.6;
color: var(--text-1);
} }
input, input,
@@ -180,42 +330,87 @@ button {
font: inherit; font: inherit;
} }
input { input,
width: 100%;
margin-top: 6px;
border: 1px solid #b6c5ca;
border-radius: 10px;
padding: 10px 12px;
min-height: 44px;
background: #fff;
}
select { select {
width: 100%; width: 100%;
margin-top: 6px; margin-top: 6px;
border: 1px solid #b6c5ca;
border-radius: 10px;
padding: 10px 12px;
min-height: 44px; min-height: 44px;
background: #fff; 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:focus-visible { input::placeholder {
outline: 2px solid #76b8ad; color: rgba(173, 192, 228, 0.66);
outline-offset: 1px;
border-color: #4fa494;
} }
select:focus-visible { input:focus-visible,
outline: 2px solid #76b8ad; select:focus-visible,
outline-offset: 1px; button:focus-visible {
border-color: #4fa494; 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 { .span-2 {
grid-column: 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 { .actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -224,30 +419,35 @@ select:focus-visible {
button { button {
min-height: 44px; min-height: 44px;
border-radius: 10px; border-radius: var(--radius-sm);
border: 1px solid #a9bbc1; border: 1px solid var(--border-0);
background: #f4f8fa; background: rgba(88, 120, 170, 0.22);
color: var(--text-0);
padding: 0 16px; padding: 0 16px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
} }
button:hover, button:hover,
button:focus-visible { button:focus-visible {
background: #e4edf1; background: rgba(122, 160, 221, 0.35);
outline: 2px solid #b7cad2; border-color: rgba(157, 194, 253, 0.72);
outline-offset: 2px; }
button:active {
transform: translateY(1px);
} }
button.primary { button.primary {
background: var(--primary); background: linear-gradient(135deg, #6fafff 0%, #8fcbff 100%);
border-color: var(--primary); border-color: #9cd0ff;
color: #fff; color: #04162d;
font-weight: 700;
} }
button.primary:hover, button.primary:hover,
button.primary:focus-visible { button.primary:focus-visible {
background: #0c5f59; background: linear-gradient(135deg, #85bcff 0%, #a1d7ff 100%);
} }
button:disabled { button:disabled {
@@ -255,30 +455,84 @@ button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
.file-picker { #log-panel {
border: 1px solid #c7d6db; display: none;
border-radius: 10px; margin-top: var(--space-3);
background: #f9fbfc;
padding: 8px;
max-height: 220px;
overflow: auto;
} }
.file-picker label { .live-grid {
display: flex; display: grid;
align-items: center; grid-template-columns: 1fr 1fr;
gap: 8px; gap: var(--space-3);
padding: 6px;
border-radius: 8px;
font-weight: 500;
} }
.file-picker label:hover { .live-column header,
background: #ecf4f7; .system-log-wrap > header {
font-size: 13px;
font-weight: 700;
color: var(--text-1);
margin-bottom: 7px;
letter-spacing: 0.2px;
} }
.table-wrap { .live-output,
overflow-x: auto; .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 { .history-toolbar {
@@ -288,12 +542,41 @@ button:disabled {
margin-bottom: 12px; 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 { .pager {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 10px; margin-top: 10px;
color: var(--muted); color: var(--text-2);
font-size: 14px; font-size: 14px;
} }
@@ -302,19 +585,10 @@ button:disabled {
gap: 8px; gap: 8px;
} }
table { .btn-cancel-task {
width: 100%; min-height: 32px;
border-collapse: collapse; padding: 0 10px;
min-width: 720px; font-size: 13px;
}
th,
td {
padding: 10px 8px;
border-bottom: 1px solid #e8eef0;
text-align: left;
font-size: 14px;
vertical-align: top;
} }
.tag { .tag {
@@ -323,112 +597,105 @@ td {
padding: 2px 10px; padding: 2px 10px;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
border: 1px solid transparent;
} }
.tag.SUCCESS { .tag.SUCCESS {
background: #d1fadf; background: rgba(73, 194, 138, 0.16);
color: var(--success); border-color: rgba(73, 194, 138, 0.5);
color: #95edc2;
} }
.tag.RUNNING, .tag.RUNNING,
.tag.PENDING { .tag.PENDING {
background: #fef0c7; background: rgba(240, 184, 93, 0.16);
color: var(--warning); border-color: rgba(240, 184, 93, 0.45);
color: #ffd99d;
} }
.tag.FAILED { .tag.FAILED {
background: #fee4e2; background: rgba(255, 127, 135, 0.16);
color: var(--danger); border-color: rgba(255, 127, 135, 0.5);
color: #ffb5bc;
} }
.tag.CANCELLED { .tag.CANCELLED {
background: #e4e7ec; background: rgba(148, 166, 196, 0.16);
color: #344054; border-color: rgba(148, 166, 196, 0.45);
color: #ced9f1;
} }
.muted { .muted {
color: var(--muted); color: var(--text-2);
} }
.toast { .toast {
position: fixed; position: fixed;
right: 20px; right: 24px;
bottom: 20px; bottom: 24px;
border-radius: 10px; z-index: var(--z-toast);
border-radius: var(--radius-md);
padding: 12px 14px; padding: 12px 14px;
background: #11343b; background: rgba(8, 16, 33, 0.96);
color: #fff; border: 1px solid rgba(132, 171, 235, 0.46);
color: #eef4ff;
min-width: 240px; min-width: 240px;
max-width: 380px; max-width: 400px;
display: none; display: none;
box-shadow: var(--shadow); box-shadow: var(--shadow-0);
} }
.toast.show { .toast.show {
display: block; display: block;
} }
.live-grid { @media (max-width: 1180px) {
display: grid; .grid.cols-4 {
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; }
} }
.live-column header, @media (max-width: 980px) {
.system-log-wrap > header {
font-size: 13px;
font-weight: 700;
color: var(--muted);
margin-bottom: 6px;
}
.live-output,
.system-output {
height: 240px;
overflow-y: auto;
border-radius: 8px;
padding: 12px;
font-family: monospace;
font-size: 13px;
line-height: 1.6;
border: 1px solid var(--border);
background: #f8f9fa;
}
.live-column.reasoning .live-output {
background: #f6fbff;
}
.live-column.answer .live-output {
background: #f5fdf8;
}
.system-log-wrap {
margin-top: 10px;
}
@media (max-width: 1024px) {
.app-shell { .app-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-3);
padding: var(--space-3);
} }
.sidebar { .sidebar {
position: static; position: sticky;
top: 0;
height: auto; height: auto;
padding: 14px;
gap: 12px;
border-radius: var(--radius-md);
} }
.sidebar nav { .brand p {
display: grid; display: none;
grid-template-columns: repeat(2, minmax(0, 1fr)); }
.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-3,
.grid.cols-4,
.grid.cols-2, .grid.cols-2,
.form-grid { .grid.cols-3,
grid-template-columns: 1fr; .form-grid,
}
.history-toolbar { .history-toolbar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -436,16 +703,42 @@ td {
.span-2 { .span-2 {
grid-column: span 1; grid-column: span 1;
} }
.month-action {
grid-column: span 1;
}
} }
@media (max-width: 900px) { @media (max-width: 760px) {
.grid.cols-4 {
grid-template-columns: 1fr;
}
.main-header h2 {
font-size: 24px;
}
.card {
padding: var(--space-3);
}
.live-grid { .live-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.toast {
right: 12px;
left: 12px;
bottom: 12px;
min-width: 0;
max-width: none;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *,
*::before,
*::after {
animation: none !important; animation: none !important;
transition: none !important; transition: none !important;
scroll-behavior: auto !important; scroll-behavior: auto !important;