feat: update web ui, docker make commands, and related docs/config
This commit is contained in:
@@ -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
154
CLAUDE.md
Normal 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
16
Dockerfile
Normal 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
17
Makefile
Normal 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"
|
||||||
11
README.md
11
README.md
@@ -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
11
docker-compose.yml
Normal 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
|
||||||
@@ -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`
|
||||||
|
|
||||||
## 使用步骤
|
## 使用步骤
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
## 页面说明
|
## 页面说明
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
@@ -117,129 +125,146 @@ public class SVNLogFetcher {
|
|||||||
public void testConnection() throws SVNException {
|
public void testConnection() throws SVNException {
|
||||||
repository.testConnection();
|
repository.testConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定年月的版本范围(采样估算法,不过滤用户)
|
* 获取指定年月的版本范围(基于时间边界,不过滤用户)
|
||||||
* @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};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>();
|
||||||
response.put("files", outputFileService.listOutputFiles());
|
try {
|
||||||
response.put("outputDir", outputFileService.getOutputRoot().toString());
|
response.put("files", outputFileService.listOutputFiles());
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Map<String, Object>> build(HttpStatus status, String message) {
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
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", " ");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
final OutputFileInfo info = new OutputFileInfo();
|
try {
|
||||||
info.setPath(root.relativize(path).toString().replace(File.separatorChar, '/'));
|
final OutputFileInfo info = new OutputFileInfo();
|
||||||
info.setSize(Files.size(path));
|
info.setPath(root.relativize(path).toString().replace(File.separatorChar, '/'));
|
||||||
info.setModifiedAt(Instant.ofEpochMilli(lastModified(path)));
|
info.setSize(Files.size(path));
|
||||||
result.add(info);
|
info.setModifiedAt(Instant.ofEpochMilli(lastModified(path)));
|
||||||
|
result.add(info);
|
||||||
|
} catch (NoSuchFileException ignored) {
|
||||||
|
// file may disappear between walk and stat under concurrent updates
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -102,23 +102,41 @@ 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"),
|
]);
|
||||||
]);
|
|
||||||
state.tasks = (tasksResp || []).slice().sort((a, b) => sortByTimeDesc(a.createdAt, b.createdAt));
|
|
||||||
state.files = (filesResp.files || []).slice().sort((a, b) => sortByTimeDesc(a.modifiedAt, b.modifiedAt));
|
|
||||||
state.health = healthResp || null;
|
|
||||||
renderDashboard();
|
|
||||||
if (state.activeView === "history") {
|
|
||||||
loadTaskPage();
|
|
||||||
renderFileTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
if (tasksResult.status === "fulfilled") {
|
||||||
toast(err.message, true);
|
const tasksResp = tasksResult.value;
|
||||||
|
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));
|
||||||
|
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();
|
||||||
|
if (state.activeView === "history") {
|
||||||
|
loadTaskPage();
|
||||||
|
renderFileTable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 = {
|
||||||
|
presetId: project.presetId,
|
||||||
|
username: "liujing2",
|
||||||
|
password: "sunri@20230620*#&",
|
||||||
|
year: parseInt(year),
|
||||||
|
month: parseInt(month),
|
||||||
|
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", {
|
const data = await apiFetch("/api/svn/version-range", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(requestPayload),
|
||||||
presetId: project.presetId,
|
|
||||||
username: "liujing2",
|
|
||||||
password: "sunri@20230620*#&",
|
|
||||||
year: parseInt(year),
|
|
||||||
month: parseInt(month),
|
|
||||||
filterUser: "liujing2@SZNARI" // 只查询该用户的提交(完整用户名)
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
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}>\"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
391
src/main/resources/static/index-redesign.html
Normal file
391
src/main/resources/static/index-redesign.html
Normal 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>
|
||||||
@@ -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="主导航">
|
||||||
<h1>SVN 工作台</h1>
|
<div class="brand">
|
||||||
<nav>
|
<span class="brand-dot" aria-hidden="true"></span>
|
||||||
<button class="nav-item" data-view="dashboard">工作台</button>
|
<div>
|
||||||
<button class="nav-item active" data-view="svn">SVN 日志抓取</button>
|
<h1>SVN 工作台</h1>
|
||||||
|
<p>日志抓取与统计分析</p>
|
||||||
|
</div>
|
||||||
|
</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,61 +74,59 @@
|
|||||||
<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>项目 1:PRS-7050 场站智慧管控</h4>
|
||||||
<h4 style="margin:0 0 10px 0">项目 1:PRS-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>项目 2:PRS-7950 在线巡视</h4>
|
||||||
<h4 style="margin:0 0 10px 0">项目 2:PRS-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>项目 3:PRS-7950 在线巡视电科院测试版</h4>
|
||||||
<h4 style="margin:0 0 10px 0">项目 3:PRS-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>
|
||||||
|
|
||||||
<div class="actions span-2">
|
<div class="actions span-2">
|
||||||
<button type="button" id="btn-test-connection">测试连接</button>
|
<button type="button" id="btn-test-connection">测试连接</button>
|
||||||
<button type="submit" id="btn-svn-run" class="primary">一键抓取并导出 Excel</button>
|
<button type="submit" id="btn-svn-run" class="primary">一键抓取并导出 Excel</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user