feat: v2 Vue3 frontend + multiple optimizations

- New Vue 3 + Vite frontend at /v2/ (OLED dark theme, Fira Sans/Code)
- Date selector: support day/week/month range (backend unchanged)
- SSE auto-reconnect (up to 3 retries)
- Visibility polling pause (dashboard pauses when tab hidden)
- Friendly Chinese HTTP error messages
- Cancel task with confirmation in Dashboard
- Split AiWorkflowService (1700->845 lines):
  - AiApiService: AI API calls + streaming
  - ExcelExportService: POI Excel generation
- Dockerfile: 3-stage build (Node frontend -> Maven -> JRE)
- WebApplication.java: System.out -> Logger
- .gitignore: v2 build output, backup dirs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-06-08 15:12:52 +08:00
parent c9c40869d7
commit 1b182c2930
37 changed files with 4782 additions and 1913 deletions
+9
View File
@@ -1,3 +1,12 @@
# frontend-vue (Vue 3 + Vite)
frontend-vue/node_modules/
# frontend build output (auto-generated by Docker build)
src/main/resources/static/v2/
# Backup directory
outputs.nobody-backup-*/
# Maven
target/
pom.xml.tag
+44 -6
View File
@@ -1,19 +1,57 @@
# syntax=docker/dockerfile:1.7
# ============================================================
# Docker 镜像仓库加速(默认使用 docker.1ms.run 国内代理)
# 如需切换回 Docker Hub
# docker compose build --build-arg REGISTRY_MIRROR=docker.io/library
# ============================================================
ARG REGISTRY_MIRROR=docker.1ms.run/library
# ============================================================
# Stage 1: 前端构建(Vue 3 + Vite
# ============================================================
FROM ${REGISTRY_MIRROR}/node:22-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend-vue/package.json frontend-vue/package-lock.json ./
RUN npm ci
COPY frontend-vue/ ./
RUN npm run build
# ============================================================
# Stage 2: 后端构建(Maven + Java 8
# ============================================================
FROM ${REGISTRY_MIRROR}/maven:3.9.6-eclipse-temurin-8 AS builder
# Maven JVM 调优:增大堆内存、启用并行
ENV MAVEN_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m -Djava.util.concurrent.ForkJoinPool.common.parallelism=4"
FROM maven:3.9.6-eclipse-temurin-8 AS builder
WORKDIR /app
# 使用阿里云 Maven 镜像加速依赖下载(替换 Maven Central
COPY maven-settings.xml /root/.m2/settings.xml
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests dependency:go-offline
mvn -B -DskipTests -T 1C dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests clean package
# 将前端构建产物注入静态资源目录(Maven 会自动打包进 jar)
# vite.config.js 中 outDir 为相对 __dirname 的路径,容器内 __dirname=/frontend
COPY --from=frontend-builder /src/main/resources/static/v2 /app/src/main/resources/static/v2
FROM eclipse-temurin:8-jre
# -T 1C: 按 CPU 核数并行; -o: 离线模式(依赖已缓存,跳过元数据检查)
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests -T 1C -o clean package
# ============================================================
# Stage 3: 运行镜像(最小化 JRE)
# ============================================================
FROM ${REGISTRY_MIRROR}/eclipse-temurin:8-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/svn-log-tool-1.0.0-jar-with-dependencies.jar app.jar
+8 -1
View File
@@ -1,3 +1,10 @@
# ============================================================
# Docker Hub 镜像代理(默认使用 docker.1ms.run 加速)
# 如果需要切换回 Docker Hub
# DOCKER_REGISTRY_MIRROR=docker.io/library make up
# ============================================================
DOCKER_REGISTRY_MIRROR ?= docker.1ms.run/library
.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)
@@ -5,7 +12,7 @@ BUILD_ENV := DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1
up:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(BUILD_ENV) $(COMPOSE_CMD) up -d --build
@REGISTRY_MIRROR=$(DOCKER_REGISTRY_MIRROR) $(BUILD_ENV) $(COMPOSE_CMD) up -d --build
@echo "Application is starting at http://localhost:18088"
down:
+5 -2
View File
@@ -3,9 +3,12 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
# Docker 镜像加速(默认 Docker Hub,国内可设阿里云)
# 使用方式: REGISTRY_MIRROR=registry.cn-hangzhou.aliyuncs.com/library docker compose build
REGISTRY_MIRROR: ${REGISTRY_MIRROR:-docker.1ms.run/library}
container_name: svn-log-tool
ports:
- "18088:18088"
network_mode: host
volumes:
- ./outputs:/app/outputs
restart: unless-stopped
+1
View File
@@ -93,6 +93,7 @@ http://localhost:18088
- `POST /api/svn/test-connection`
- `POST /api/svn/fetch`
- `POST /api/svn/version-range`:按 `rangeType=date|week|month` 查询版本范围;旧的 `year/month` 月份请求仍兼容
- `GET /api/svn/presets`
- `POST /api/ai/analyze`
- `GET /api/tasks`
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0F172A" />
<title>SVN 日志工作台 v2</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=Fira+Sans:wght@300;400;500;600;700&family=Fira+Code:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1229
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "svn-log-tool-v2",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.0"
}
}
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="app-layout">
<a href="#main-content" class="skip-link" @click.prevent="focusMain">跳到主内容</a>
<AppSidebar />
<main class="main-area" id="main-content" tabindex="-1">
<header class="main-header">
<h2>{{ route.meta.title || 'SVN 工作台' }}</h2>
<p>{{ route.meta.desc || '' }}</p>
</header>
<div class="main-content">
<router-view />
</div>
</main>
<div class="toast-container" aria-live="polite">
<div
v-for="t in toastQueue"
:key="t.id"
:class="['toast-item', t.isError ? 'toast-error' : 'toast-success']"
>
<span>{{ t.isError ? '!' : '✓' }}</span>
<span>{{ t.message }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router'
import AppSidebar from './components/AppSidebar.vue'
import { useToast } from './composables/useApi'
const route = useRoute()
const { toastQueue } = useToast()
function focusMain() {
document.getElementById('main-content')?.focus()
}
</script>
<style scoped>
.skip-link {
position: fixed;
top: -100%;
left: 8px;
z-index: 10000;
background: var(--c-primary);
color: #fff;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 600;
text-decoration: none;
font-family: var(--font-sans);
}
.skip-link:focus {
top: 8px;
}
#main-content:focus {
outline: none;
}
</style>
@@ -0,0 +1,30 @@
<template>
<aside class="sidebar" aria-label="主导航">
<div class="sidebar-brand">
<div class="sidebar-brand-icon" aria-hidden="true">S</div>
<div class="sidebar-brand-text">
<h1>SVN 工作台</h1>
<p>v2 · Log &amp; Analysis</p>
</div>
</div>
<nav class="sidebar-nav">
<router-link class="sidebar-link" to="/dashboard" exact-active-class="active" aria-label="工作台">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
</router-link>
<router-link class="sidebar-link" to="/svn-fetch" active-class="active" aria-label="SVN 日志抓取">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>SVN 日志抓取</span>
</router-link>
<router-link class="sidebar-link" to="/history" active-class="active" aria-label="任务历史">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>任务历史</span>
</router-link>
<router-link class="sidebar-link" to="/settings" active-class="active" aria-label="系统设置">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>系统设置</span>
</router-link>
</nav>
<div class="sidebar-footer">SVN Log Tool v2</div>
</aside>
</template>
+76
View File
@@ -0,0 +1,76 @@
import { ref } from 'vue'
const toastQueue = ref([])
let toastId = 0
export function useToast() {
function toast(message, isError = false) {
const id = ++toastId
toastQueue.value.push({ id, message, isError })
setTimeout(() => {
const idx = toastQueue.value.findIndex(t => t.id === id)
if (idx >= 0) toastQueue.value.splice(idx, 1)
}, 3500)
}
return { toastQueue, toast }
}
const HTTP_ERRORS = {
400: '请求参数有误,请检查输入',
401: '认证失败,请检查 API Key 配置',
403: '无权限访问',
404: '请求的资源不存在',
413: '文件过大,请减小输入文件',
429: '请求过于频繁,请稍后重试',
500: '服务器内部错误,请稍后重试',
502: '服务暂时不可用,请稍后重试',
503: '服务暂时不可用,请稍后重试',
}
export function useApi() {
async function apiFetch(url, options = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const friendly = HTTP_ERRORS[res.status] || `请求失败 (${res.status})`
throw new Error(body.error || body.message || friendly)
}
const text = await res.text()
return text ? JSON.parse(text) : {}
}
function buildDownloadUrl(path) {
return `/api/files/download?path=${encodeURIComponent(path || '')}`
}
async function downloadFile(path) {
const response = await fetch(buildDownloadUrl(path), {
headers: { Accept: 'application/octet-stream' },
})
if (!response.ok) throw new Error(`下载失败: ${response.status}`)
// Check content type to avoid HTML error pages
const ct = (response.headers.get('Content-Type') || '').toLowerCase()
if (ct.includes('text/html')) {
throw new Error('下载接口返回了 HTML 错误页')
}
const blob = await response.blob()
const cd = response.headers.get('Content-Disposition') || ''
const match = cd.match(/filename\*=UTF-8''([^;]+)/i)
let name = path.split('/').filter(Boolean).pop() || 'download'
if (match) try { name = decodeURIComponent(match[1]) } catch {}
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = name
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(blobUrl)
}
return { apiFetch, buildDownloadUrl, downloadFile }
}
+25
View File
@@ -0,0 +1,25 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import DashboardView from './views/DashboardView.vue'
import SvnFetchView from './views/SvnFetchView.vue'
import HistoryView from './views/HistoryView.vue'
import SettingsView from './views/SettingsView.vue'
import './styles/main.css'
const routes = [
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', name: 'dashboard', component: DashboardView, meta: { title: '工作台', desc: '查看系统状态与最近产物' } },
{ path: '/svn-fetch', name: 'svn-fetch', component: SvnFetchView, meta: { title: 'SVN 日志抓取', desc: '一键抓取 SVN 日志并导出工作量 Excel' } },
{ path: '/history', name: 'history', component: HistoryView, meta: { title: '任务历史', desc: '查看任务执行状态、日志与产物' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: '系统设置', desc: '配置 API Key 与输出目录' } },
]
const router = createRouter({
history: createWebHashHistory('/v2/'),
routes,
})
const app = createApp(App)
app.use(router)
app.mount('#app')
+658
View File
@@ -0,0 +1,658 @@
/* =============================================
SVN Log Tool v2 — OLED Dark Theme
Design System: Dark Mode (OLED) by UI/UX Pro Max
Colors: #0F172A bg, #1E293B surface, #22C55E accent
Fonts: Fira Sans (body), Fira Code (heading/data)
============================================= */
:root {
--c-bg: #0F172A;
--c-surface: #1E293B;
--c-surface-hover: #273548;
--c-surface-subtle: #1A2538;
--c-border: #334155;
--c-border-light: #293548;
--c-text: #F1F5F9;
--c-text-secondary: #94A3B8;
--c-text-muted: #64748B;
--c-primary: #22C55E;
--c-primary-hover: #16A34A;
--c-primary-bg: rgba(34, 197, 94, 0.10);
--c-primary-glow: 0 0 20px rgba(34, 197, 94, 0.15);
--c-success: #22C55E;
--c-success-bg: rgba(34, 197, 94, 0.12);
--c-warning: #EAB308;
--c-warning-bg: rgba(234, 179, 8, 0.12);
--c-danger: #EF4444;
--c-danger-bg: rgba(239, 68, 68, 0.12);
--c-info: #3B82F6;
--c-info-bg: rgba(59, 130, 246, 0.12);
--c-code-bg: #0C1929;
--font-sans: 'Fira Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2);
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.3);
--shadow-glow: 0 0 20px rgba(34, 197, 94, 0.12);
--transition: 180ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { height: 100%; -webkit-font-smoothing: antialiased; }
body {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
color: var(--c-text);
background: var(--c-bg);
height: 100%;
overflow: hidden;
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(34, 197, 94, 0.2);
}
#app { height: 100%; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
/* ========== Layout ========== */
.app-layout {
display: flex;
height: 100vh;
}
/* ========== Sidebar ========== */
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--c-surface);
border-right: 1px solid var(--c-border-light);
display: flex;
flex-direction: column;
padding: var(--space-lg) var(--space-md);
gap: var(--space-xl);
user-select: none;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
padding: 0 var(--space-sm);
}
.sidebar-brand-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, #22C55E, #16A34A);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 16px;
font-family: var(--font-mono);
box-shadow: var(--shadow-glow);
}
.sidebar-brand-text h1 {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.2px;
color: var(--c-text);
}
.sidebar-brand-text p {
font-size: 11px;
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-top: 2px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 450;
color: var(--c-text-secondary);
text-decoration: none;
transition: background var(--transition), color var(--transition);
cursor: pointer;
border: none;
background: none;
font-family: inherit;
width: 100%;
text-align: left;
position: relative;
}
.sidebar-link:hover {
background: rgba(255,255,255,0.04);
color: var(--c-text);
}
.sidebar-link.active,
.sidebar-link.router-link-exact-active {
background: var(--c-primary-bg);
color: var(--c-primary);
font-weight: 500;
}
.sidebar-link.active::before,
.sidebar-link.router-link-exact-active::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--c-primary);
border-radius: 0 2px 2px 0;
}
.sidebar-link:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
border-radius: var(--radius-md);
}
.sidebar-link .icon {
width: 18px;
height: 18px;
flex-shrink: 0;
opacity: 0.6;
transition: opacity var(--transition);
}
.sidebar-link:hover .icon { opacity: 0.9; }
.sidebar-link.active .icon,
.sidebar-link.router-link-exact-active .icon {
opacity: 1;
color: var(--c-primary);
}
.sidebar-footer {
font-size: 11px;
color: var(--c-text-muted);
padding: var(--space-md) var(--space-sm) 0;
border-top: 1px solid var(--c-border-light);
margin-top: auto;
}
/* ========== Main Area ========== */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
background: var(--c-bg);
}
.main-header {
padding: var(--space-xl) var(--space-xl) 0;
flex-shrink: 0;
}
.main-header h2 {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.2px;
color: var(--c-text);
font-family: var(--font-sans);
}
.main-header p {
font-size: 13px;
color: var(--c-text-secondary);
margin-top: 4px;
}
.main-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-lg) var(--space-xl) var(--space-xl);
min-height: 0;
}
/* ========== Cards ========== */
.card {
background: var(--c-surface);
border: 1px solid var(--c-border-light);
border-radius: var(--radius-lg);
padding: var(--space-lg);
box-shadow: var(--shadow-card);
transition: border-color var(--transition);
}
.card:hover {
border-color: var(--c-border);
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--c-text);
margin-bottom: var(--space-md);
display: flex;
align-items: center;
gap: 8px;
}
/* ========== Stats Row ========== */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-md);
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: var(--space-sm) 0;
}
.stat-item .label {
font-size: 11px;
font-weight: 500;
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-item .value {
font-size: 28px;
font-weight: 600;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
line-height: 1.2;
color: var(--c-text);
}
.stat-item .value-sub {
font-size: 12px;
font-weight: 400;
font-family: var(--font-sans);
color: var(--c-text-secondary);
line-height: 1.5;
}
/* ========== Grids ========== */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-lg);
}
.span-2 { grid-column: span 2; }
.span-all { grid-column: 1 / -1; }
/* ========== Forms ========== */
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label {
font-size: 12px;
font-weight: 500;
color: var(--c-text-secondary);
letter-spacing: 0.2px;
}
.form-input, .form-select, .form-textarea {
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
padding: 9px 12px;
font-size: 13px;
font-family: inherit;
color: var(--c-text);
background: var(--c-code-bg);
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
width: 100%;
}
.form-input:hover, .form-select:hover { border-color: #475569; }
.form-input:focus-visible, .form-select:focus-visible {
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12);
}
.form-input:focus:not(:focus-visible), .form-select:focus:not(:focus-visible) {
border-color: var(--c-border);
}
.form-input::placeholder { color: var(--c-text-muted); }
.form-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 32px;
cursor: pointer;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
}
/* ========== Buttons ========== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
color: var(--c-text);
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
text-decoration: none;
}
.btn:hover:not(:disabled) {
border-color: #475569;
background: var(--c-surface-hover);
}
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary {
background: var(--c-primary);
border-color: var(--c-primary);
color: #fff;
font-weight: 600;
box-shadow: var(--shadow-glow);
}
.btn-primary:hover:not(:disabled) {
background: var(--c-primary-hover);
border-color: var(--c-primary-hover);
box-shadow: 0 0 24px rgba(34, 197, 94, 0.25);
}
.btn-danger {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: var(--c-danger);
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.25);
border-color: var(--c-danger);
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.btn-group {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
/* ========== Tags ========== */
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.tag-success { background: var(--c-success-bg); color: var(--c-success); }
.tag-warning { background: var(--c-warning-bg); color: var(--c-warning); }
.tag-danger { background: var(--c-danger-bg); color: var(--c-danger); }
.tag-muted { background: rgba(100, 116, 139, 0.15); color: var(--c-text-muted); }
.tag-info { background: var(--c-info-bg); color: var(--c-info); }
/* ========== Tables ========== */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--c-border-light);
border-radius: var(--radius-md);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead th {
background: rgba(15, 23, 42, 0.5);
padding: 10px 14px;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--c-text-muted);
text-align: left;
border-bottom: 1px solid var(--c-border-light);
}
tbody td {
padding: 10px 14px;
border-bottom: 1px solid var(--c-border-light);
color: var(--c-text);
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(255,255,255,0.02); }
tr a { color: var(--c-primary); text-decoration: none; font-weight: 500; }
tr a:hover { text-decoration: underline; color: var(--c-primary-hover); }
/* ========== Lists ========== */
.list { list-style: none; }
.list li {
padding: 10px 12px;
border-bottom: 1px solid var(--c-border-light);
font-size: 13px;
}
.list li:last-child { border-bottom: none; }
.list li:hover { background: rgba(255,255,255,0.02); }
.list-empty {
color: var(--c-text-muted);
font-size: 13px;
padding: 24px 20px;
text-align: center;
}
/* ========== Toast ========== */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--space-sm);
pointer-events: none;
}
.toast-item {
pointer-events: auto;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
padding: 12px 20px;
box-shadow: var(--shadow-elevated);
font-size: 13px;
font-weight: 500;
color: var(--c-text);
display: flex;
align-items: center;
gap: 8px;
animation: toast-in 200ms ease-out forwards;
max-width: 420px;
}
.toast-item.toast-error { border-left: 3px solid var(--c-danger); }
.toast-item.toast-success { border-left: 3px solid var(--c-primary); }
.toast-item.toast-leave { animation: toast-out 200ms ease-in forwards; }
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
/* ========== Log Panels ========== */
.log-panel {
background: var(--c-code-bg);
border: 1px solid #1E293B;
border-radius: var(--radius-md);
padding: var(--space-md);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
color: #CBD5E1;
overflow-y: auto;
position: relative;
white-space: pre-wrap;
word-break: break-all;
}
.log-panel-dots {
padding-bottom: var(--space-sm);
margin-bottom: var(--space-sm);
border-bottom: 1px solid rgba(255,255,255,0.06);
opacity: 0.3;
display: flex;
gap: 6px;
}
.log-panel-dots span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.log-panel-dots .dot-red { background: #FF5F56; }
.log-panel-dots .dot-yellow { background: #FFBD2E; }
.log-panel-dots .dot-green { background: #27C93F; }
.log-pane-3 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
}
.log-pane-3 > div:nth-child(3) { grid-column: 1 / -1; }
.log-line { margin: 2px 0; }
.log-info { color: #94A3B8; }
.log-error { color: #F87171; }
.log-reasoning { color: #818CF8; font-style: italic; }
.log-answer { color: #34D399; }
.log-muted { color: #475569; }
/* ========== Toolbar ========== */
.toolbar {
display: flex;
gap: var(--space-sm);
align-items: center;
flex-wrap: wrap;
padding: var(--space-md);
background: rgba(15, 23, 42, 0.4);
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
border: 1px solid var(--c-border-light);
}
.toolbar .form-input,
.toolbar .form-select {
padding: 7px 10px;
font-size: 12px;
background: var(--c-code-bg);
}
/* ========== Pagination ========== */
.pager {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) 0 0;
font-size: 13px;
color: var(--c-text-secondary);
}
.pager-actions { display: flex; gap: var(--space-sm); }
/* ========== Alerts ========== */
.alert {
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.5;
}
.alert-info {
background: var(--c-info-bg);
border-left: 3px solid var(--c-info);
color: #93C5FD;
}
/* ========== Project Blocks ========== */
.project-block {
border: 1px solid var(--c-border-light);
border-radius: var(--radius-md);
padding: var(--space-md);
background: rgba(15, 23, 42, 0.3);
}
.project-block h4 {
font-size: 13px;
font-weight: 600;
margin-bottom: var(--space-md);
color: var(--c-text);
font-family: var(--font-sans);
}
/* ========== Utilities ========== */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.15);
border-top-color: var(--c-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.form-grid-section-title {
font-size: 13px;
font-weight: 600;
color: var(--c-text-secondary);
padding: var(--space-md) 0 var(--space-sm);
border-bottom: 1px solid var(--c-border-light);
margin-bottom: var(--space-sm);
letter-spacing: 0.3px;
}
/* ========== Responsive ========== */
@media (max-width: 1024px) {
.sidebar { width: 200px; padding: var(--space-md) var(--space-sm); }
.main-header { padding: var(--space-lg) var(--space-md) 0; }
.main-content { padding: var(--space-md); }
}
@media (max-width: 768px) {
.sidebar { width: 56px; padding: var(--space-md) var(--space-sm); }
.sidebar-brand-text { display: none; }
.sidebar-link span:not(.icon) { display: none; }
.sidebar-link { justify-content: center; padding: 10px; }
.sidebar-link.active::before,
.sidebar-link.router-link-exact-active::before { left: -8px; }
.sidebar-footer { display: none; }
.grid-2 { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
.log-pane-3 { grid-template-columns: 1fr; }
.stats-row { grid-template-columns: repeat(2, 1fr); }
}
+172
View File
@@ -0,0 +1,172 @@
<template>
<div class="dashboard">
<div class="card" style="padding: var(--space-md) var(--space-lg);">
<div class="stats-row">
<div class="stat-item">
<span class="label">任务总数</span>
<span class="value">{{ stats.total }}</span>
</div>
<div class="stat-item">
<span class="label">执行中</span>
<span class="value" style="color: var(--c-warning);">{{ stats.running }}</span>
</div>
<div class="stat-item">
<span class="label">失败任务</span>
<span class="value" style="color: var(--c-danger);">{{ stats.failed }}</span>
</div>
<div class="stat-item">
<span class="label">系统状态</span>
<span
class="value"
:style="{ color: healthOk ? 'var(--c-success)' : 'var(--c-danger)' }"
>{{ healthOk ? '正常' : '异常' }}</span>
</div>
<div class="stat-item">
<span class="label">健康详情</span>
<span class="value-sub">{{ healthDetail }}</span>
</div>
</div>
</div>
<div class="grid-2" style="margin-top: var(--space-lg); flex: 1; min-height: 0;">
<div class="card" style="display:flex;flex-direction:column;">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
最近任务
</div>
<div style="flex:1;overflow-y:auto;min-height:0;">
<ul class="list">
<li v-for="t in recentTasks" :key="t.taskId" style="display:flex;align-items:flex-start;gap:8px;">
<div style="flex:1;min-width:0;">
<strong style="font-family:var(--font-mono);font-size:12px;">{{ t.type }}</strong>
<span :class="['tag', statusClass(t.status)]" style="margin-left:6px;">{{ t.status }}</span>
<br>
<span style="font-size:12px;color:var(--c-text-muted);">{{ t.message || '' }}</span>
</div>
<button
v-if="t.status === 'RUNNING' || t.status === 'PENDING'"
class="btn btn-sm btn-danger"
style="flex-shrink:0;"
@click="cancelTask(t.taskId)"
>取消</button>
</li>
<li v-if="!recentTasks.length" class="list-empty">暂无任务记录</li>
</ul>
</div>
</div>
<div class="card" style="display:flex;flex-direction:column;">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
最近文件
</div>
<div style="flex:1;overflow-y:auto;min-height:0;">
<ul class="list">
<li v-for="f in recentFiles" :key="f.path">
<a
href="#"
@click.prevent="downloadFile(f.path)"
style="font-family:var(--font-mono);font-size:12px;"
>{{ f.path }}</a>
<br>
<span style="font-size:11px;color:var(--c-text-muted);">{{ formatBytes(f.size) }}</span>
</li>
<li v-if="!recentFiles.length" class="list-empty">暂无输出文件</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch, downloadFile } = useApi()
const { toast } = useToast()
const tasks = ref([])
const files = ref([])
const health = ref(null)
let timer = null
const stats = computed(() => ({
total: tasks.value.length,
running: tasks.value.filter(t => t.status === 'RUNNING' || t.status === 'PENDING').length,
failed: tasks.value.filter(t => t.status === 'FAILED').length,
}))
const healthOk = computed(() => health.value?.outputDirWritable || false)
const healthDetail = computed(() => {
if (!health.value) return '健康状态暂不可用'
return `输出目录: ${health.value.outputDir} | 可写: ${health.value.outputDirWritable ? '是' : '否'} | API Key: ${health.value.apiKeyConfigured ? '已配置' : '未配置'}`
})
const recentTasks = computed(() => tasks.value.slice(0, 20))
const recentFiles = computed(() => files.value.slice(0, 20))
async function refresh() {
const [tasksRes, filesRes, healthRes] = await Promise.allSettled([
apiFetch('/api/tasks'),
apiFetch('/api/files'),
apiFetch('/api/health/details'),
])
if (tasksRes.status === 'fulfilled') {
const items = tasksRes.value || []
items.sort((a, b) => sortTime(b.createdAt, a.createdAt))
tasks.value = items
}
if (filesRes.status === 'fulfilled') {
const items = (filesRes.value?.files || []).slice()
items.sort((a, b) => sortTime(b.modifiedAt, a.modifiedAt))
files.value = items
}
if (healthRes.status === 'fulfilled') {
health.value = healthRes.value
}
}
function sortTime(a, b) {
return (new Date(a || 0)).getTime() - (new Date(b || 0)).getTime()
}
function formatBytes(bytes) {
if (bytes == null) return '-'
const u = ['B', 'KB', 'MB', 'GB']
let v = Number(bytes), i = 0
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`
}
function statusClass(s) {
return s === 'SUCCESS' ? 'tag-success' : s === 'RUNNING' || s === 'PENDING' ? 'tag-warning' : s === 'FAILED' ? 'tag-danger' : 'tag-muted'
}
async function cancelTask(taskId) {
if (!confirm('确定要取消此任务吗?')) return
try {
await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: 'POST' })
toast('任务已取消')
await refresh()
} catch (err) { toast(err.message, true) }
}
onMounted(() => {
refresh()
timer = setInterval(refresh, 8000)
document.addEventListener('visibilitychange', onVisibilityChange)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
document.removeEventListener('visibilitychange', onVisibilityChange)
})
function onVisibilityChange() {
if (document.hidden) {
if (timer) { clearInterval(timer); timer = null }
} else {
refresh()
if (!timer) timer = setInterval(refresh, 8000)
}
}
</script>
+170
View File
@@ -0,0 +1,170 @@
<template>
<div class="history">
<div class="card" style="margin-bottom:var(--space-lg);">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
任务列表
</div>
<div class="toolbar">
<select class="form-select" v-model="query.status" style="min-width:120px;" aria-label="状态筛选">
<option value="">全部状态</option>
<option v-for="s in statusOptions" :key="s" :value="s">{{ s }}</option>
</select>
<select class="form-select" v-model="query.type" style="min-width:120px;" aria-label="类型筛选">
<option value="">全部类型</option>
<option v-for="t in typeOptions" :key="t" :value="t">{{ t }}</option>
</select>
<input class="form-input" v-model="query.keyword" placeholder="搜索任务ID/信息" style="min-width:160px;flex:1;" aria-label="关键词搜索" spellcheck="false" />
<button class="btn btn-primary" @click="search">查询</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>任务ID</th>
<th>类型</th>
<th>状态</th>
<th>进度</th>
<th>说明</th>
<th>产物</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in page.items" :key="t.taskId">
<td style="font-family:var(--font-mono);font-size:12px;">{{ t.taskId.slice(0, 8) }}</td>
<td>{{ t.type }}</td>
<td><span :class="['tag', statusClass(t.status)]">{{ t.status }}</span></td>
<td><span style="font-family:var(--font-mono);">{{ t.progress || 0 }}%</span></td>
<td>
<div :title="t.message" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-text-secondary);">{{ t.message || '-' }}</div>
<div v-if="t.error" style="color:var(--c-danger);font-size:12px;margin-top:2px;">{{ t.error }}</div>
</td>
<td>
<span v-for="f in (t.files || [])" :key="f" style="display:block;">
<a href="#" @click.prevent="downloadFile(f)" style="font-size:12px;">{{ basename(f) }}</a>
</span>
<span v-if="!t.files?.length" style="color:var(--c-text-muted);">-</span>
</td>
<td>
<button v-if="t.status === 'RUNNING' || t.status === 'PENDING'" class="btn btn-sm btn-danger" @click="cancelTask(t.taskId)">取消</button>
<span v-else style="color:var(--c-text-muted);">-</span>
</td>
</tr>
<tr v-if="!page.items.length">
<td colspan="7" style="text-align:center;color:var(--c-text-muted);padding:32px;">暂无任务记录</td>
</tr>
</tbody>
</table>
</div>
<div class="pager">
<span> <strong style="color:var(--c-text);font-family:var(--font-mono);">{{ page.total }}</strong> 条记录 {{ page.page }} / {{ totalPages }} </span>
<div class="pager-actions">
<button class="btn btn-sm" :disabled="page.page <= 1" @click="goPage(page.page - 1)">上一页</button>
<button class="btn btn-sm" :disabled="page.page >= totalPages" @click="goPage(page.page + 1)">下一页</button>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
输出文件归档
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>文件路径</th>
<th>大小</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="f in files" :key="f.path">
<td style="word-break:break-all;font-family:var(--font-mono);font-size:12px;">{{ f.path }}</td>
<td style="font-family:var(--font-mono);font-variant-numeric:tabular-nums;">{{ formatBytes(f.size) }}</td>
<td style="color:var(--c-text-secondary);">{{ formatTime(f.modifiedAt) }}</td>
<td><a href="#" @click.prevent="downloadFile(f.path)">下载</a></td>
</tr>
<tr v-if="!files.length">
<td colspan="4" style="text-align:center;color:var(--c-text-muted);padding:32px;">暂无输出文件</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch, downloadFile } = useApi()
const { toast } = useToast()
const statusOptions = ['PENDING', 'RUNNING', 'SUCCESS', 'FAILED', 'CANCELLED']
const typeOptions = ['SVN_FETCH', 'AI_ANALYZE']
const query = ref({ status: '', type: '', keyword: '', page: 1, size: 10 })
const page = ref({ items: [], page: 1, size: 10, total: 0 })
const files = ref([])
const totalPages = computed(() => Math.max(1, Math.ceil((page.value.total || 0) / query.value.size)))
onMounted(() => { loadTasks(); loadFiles() })
function statusClass(s) {
return s === 'SUCCESS' ? 'tag-success' : s === 'RUNNING' || s === 'PENDING' ? 'tag-warning' : s === 'FAILED' ? 'tag-danger' : 'tag-muted'
}
function basename(p) { return (p || '').split('/').filter(Boolean).pop() || p }
function formatBytes(bytes) {
if (bytes == null) return '-'
const u = ['B', 'KB', 'MB', 'GB']
let v = Number(bytes), i = 0
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`
}
function formatTime(v) {
if (!v) return '-'
const d = new Date(v)
return isNaN(d.getTime()) ? '-' : d.toLocaleString('zh-CN', { hour12: false })
}
function search() { query.value.page = 1; loadTasks() }
async function loadTasks() {
const p = new URLSearchParams()
if (query.value.status) p.set('status', query.value.status)
if (query.value.type) p.set('type', query.value.type)
if (query.value.keyword) p.set('keyword', query.value.keyword)
p.set('page', String(query.value.page))
p.set('size', String(query.value.size))
try {
const data = await apiFetch(`/api/tasks/query?${p.toString()}`)
page.value = { items: data.items || [], page: data.page || 1, size: data.size || 10, total: data.total || 0 }
} catch (err) { toast(err.message, true) }
}
function goPage(p) { query.value.page = p; loadTasks() }
async function cancelTask(taskId) {
if (!confirm('确定要取消此任务吗?')) return
try {
await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: 'POST' })
toast('任务取消请求已处理')
loadTasks()
} catch (err) { toast(err.message, true) }
}
async function loadFiles() {
try {
const data = await apiFetch('/api/files')
files.value = data.files || []
} catch (err) { toast(err.message, true) }
}
</script>
+159
View File
@@ -0,0 +1,159 @@
<template>
<div class="settings">
<div class="card">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
系统配置
</div>
<form @submit.prevent="onSave">
<div class="form-grid span-all" style="gap:var(--space-lg);">
<div class="form-group span-all">
<label for="provider">AI 提供商</label>
<select id="provider" class="form-select" v-model="form.provider" style="max-width:300px;">
<option value="deepseek">DeepSeek</option>
<option value="openai-compatible">OpenAI 兼容</option>
</select>
</div>
<template v-if="form.provider === 'deepseek'">
<div class="form-group span-all">
<label for="apiKey">DeepSeek API Key</label>
<input id="apiKey" class="form-input" type="password" v-model="form.apiKey" placeholder="保存后写入本地 settings.json" autocomplete="off" />
</div>
</template>
<template v-if="form.provider === 'openai-compatible'">
<div class="form-group span-all">
<label for="openaiBaseUrl">OpenAI 兼容 Base URL</label>
<input id="openaiBaseUrl" class="form-input" v-model="form.openaiBaseUrl" placeholder="例如 http://127.0.0.1:5001/v1" autocomplete="off" />
</div>
<div class="form-group span-all">
<label for="openaiApiKey">OpenAI 兼容 API Key</label>
<input id="openaiApiKey" class="form-input" type="password" v-model="form.openaiApiKey" placeholder="保存后写入本地 settings.json" autocomplete="off" />
</div>
<div class="form-group">
<label for="stageOneModel">第一阶段模型</label>
<select id="stageOneModel" class="form-select" v-model="form.openaiStageOneModel">
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
</select>
</div>
<div class="form-group">
<label for="stageTwoModel">第二阶段模型</label>
<select id="stageTwoModel" class="form-select" v-model="form.openaiStageTwoModel">
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
</select>
</div>
</template>
<div class="form-group">
<label for="svnUsername">SVN 用户名</label>
<input id="svnUsername" class="form-input" v-model="form.svnUsername" placeholder="留空则继续使用已保存值" autocomplete="username" spellcheck="false" />
</div>
<div class="form-group">
<label for="svnPassword">SVN 密码</label>
<input id="svnPassword" class="form-input" type="password" v-model="form.svnPassword" placeholder="留空则不覆盖已保存密码" autocomplete="current-password" />
</div>
<div class="form-group span-all">
<label for="defaultPreset">默认 SVN 项目</label>
<select id="defaultPreset" class="form-select" v-model="form.defaultSvnPresetId" style="max-width:400px;">
<option v-for="p in presets" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="form-group span-all">
<label for="outputDir">输出目录</label>
<input id="outputDir" class="form-input" v-model="form.outputDir" placeholder="默认 outputs" autocomplete="off" />
</div>
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="spinner"></span>
保存系统设置
</button>
</div>
</form>
<div v-if="savedState" style="margin-top:var(--space-lg);padding-top:var(--space-md);border-top:1px solid var(--c-border-light);font-size:13px;color:var(--c-text-secondary);line-height:1.8;">
<div v-if="savedState.provider === 'openai-compatible'">
当前提供商: <strong style="color:var(--c-text);">OpenAI 兼容</strong><br>
Base URL: {{ savedState.openaiBaseUrl || '(未配置)' }}<br>
API Key: <span :style="{ color: savedState.openaiApiKeyConfigured ? 'var(--c-success)' : 'var(--c-warning)' }">{{ savedState.openaiApiKeyConfigured ? '已配置' : '未配置' }}</span><br>
Stage1: {{ savedState.openaiStageOneModel || '-' }}<br>
Stage2: {{ savedState.openaiStageTwoModel || '-' }}<br>
SVN: {{ renderSvnState(savedState) }}
</div>
<div v-else>
当前提供商: <strong style="color:var(--c-text);">DeepSeek</strong><br>
API Key: <span :style="{ color: savedState.apiKeyConfigured ? 'var(--c-success)' : 'var(--c-warning)' }">{{ savedState.apiKeyConfigured ? '已配置' : '未配置' }}</span> (来源: {{ savedState.apiKeySource }})<br>
SVN: {{ renderSvnState(savedState) }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch } = useApi()
const { toast } = useToast()
const form = ref({
provider: 'deepseek',
apiKey: '',
openaiBaseUrl: '',
openaiApiKey: '',
openaiStageOneModel: 'deepseek-v4-flash',
openaiStageTwoModel: 'deepseek-v4-pro',
svnUsername: '',
svnPassword: '',
outputDir: '',
defaultSvnPresetId: '',
})
const presets = ref([])
const savedState = ref(null)
const saving = ref(false)
onMounted(async () => {
try {
const [settingsData, presetsData] = await Promise.all([
apiFetch('/api/settings'),
apiFetch('/api/svn/presets'),
])
presets.value = presetsData.presets || []
form.value.provider = settingsData.provider || 'deepseek'
form.value.openaiBaseUrl = settingsData.openaiBaseUrl || ''
form.value.openaiStageOneModel = settingsData.openaiStageOneModel || 'deepseek-v4-flash'
form.value.openaiStageTwoModel = settingsData.openaiStageTwoModel || 'deepseek-v4-pro'
form.value.svnUsername = settingsData.svnUsername || ''
form.value.outputDir = settingsData.outputDir || ''
form.value.defaultSvnPresetId = settingsData.defaultSvnPresetId || (presets.value[0]?.id || '')
savedState.value = settingsData
} catch (err) { toast(err.message, true) }
})
async function onSave() {
saving.value = true
try {
const data = await apiFetch('/api/settings', { method: 'PUT', body: JSON.stringify(form.value) })
savedState.value = data
form.value.apiKey = ''
form.value.openaiApiKey = ''
form.value.svnPassword = ''
toast('设置保存成功')
} catch (err) { toast(err.message, true) }
finally { saving.value = false }
}
function renderSvnState(d) {
const user = d.svnUsername || '(未配置)'
const configured = d.svnCredentialsConfigured ? '已配置' : '未配置'
return `${user} / ${configured}`
}
</script>
+378
View File
@@ -0,0 +1,378 @@
<template>
<div class="svn-fetch">
<div class="card">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
SVN 批量抓取参数
</div>
<div class="alert alert-info" style="margin-bottom:var(--space-md);">
默认已填充 3 个常用项目路径可选择月份自动填充版本号或手动填写
</div>
<div style="padding:var(--space-md);background:rgba(15,23,42,0.4);border-radius:var(--radius-md);margin-bottom:var(--space-lg);border:1px solid var(--c-border-light);">
<h4 style="font-size:13px;font-weight:600;margin-bottom:12px;color:var(--c-text);">智能版本号辅助</h4>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select v-model="rangeType" class="form-select" style="width:80px;" aria-label="范围类型">
<option value="month"></option>
<option value="week"></option>
<option value="date"></option>
</select>
<input v-if="rangeType === 'month'" type="month" v-model="dateValue" class="form-input" style="max-width:200px;" aria-label="选择月份" />
<input v-if="rangeType === 'week'" type="week" v-model="dateValue" class="form-input" style="max-width:200px;" aria-label="选择周" />
<input v-if="rangeType === 'date'" type="date" v-model="dateValue" class="form-input" style="max-width:200px;" aria-label="选择日期" />
<button type="button" class="btn btn-primary" @click="autoFillVersions" :disabled="autoFillLoading">
<span v-if="autoFillLoading" class="spinner"></span>
自动计算并填充
</button>
</div>
</div>
<form @submit.prevent="onRunSvn">
<div class="form-grid" style="gap:var(--space-lg);">
<div class="span-all project-block" v-for="(proj, idx) in projects" :key="idx">
<h4>{{ proj.name }}</h4>
<div class="form-grid">
<div class="form-group">
<label :for="'start-'+idx">开始版本号</label>
<input :id="'start-'+idx" class="form-input" v-model="proj.startRevision" inputmode="numeric" placeholder="请输入开始版本" autocomplete="off" />
</div>
<div class="form-group">
<label :for="'end-'+idx">结束版本号</label>
<input :id="'end-'+idx" class="form-input" v-model="proj.endRevision" inputmode="numeric" placeholder="请输入结束版本" autocomplete="off" />
</div>
</div>
</div>
<div class="form-group">
<label for="filterUser">过滤用户名</label>
<input id="filterUser" class="form-input" v-model="filterUser" placeholder="包含匹配,留空不过滤" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="period">工作周期</label>
<input id="period" class="form-input" v-model="period" placeholder="例如 2026年03月" autocomplete="off" />
</div>
<div class="form-group span-all">
<label for="outputFileName">输出文件名</label>
<input id="outputFileName" class="form-input" v-model="outputFileName" placeholder="例如 202603工作量统计.xlsx" autocomplete="off" />
</div>
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="button" class="btn" @click="onTestConnection" :disabled="testing" aria-label="测试 SVN 连接">
<span v-if="testing" class="spinner"></span>
测试连接
</button>
<button type="submit" class="btn btn-primary" :disabled="running">
<span v-if="running" class="spinner"></span>
一键抓取并生成 Excel
</button>
</div>
</form>
</div>
<div class="card" v-show="showLogPanel" style="margin-top:var(--space-lg);">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
执行进度面板
</div>
<div class="log-pane-3">
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--c-text-muted);margin-bottom:6px;">AI 思考过程</div>
<div ref="reasoningPane" class="log-panel" style="height:250px;" role="log" aria-live="polite">
<div class="log-panel-dots"><span class="dot-red"></span><span class="dot-yellow"></span><span class="dot-green"></span></div>
<div v-if="!reasoningLines.length" class="log-line log-muted">等待思考输出...</div>
<div v-for="(line, i) in reasoningLines" :key="i" :class="['log-line', line.cls || 'log-reasoning']">{{ line.text }}</div>
</div>
</div>
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--c-text-muted);margin-bottom:6px;">最终分析输出</div>
<div ref="answerPane" class="log-panel" style="height:250px;" role="log" aria-live="polite">
<div class="log-panel-dots"><span class="dot-red"></span><span class="dot-yellow"></span><span class="dot-green"></span></div>
<div v-if="!answerLines.length" class="log-line log-muted">等待答案输出...</div>
<div v-for="(line, i) in answerLines" :key="i" :class="['log-line', line.cls || 'log-answer']">{{ line.text }}</div>
</div>
</div>
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--c-text-muted);margin-bottom:6px;">系统控制台</div>
<div ref="syslogPane" class="log-panel" style="height:180px;" role="log" aria-live="polite">
<div class="log-panel-dots"><span class="dot-red"></span><span class="dot-yellow"></span><span class="dot-green"></span></div>
<div v-if="!syslogLines.length" class="log-line log-muted">等待任务开始...</div>
<div v-for="(line, i) in syslogLines" :key="i" :class="['log-line', line.cls || 'log-info']">{{ line.text }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch, downloadFile } = useApi()
const { toast } = useToast()
const presets = ref([])
const defaultPresetId = ref('')
const testing = ref(false)
const running = ref(false)
const autoFillLoading = ref(false)
const showLogPanel = ref(false)
const rangeType = ref('month')
const dateValue = ref('')
const filterUser = ref('liujing')
const period = ref('')
const outputFileName = ref('')
const projects = ref([])
const syslogLines = ref([])
const reasoningLines = ref([])
const answerLines = ref([])
const reasoningPane = ref(null)
const answerPane = ref(null)
const syslogPane = ref(null)
function appendSyslog(msg, isError = false) {
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
syslogLines.value.push({ text: `[${time}] ${isError ? '!' : ''} ${msg}`, cls: isError ? 'log-error' : 'log-info' })
scrollPane(syslogPane)
}
function appendReasoning(text) {
reasoningLines.value.push({ text, cls: 'log-reasoning' })
scrollPane(reasoningPane)
}
function appendAnswer(text) {
answerLines.value.push({ text, cls: 'log-answer' })
scrollPane(answerPane)
}
function clearLogs() {
syslogLines.value = []
reasoningLines.value = []
answerLines.value = []
}
function scrollPane(refEl) {
nextTick(() => { if (refEl.value) refEl.value.scrollTop = refEl.value.scrollHeight })
}
onMounted(async () => {
try {
const data = await apiFetch('/api/svn/presets')
presets.value = data.presets || []
defaultPresetId.value = data.defaultPresetId || ''
projects.value = (data.presets || []).map(p => ({
presetId: p.id,
name: p.name,
startRevision: '',
endRevision: '',
}))
} catch (err) { toast(err.message, true) }
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
dateValue.value = `${y}-${m}`
period.value = `${y}${m}`
outputFileName.value = `${y}${m}工作量统计.xlsx`
})
function getFormProjects() {
return projects.value.filter(p => p.startRevision && p.endRevision).map(p => ({ ...p }))
}
async function onTestConnection() {
if (!presets.value.length) { toast('未加载到 SVN 预设', true); return }
testing.value = true
try {
const pid = defaultPresetId.value && presets.value.some(p => p.id === defaultPresetId.value)
? defaultPresetId.value : presets.value[0].id
await apiFetch('/api/svn/test-connection', { method: 'POST', body: JSON.stringify({ presetId: pid }) })
toast('SVN 连接成功')
} catch (err) { toast(err.message, true) }
finally { testing.value = false }
}
async function autoFillVersions() {
if (!presets.value.length) { toast('未加载到 SVN 预设', true); return }
const rt = rangeType.value
const dv = dateValue.value
if (!dv) { toast('请选择日期范围', true); return }
let logPrefix = ''
let body
if (rt === 'month') {
const [y, m] = dv.split('-')
if (!y || !m) { toast('请选择月份', true); return }
logPrefix = `${y}${m}`
body = { presetId: '', year: parseInt(y, 10), month: parseInt(m, 10) }
} else if (rt === 'week') {
logPrefix = dv
body = { presetId: '', rangeType: 'week', week: dv }
} else {
logPrefix = dv
body = { presetId: '', rangeType: 'date', date: dv }
}
autoFillLoading.value = true
showLogPanel.value = true
clearLogs()
appendSyslog(`开始查询 ${logPrefix} 的版本范围...`)
try {
for (let i = 0; i < presets.value.length; i++) {
const proj = projects.value[i]
appendSyslog(`正在查询 ${proj.name} 的版本范围...`)
const data = await apiFetch('/api/svn/version-range', {
method: 'POST',
body: JSON.stringify({ ...body, presetId: proj.presetId }),
})
if (data.startRevision && data.endRevision) {
proj.startRevision = String(data.startRevision)
proj.endRevision = String(data.endRevision)
appendSyslog(`${proj.name} 版本范围: ${data.startRevision} - ${data.endRevision}`)
} else {
appendSyslog(`${proj.name} 该范围无提交记录`, true)
}
}
appendSyslog('所有项目版本号填充完成')
toast('版本号填充完成')
} catch (err) { appendSyslog(`填充失败: ${err.message}`, true); toast(err.message, true) }
finally { autoFillLoading.value = false }
}
async function onRunSvn() {
if (!presets.value.length) { toast('SVN 预设加载异常', true); return }
const formProjects = getFormProjects()
if (!formProjects.length) { toast('请至少填写一个项目的开始和结束版本号', true); return }
showLogPanel.value = true
clearLogs()
running.value = true
appendSyslog('任务开始...')
let aiStream = null
try {
const mdFiles = []
for (let i = 0; i < formProjects.length; i++) {
const proj = formProjects[i]
appendSyslog(`正在提交 ${proj.name} 的抓取任务...`)
const data = await apiFetch('/api/svn/fetch', {
method: 'POST',
body: JSON.stringify({ presetId: proj.presetId, startRevision: toNum(proj.startRevision), endRevision: toNum(proj.endRevision), filterUser: filterUser.value || '' }),
})
const taskId = data.taskId
appendSyslog(`已创建抓取任务: ${proj.name} (${taskId.slice(0,8)})`)
while (true) {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`)
if (task.status === 'SUCCESS') {
appendSyslog(`${proj.name} 抓取完成`)
if (task.files) mdFiles.push(...task.files.filter(f => f.endsWith('.md')))
break
}
if (task.status === 'FAILED' || task.status === 'CANCELLED') {
throw new Error(`${proj.name} 抓取失败: ${task.error || task.message}`)
}
if (task.message) appendSyslog(`[${proj.name}] ${task.message}`)
await sleep(2000)
}
}
appendSyslog(`所有 SVN 抓取完成,共 ${mdFiles.length} 个文件`)
appendSyslog('正在提交 AI 分析任务...')
const aiData = await apiFetch('/api/ai/analyze', {
method: 'POST',
body: JSON.stringify({ filePaths: mdFiles, period: period.value || '', apiKey: '', outputFileName: outputFileName.value || '' }),
})
appendSyslog(`AI 分析任务已创建 (${aiData.taskId.slice(0,8)})`)
const streamReady = { reasoningLen: 0, answerLen: 0 }
const source = openStream(aiData.taskId, streamReady)
while (true) {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`)
syncAiOutput(task, streamReady)
if (task.status === 'SUCCESS') {
appendSyslog('AI 分析完成')
syncAiOutput(task, streamReady)
source?.close()
break
}
if (task.status === 'FAILED' || task.status === 'CANCELLED') {
source?.close()
throw new Error(`AI 分析失败: ${task.error || task.message}`)
}
if (task.message) appendSyslog(task.message)
await sleep(1000)
}
const finalTask = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`)
if (finalTask.files) {
const excel = finalTask.files.find(f => f.endsWith('.xlsx'))
if (excel) {
appendSyslog('Excel 生成成功,开始下载...')
await downloadFile(excel)
appendSyslog('任务全部完成!')
}
}
toast('任务全部完成')
aiStream = source
} catch (err) {
appendSyslog(`错误: ${err.message}`, true)
toast(err.message, true)
} finally {
if (aiStream) aiStream.close()
running.value = false
}
}
function openStream(taskId, state) {
if (!window.EventSource) return { close() {} }
const url = `/api/tasks/${encodeURIComponent(taskId)}/stream`
let src = null
let reconnectAttempts = 0
const MAX_RECONNECT = 3
let closed = false
function connect() {
if (closed) return
src = new EventSource(url)
const onEvent = (event, handler) => {
src.addEventListener(event, e => {
try { const d = JSON.parse(e.data); handler(d) } catch {}
})
}
onEvent('reasoning_delta', d => { if (d.text) { appendReasoning(d.text); state.reasoningLen += d.text.length } })
onEvent('answer_delta', d => { if (d.text) { appendAnswer(d.text); state.answerLen += d.text.length } })
onEvent('phase', d => { if (d.message) appendSyslog(d.message) })
onEvent('usage', d => { appendSyslog(`Token: prompt=${d.promptTokens || 0} / completion=${d.completionTokens || 0} / total=${d.totalTokens || 0}`) })
onEvent('done', () => { closed = true; appendSyslog('SSE 流结束'); src.close() })
src.onerror = () => {
src.close()
if (closed) return
if (reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++
appendSyslog(`SSE 连接断开,${reconnectAttempts}/${MAX_RECONNECT} 次重连...`, true)
setTimeout(connect, 2000)
} else {
closed = true
appendSyslog('SSE 重连失败,切换为轮询模式', true)
}
}
}
connect()
return { close() { closed = true; if (src) src.close() } }
}
function syncAiOutput(task, state) {
if (!task) return
const reasoning = task.aiReasoningText || ''
const answer = task.aiAnswerText || ''
if (reasoning.length > state.reasoningLen) {
const delta = reasoning.slice(state.reasoningLen)
if (delta) { appendReasoning(delta); state.reasoningLen = reasoning.length }
}
if (answer.length > state.answerLen) {
const delta = answer.slice(state.answerLen)
if (delta) { appendAnswer(delta); state.answerLen = answer.length }
}
}
function toNum(v) { const n = Number(v); return Number.isFinite(n) ? n : null }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
</script>
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
base: '/v2/',
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: resolve(__dirname, '..', 'src', 'main', 'resources', 'static', 'v2'),
emptyOutDir: true,
},
})
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">
<!--
阿里云 Maven 镜像配置
将 Maven Central 所有请求代理到阿里云国内镜像,大幅加速依赖下载
-->
<mirrors>
<mirror>
<id>aliyun-maven</id>
<name>阿里云 Maven 镜像 (Aliyun Maven Mirror)</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
</settings>
@@ -24,7 +24,7 @@ import org.tmatesoft.svn.core.wc.SVNWCUtil;
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 TimeZone RANGE_TIME_ZONE = TimeZone.getTimeZone("Asia/Shanghai");
private static final long DEFAULT_BOUNDARY_PADDING = 50L;
private static final long FALLBACK_SCAN_PADDING = 2000L;
@@ -138,16 +138,33 @@ public class SVNLogFetcher {
}
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);
final Calendar startCal = Calendar.getInstance(RANGE_TIME_ZONE);
startCal.set(year, month - 1, 1, 0, 0, 0);
startCal.set(Calendar.MILLISECOND, 0);
final long monthStart = startCal.getTimeInMillis();
final Calendar nextMonthCal = (Calendar) startCal.clone();
nextMonthCal.add(Calendar.MONTH, 1);
final long nextMonthStart = nextMonthCal.getTimeInMillis();
return getVersionRangeByTimeRange(startCal.getTime(), nextMonthCal.getTime(), traceId, "month");
}
/**
* 获取指定时间范围的版本范围(基于时间边界,不过滤用户)
*
* @param rangeStartInclusive 开始时间(包含)
* @param rangeEndExclusive 结束时间(不包含)
* @param traceId 追踪ID
* @param rangeType 范围类型
* @return 数组 [startRevision, endRevision],如果范围内无提交返回null
* @throws SVNException SVN异常
*/
public long[] getVersionRangeByTimeRange(Date rangeStartInclusive,
Date rangeEndExclusive,
String traceId,
String rangeType) throws SVNException {
final String trace = traceId == null ? "" : traceId;
final long rangeStart = rangeStartInclusive.getTime();
final long rangeEnd = rangeEndExclusive.getTime();
final long latestRevision = getLatestRevision();
if (latestRevision < 1L) {
@@ -155,17 +172,16 @@ public class SVNLogFetcher {
return null;
}
final long startAnchor = repository.getDatedRevision(new Date(monthStart));
final long endAnchor = repository.getDatedRevision(new Date(nextMonthStart - 1L));
final long startAnchor = repository.getDatedRevision(rangeStartInclusive);
final long endAnchor = repository.getDatedRevision(new Date(rangeEnd - 1L));
LOGGER.info(
"[SVN_VERSION_RANGE][FETCHER] traceId={} queryMonth={}-{} tz={} monthStart={} nextMonthStart={} latestRevision={} startAnchor={} endAnchor={}",
"[SVN_VERSION_RANGE][FETCHER] traceId={} rangeType={} tz={} rangeStart={} rangeEnd={} latestRevision={} startAnchor={} endAnchor={}",
trace,
year,
month,
MONTH_TIME_ZONE.getID(),
formatDate(new Date(monthStart)),
formatDate(new Date(nextMonthStart)),
rangeType,
RANGE_TIME_ZONE.getID(),
formatDate(rangeStartInclusive),
formatDate(rangeEndExclusive),
latestRevision,
startAnchor,
endAnchor
@@ -179,8 +195,8 @@ public class SVNLogFetcher {
long[] exactRange = findRangeInWindow(
Math.max(1L, startAnchor - DEFAULT_BOUNDARY_PADDING),
Math.min(latestRevision, endAnchor + DEFAULT_BOUNDARY_PADDING),
monthStart,
nextMonthStart,
rangeStart,
rangeEnd,
trace,
"primary"
);
@@ -194,8 +210,8 @@ public class SVNLogFetcher {
exactRange = findRangeInWindow(
Math.max(1L, startAnchor - FALLBACK_SCAN_PADDING),
Math.min(latestRevision, endAnchor + FALLBACK_SCAN_PADDING),
monthStart,
nextMonthStart,
rangeStart,
rangeEnd,
trace,
"fallback"
);
@@ -211,8 +227,8 @@ public class SVNLogFetcher {
private long[] findRangeInWindow(long fromRevision,
long toRevision,
long monthStart,
long nextMonthStart,
long rangeStart,
long rangeEnd,
String trace,
String strategyTag) throws SVNException {
if (fromRevision > toRevision) {
@@ -247,7 +263,7 @@ public class SVNLogFetcher {
}
long logTime = logDate.getTime();
if (logTime >= monthStart && logTime < nextMonthStart) {
if (logTime >= rangeStart && logTime < rangeEnd) {
long revision = entry.getRevision();
if (revision < minRevision) {
minRevision = revision;
@@ -8,12 +8,16 @@ import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.security.cert.X509Certificate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.svnlog")
public class WebApplication {
private static final Logger LOGGER = LoggerFactory.getLogger(WebApplication.class);
static {
// 配置 Java 全局 SSL 上下文(用于内网 SVN 服务器)
try {
@@ -26,7 +30,7 @@ public class WebApplication {
.replaceAll(",\\s*TLSv1\\.1", "")
.replaceAll(",\\s*TLSv1", "");
java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms);
System.out.println("TLS configuration updated: " + disabledAlgorithms);
LOGGER.info("TLS configuration updated: {}", disabledAlgorithms);
}
// 配置信任所有证书的 SSL 上下文
@@ -54,9 +58,9 @@ public class WebApplication {
}
});
System.out.println("SSL context configured to trust all certificates");
LOGGER.info("SSL context configured to trust all certificates");
} catch (Exception e) {
System.err.println("Warning: Failed to configure SSL context: " + e.getMessage());
LOGGER.warn("Failed to configure SSL context: {}", e.getMessage());
}
// 配置 TLS 协议版本
@@ -95,7 +95,7 @@ public class AppController {
}
/**
* 查询指定月份的SVN版本范围
* 查询指定时间范围的SVN版本范围
*/
@PostMapping("/svn/version-range")
public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception {
@@ -104,19 +104,21 @@ public class AppController {
final String url = preset.getUrl();
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
request.getPassword(),
request.getPresetId()
);
final int year = request.getYear().intValue();
final int month = request.getMonth().intValue();
LOGGER.info(
"[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} year={} month={} username={} password={}",
"[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} rangeType={} year={} month={} date={} week={} username={} password={}",
traceId,
request.getPresetId(),
preset.getName(),
url,
year,
month,
request.getRangeType(),
request.getYear(),
request.getMonth(),
request.getDate(),
request.getWeek(),
credentials.getUsername(),
maskPassword(credentials.getPassword())
);
@@ -127,8 +129,11 @@ public class AppController {
response.put("presetId", request.getPresetId());
response.put("presetName", preset.getName());
response.put("resolvedSvnUrl", url);
response.put("year", year);
response.put("month", month);
response.put("rangeType", request.getRangeType());
response.put("year", request.getYear());
response.put("month", request.getMonth());
response.put("date", request.getDate());
response.put("week", request.getWeek());
response.put("traceId", traceId);
if (range != null) {
response.put("startRevision", range[0]);
@@ -14,6 +14,20 @@ public class IndexController {
@GetMapping(value = {"/", "/index.html"})
public ResponseEntity<Resource> index() {
return htmlResponse("static/index.html");
}
@GetMapping("/v2")
public String v2Redirect() {
return "redirect:/v2/";
}
@GetMapping("/v2/")
public ResponseEntity<Resource> v2Index() {
return htmlResponse("static/v2/index.html");
}
private ResponseEntity<Resource> htmlResponse(String classpath) {
final HttpHeaders headers = new HttpHeaders();
headers.setCacheControl(CacheControl.noStore().mustRevalidate().getHeaderValue());
headers.add(HttpHeaders.PRAGMA, "no-cache");
@@ -21,6 +35,6 @@ public class IndexController {
return ResponseEntity.ok()
.headers(headers)
.contentType(MediaType.TEXT_HTML)
.body(new ClassPathResource("static/index.html"));
.body(new ClassPathResource(classpath));
}
}
@@ -1,7 +1,6 @@
package com.svnlog.web.dto;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public class SvnVersionRangeRequest {
@@ -12,11 +11,16 @@ public class SvnVersionRangeRequest {
private String password;
@NotNull
private Integer year;
@NotNull
private Integer month;
private String rangeType;
private String date;
private String week;
private String clientTraceId;
public String getPresetId() {
@@ -59,6 +63,30 @@ public class SvnVersionRangeRequest {
this.month = month;
}
public String getRangeType() {
return rangeType;
}
public void setRangeType(String rangeType) {
this.rangeType = rangeType;
}
public String getDate() {
return date;
}
public void setDate(String date) {
this.date = date;
}
public String getWeek() {
return week;
}
public void setWeek(String week) {
this.week = week;
}
public String getClientTraceId() {
return clientTraceId;
}
@@ -0,0 +1,98 @@
package com.svnlog.web.model;
import java.util.UUID;
public class RepositoryConfig {
private String id;
private String name;
private String type;
private boolean enabled;
private String svnUrl;
private String svnUsername;
private String svnPasswordEncrypted;
private long createdAt;
private long lastUsedAt;
public RepositoryConfig() {
this.id = UUID.randomUUID().toString();
this.enabled = true;
this.createdAt = System.currentTimeMillis();
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getSvnUrl() {
return svnUrl;
}
public void setSvnUrl(String svnUrl) {
this.svnUrl = svnUrl;
}
public String getSvnUsername() {
return svnUsername;
}
public void setSvnUsername(String svnUsername) {
this.svnUsername = svnUsername;
}
public String getSvnPasswordEncrypted() {
return svnPasswordEncrypted;
}
public void setSvnPasswordEncrypted(String svnPasswordEncrypted) {
this.svnPasswordEncrypted = svnPasswordEncrypted;
}
public long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(long createdAt) {
this.createdAt = createdAt;
}
public long getLastUsedAt() {
return lastUsedAt;
}
public void setLastUsedAt(long lastUsedAt) {
this.lastUsedAt = lastUsedAt;
}
public boolean isSvn() {
return "SVN".equalsIgnoreCase(type);
}
}
@@ -0,0 +1,410 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
@Service
public class AiApiService {
private static final Logger LOGGER = LoggerFactory.getLogger(AiApiService.class);
static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
private static final String DEEPSEEK_MODEL_CHAT = "deepseek-chat";
private static final String DEEPSEEK_MODEL_THINK = "deepseek-reasoner";
private static final int DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY = 8000;
private static final int DEEPSEEK_CHAT_MAX_TOKENS_RETRY = 8000;
private static final int DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY = 64000;
private static final int DEEPSEEK_REASONER_MAX_TOKENS_RETRY = 64000;
private static final int STREAM_PERSIST_INTERVAL = 8;
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.readTimeout(180, TimeUnit.SECONDS)
.build();
private final SettingsService settingsService;
private final RetrySupport retrySupport = new RetrySupport();
public AiApiService(SettingsService settingsService) {
this.settingsService = settingsService;
}
public String callAi(AiProviderContext providerContext,
String prompt,
TaskContext context,
int stageNumber) throws IOException {
final String modelName = stageNumber == 2
? providerContext.stageTwoModel
: providerContext.stageOneModel;
final int primaryMaxTokens = stageNumber == 2
? DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY
: DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY;
final int retryMaxTokens = stageNumber == 2
? DEEPSEEK_REASONER_MAX_TOKENS_RETRY
: DEEPSEEK_CHAT_MAX_TOKENS_RETRY;
try {
final AiStreamResult primary = retrySupport.execute(
() -> callAiOnce(providerContext, prompt, context, modelName, primaryMaxTokens),
3,
1000L
);
if (!"length".equalsIgnoreCase(primary.finishReason)) {
return primary.answer;
}
if (isValidJsonObjectText(primary.answer)) {
LOGGER.warn("DeepSeek finish_reason=length, but JSON is complete; using primary response");
return primary.answer;
}
context.emitEvent("phase", buildEventPayload(
providerContext.displayName
+ "(" + modelName + ") 输出长度触顶,自动重试(max_tokens=" + retryMaxTokens + ""
));
final AiStreamResult retried = retrySupport.execute(
() -> callAiOnce(providerContext, prompt, context, modelName, retryMaxTokens),
2,
1200L
);
if ("length".equalsIgnoreCase(retried.finishReason) && !isValidJsonObjectText(retried.answer)) {
throw new IllegalStateException(
providerContext.displayName + " 输出被截断(finish_reason=length),请缩短输入日志范围后重试"
);
}
return retried.answer;
} catch (IOException e) {
context.setAiStreamStatus("FALLBACK");
throw e;
} catch (Exception e) {
context.setAiStreamStatus("FALLBACK");
throw new IOException(e.getMessage(), e);
}
}
private AiStreamResult callAiOnce(AiProviderContext providerContext,
String prompt,
TaskContext context,
String model,
int maxTokens) throws Exception {
final JsonObject message = new JsonObject();
message.addProperty("role", "user");
message.addProperty("content", prompt);
final JsonArray messages = new JsonArray();
messages.add(message);
final JsonObject body = new JsonObject();
body.addProperty("model", model);
body.add("messages", messages);
body.addProperty("max_tokens", maxTokens);
body.addProperty("stream", true);
final JsonObject responseFormat = new JsonObject();
responseFormat.addProperty("type", "json_object");
body.add("response_format", responseFormat);
final JsonObject streamOptions = new JsonObject();
streamOptions.addProperty("include_usage", true);
body.add("stream_options", streamOptions);
final Request request = new Request.Builder()
.url(providerContext.apiUrl)
.addHeader("Authorization", "Bearer " + providerContext.apiKey)
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
.build();
try (okhttp3.Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
String errorBody = "";
if (response.body() != null) {
errorBody = response.body().string();
}
String detail = providerContext.displayName + " API 调用失败: " + response.code() + " " + errorBody;
if (response.code() == 429 || response.code() >= 500) {
throw new RetrySupport.RetryableException(detail);
}
throw new IllegalStateException(detail);
}
if (response.body() == null) {
throw new RetrySupport.RetryableException(providerContext.displayName + " API 返回空响应体");
}
final okhttp3.ResponseBody responseBody = response.body();
return readStreamingResponse(responseBody.source(), context, providerContext, model, maxTokens);
}
}
AiStreamResult readStreamingResponse(okio.BufferedSource source,
TaskContext context,
AiProviderContext providerContext,
String model,
int maxTokens) throws Exception {
final StringBuilder answerBuilder = new StringBuilder();
final StringBuilder reasoningBuilder = new StringBuilder();
String finishReason = "";
int reasoningDeltaCount = 0;
int answerDeltaCount = 0;
Long usagePromptTokens = null;
Long usageCompletionTokens = null;
Long usageTotalTokens = null;
String finalMessageContent = "";
context.emitEvent("phase", buildEventPayload("正在流式接收 " + providerContext.displayName + " 输出"));
while (!source.exhausted()) {
final String line = source.readUtf8Line();
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
continue;
}
final String dataLine = line.substring(5).trim();
if ("[DONE]".equals(dataLine)) {
break;
}
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
if (data.has("usage") && data.get("usage").isJsonObject()) {
final JsonObject usage = data.getAsJsonObject("usage");
usagePromptTokens = optLong(usage, "prompt_tokens");
usageCompletionTokens = optLong(usage, "completion_tokens");
usageTotalTokens = optLong(usage, "total_tokens");
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
usagePayload.put("promptTokens", usagePromptTokens);
usagePayload.put("completionTokens", usageCompletionTokens);
usagePayload.put("totalTokens", usageTotalTokens);
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
context.emitEvent("usage", usagePayload);
}
final JsonArray choices = data.getAsJsonArray("choices");
if (choices == null || choices.size() == 0) {
continue;
}
final JsonObject first = choices.get(0).getAsJsonObject();
if (first.has("message") && first.get("message").isJsonObject()) {
final String content = optString(first.getAsJsonObject("message"), "content");
if (content != null && !content.trim().isEmpty()) {
finalMessageContent = content.trim();
}
}
if (first.has("delta") && first.get("delta").isJsonObject()) {
final JsonObject delta = first.getAsJsonObject("delta");
final String reasoning = optString(delta, "reasoning_content");
if (reasoning != null && !reasoning.isEmpty()) {
reasoningDeltaCount++;
reasoningBuilder.append(reasoning);
context.emitEvent("reasoning_delta", buildTextPayload(reasoning));
if (reasoningDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
}
}
final String answer = optString(delta, "content");
if (answer != null && !answer.isEmpty()) {
answerDeltaCount++;
answerBuilder.append(answer);
context.emitEvent("answer_delta", buildTextPayload(answer));
if (answerDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
}
}
}
if (first.has("finish_reason") && !first.get("finish_reason").isJsonNull()) {
finishReason = first.get("finish_reason").getAsString();
}
}
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
throw new RetrySupport.RetryableException(providerContext.displayName + " 资源不足,请稍后重试");
}
String answer = answerBuilder.toString().trim();
if (answer.isEmpty() && finalMessageContent != null && !finalMessageContent.isEmpty()) {
answer = finalMessageContent;
}
if (answer.isEmpty()) {
throw new IllegalStateException(
providerContext.displayName + " 未返回有效 content 内容"
+ " | stage_model=" + model
+ " | finish_reason=" + finishReason
+ " | prompt_tokens=" + usagePromptTokens
+ " | completion_tokens=" + usageCompletionTokens
+ " | total_tokens=" + usageTotalTokens
);
}
context.updateAiOutput(reasoningBuilder.toString(), answer);
context.setAiStreamStatus("DONE");
LOGGER.info(
"{} stream deltas: model={}, reasoning={}, answer={}, finishReason={}, maxTokens={}",
providerContext.displayName,
model,
reasoningDeltaCount,
answerDeltaCount,
finishReason,
maxTokens
);
return new AiStreamResult(answer, finishReason);
}
public AiProviderContext resolveProviderContext(String requestApiKey) {
final String provider = settingsService.getProvider();
if (SettingsService.PROVIDER_OPENAI_COMPATIBLE.equals(provider)) {
final String baseUrl = settingsService.getOpenaiBaseUrl();
final String apiKey = settingsService.getOpenaiApiKey();
if (baseUrl == null || baseUrl.trim().isEmpty()) {
throw new IllegalStateException("未配置 OpenAI兼容 Base URL(请先在系统设置中保存)");
}
if (apiKey == null || apiKey.trim().isEmpty()) {
throw new IllegalStateException("未配置 OpenAI兼容 API Key(请先在系统设置中保存)");
}
return new AiProviderContext(
provider,
"OpenAI兼容",
normalizeChatCompletionsUrl(baseUrl),
apiKey.trim(),
settingsService.getOpenaiStageOneModel(),
settingsService.getOpenaiStageTwoModel()
);
}
final String apiKey = settingsService.pickActiveKey(requestApiKey);
if (apiKey == null || apiKey.trim().isEmpty()) {
throw new IllegalStateException("未配置 DeepSeek API Key(可在设置页配置或请求中传入)");
}
return new AiProviderContext(
SettingsService.PROVIDER_DEEPSEEK,
"DeepSeek",
DEEPSEEK_API_URL,
apiKey.trim(),
DEEPSEEK_MODEL_CHAT,
DEEPSEEK_MODEL_THINK
);
}
String normalizeChatCompletionsUrl(String baseUrl) {
final String normalized = baseUrl == null ? "" : baseUrl.trim();
if (normalized.isEmpty()) {
return "";
}
String value = normalized;
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
if (value.endsWith("/chat/completions")) {
return value;
}
return value + "/chat/completions";
}
Map<String, Object> buildTextPayload(String text) {
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("text", text);
return payload;
}
Map<String, Object> buildEventPayload(String message) {
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("message", message);
return payload;
}
private Long optLong(JsonObject object, String key) {
if (object == null || key == null || !object.has(key) || object.get(key).isJsonNull()) {
return null;
}
try {
return Long.valueOf(object.get(key).getAsLong());
} catch (Exception ignored) {
return null;
}
}
JsonObject extractJson(String rawResponse) {
String trimmed = rawResponse == null ? "" : rawResponse.trim();
if (trimmed.startsWith("```json")) {
trimmed = trimmed.substring(7).trim();
} else if (trimmed.startsWith("```")) {
trimmed = trimmed.substring(3).trim();
}
if (trimmed.endsWith("```")) {
trimmed = trimmed.substring(0, trimmed.length() - 3).trim();
}
return JsonParser.parseString(trimmed).getAsJsonObject();
}
private boolean isValidJsonObjectText(String text) {
if (text == null || text.trim().isEmpty()) {
return false;
}
try {
extractJson(text);
return true;
} catch (Exception ignored) {
return false;
}
}
String optString(JsonObject object, String key) {
if (object == null || !object.has(key) || object.get(key).isJsonNull()) {
return "";
}
return object.get(key).getAsString();
}
static final class AiProviderContext {
private final String provider;
private final String displayName;
private final String apiUrl;
private final String apiKey;
private final String stageOneModel;
private final String stageTwoModel;
AiProviderContext(String provider,
String displayName,
String apiUrl,
String apiKey,
String stageOneModel,
String stageTwoModel) {
this.provider = provider == null ? "" : provider.trim();
this.displayName = displayName == null ? "" : displayName.trim();
this.apiUrl = apiUrl == null ? "" : apiUrl.trim();
this.apiKey = apiKey == null ? "" : apiKey.trim();
this.stageOneModel = stageOneModel == null ? "" : stageOneModel.trim();
this.stageTwoModel = stageTwoModel == null ? "" : stageTwoModel.trim();
}
String getProvider() { return provider; }
String getApiUrl() { return apiUrl; }
String getStageOneModel() { return stageOneModel; }
String getStageTwoModel() { return stageTwoModel; }
}
static final class AiStreamResult {
private final String answer;
private final String finishReason;
AiStreamResult(String answer, String finishReason) {
this.answer = answer == null ? "" : answer;
this.finishReason = finishReason == null ? "" : finishReason;
}
String getAnswer() { return answer; }
String getFinishReason() { return finishReason; }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,206 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFColor;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class ExcelExportService {
private static final Logger LOGGER = LoggerFactory.getLogger(ExcelExportService.class);
private static final int EXCEL_CELL_MAX_LENGTH = 32767;
public void writeExcel(Path outputFile, String content, String period,
String team, String contact, String developer, String project) throws IOException {
validateExcelCellLength(content, "具体工作内容");
try (Workbook workbook = new XSSFWorkbook()) {
final Sheet sheet = workbook.createSheet("工作量统计");
final CellStyle headerStyle = createHeaderStyle(workbook);
final CellStyle textStyle = createTextStyle(workbook);
final CellStyle developerPeriodStyle = createDeveloperPeriodStyle(workbook);
final CellStyle projectNameStyle = createProjectNameStyle(workbook);
final CellStyle contentStyle = createContentStyle(workbook);
final String[] headers = {"序号", "所属班组", "技术对接", "开发人员", "工作周期", "开发项目名称", "具体工作内容"};
final Row header = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
final Cell cell = header.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
final Row row = sheet.createRow(1);
row.setHeightInPoints(calculateRowHeight(content));
createCell(row, 0, 1, textStyle);
createCell(row, 1, team, textStyle);
createCell(row, 2, contact, textStyle);
createCell(row, 3, developer, developerPeriodStyle);
createCell(row, 4, period, developerPeriodStyle);
createCell(row, 5, project, projectNameStyle);
createCell(row, 6, content, contentStyle);
sheet.setColumnWidth(0, 2200);
sheet.setColumnWidth(1, 4200);
sheet.setColumnWidth(2, 5200);
sheet.setColumnWidth(3, 4200);
sheet.setColumnWidth(4, 4600);
sheet.setColumnWidth(5, 12000);
sheet.setColumnWidth(6, 26000);
Files.createDirectories(outputFile.getParent());
try (OutputStream out = Files.newOutputStream(outputFile)) {
workbook.write(out);
}
}
}
private CellStyle createHeaderStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setBold(true);
font.setFontName("SimSun");
font.setColor(IndexedColors.BLACK.getIndex());
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
return style;
}
private CellStyle createTextStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("SimSun");
style.setFont(font);
style.setAlignment(HorizontalAlignment.LEFT);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setBorderBottom(BorderStyle.THIN);
style.setWrapText(false);
return style;
}
private CellStyle createDeveloperPeriodStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("SimSun");
font.setBold(false);
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setWrapText(false);
style.setBorderBottom(BorderStyle.THIN);
setSolidFillColor(style, "FEE4FF");
return style;
}
private CellStyle createProjectNameStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("宋体");
font.setBold(true);
style.setFont(font);
style.setAlignment(HorizontalAlignment.GENERAL);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setWrapText(true);
style.setBorderBottom(BorderStyle.THIN);
setSolidFillColor(style, "FFFF00");
return style;
}
private CellStyle createContentStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("NSimSun");
font.setBold(true);
style.setFont(font);
style.setAlignment(HorizontalAlignment.LEFT);
style.setVerticalAlignment(VerticalAlignment.TOP);
style.setWrapText(true);
style.setBorderBottom(BorderStyle.THIN);
setSolidFillColor(style, "FFFF00");
return style;
}
private void setSolidFillColor(CellStyle style, String rgbHex) {
if (!(style instanceof XSSFCellStyle) || rgbHex == null || rgbHex.trim().isEmpty()) {
return;
}
final String normalized = rgbHex.trim();
if (normalized.length() != 6) {
return;
}
final byte[] rgb = new byte[3];
try {
rgb[0] = (byte) Integer.parseInt(normalized.substring(0, 2), 16);
rgb[1] = (byte) Integer.parseInt(normalized.substring(2, 4), 16);
rgb[2] = (byte) Integer.parseInt(normalized.substring(4, 6), 16);
} catch (Exception ignored) {
return;
}
final XSSFCellStyle xssfStyle = (XSSFCellStyle) style;
xssfStyle.setFillForegroundColor(new XSSFColor(rgb, new DefaultIndexedColorMap()));
xssfStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
}
private void createCell(Row row, int idx, String value, CellStyle style) {
final Cell cell = row.createCell(idx);
cell.setCellValue(value == null ? "" : value);
cell.setCellStyle(style);
}
private void createCell(Row row, int idx, int value, CellStyle style) {
final Cell cell = row.createCell(idx);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private void validateExcelCellLength(String value, String fieldName) {
final String safe = value == null ? "" : value;
if (safe.length() > EXCEL_CELL_MAX_LENGTH) {
throw new IllegalArgumentException(
"Excel 单元格内容超长: "
+ (fieldName == null ? "未知字段" : fieldName)
+ " 长度=" + safe.length()
+ ",最大允许=" + EXCEL_CELL_MAX_LENGTH
+ "。请减少本次汇总范围后重试。"
);
}
}
private float calculateRowHeight(String content) {
final String safeContent = content == null ? "" : content;
final String[] lines = safeContent.split("\\r?\\n");
final int visibleLines = Math.max(lines.length, 1);
final float lineHeight = 19.0f;
final float minHeight = 220.0f;
return Math.max(minHeight, visibleLines * lineHeight);
}
}
@@ -0,0 +1,165 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.annotation.PostConstruct;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.RepositoryConfig;
import com.svnlog.web.util.CryptoUtils;
@Service
public class RepositoryConfigService {
private static final Logger LOGGER = LoggerFactory.getLogger(RepositoryConfigService.class);
private static final String CONFIG_FILE_NAME = "repository-configs.json";
private final OutputFileService outputFileService;
private final Gson gson;
private final List<RepositoryConfig> configs;
public RepositoryConfigService(OutputFileService outputFileService) {
this.outputFileService = outputFileService;
this.gson = new GsonBuilder().setPrettyPrinting().create();
this.configs = new ArrayList<RepositoryConfig>();
}
@PostConstruct
public void init() {
load();
}
public synchronized List<RepositoryConfig> listAll() {
return Collections.unmodifiableList(new ArrayList<RepositoryConfig>(configs));
}
public synchronized List<RepositoryConfig> listByType(String type) {
final List<RepositoryConfig> result = new ArrayList<RepositoryConfig>();
for (RepositoryConfig config : configs) {
if (type != null && type.equalsIgnoreCase(config.getType())) {
result.add(config);
}
}
return result;
}
public synchronized RepositoryConfig getById(String id) {
for (RepositoryConfig config : configs) {
if (id != null && id.equals(config.getId())) {
return config;
}
}
throw new IllegalArgumentException("仓库配置不存在: " + id);
}
public synchronized boolean containsId(String id) {
if (id == null || id.trim().isEmpty()) {
return false;
}
for (RepositoryConfig config : configs) {
if (id.equals(config.getId())) {
return true;
}
}
return false;
}
public String decryptPassword(RepositoryConfig config) {
if (config == null || isBlank(config.getSvnPasswordEncrypted())) {
return "";
}
return CryptoUtils.decrypt(config.getSvnPasswordEncrypted());
}
public synchronized boolean updatePresetUrl(String presetId, String newUrl, String name) {
if (!containsId(presetId)) {
return false;
}
final RepositoryConfig existing = getById(presetId);
if (newUrl.equals(existing.getSvnUrl())) {
return false;
}
final String oldUrl = existing.getSvnUrl();
existing.setSvnUrl(newUrl);
existing.setName(name);
save();
LOGGER.info("更新 SVN 预设 URL: id={} name={} oldUrl={} newUrl={}", presetId, name, oldUrl, newUrl);
return true;
}
public synchronized void migratePreset(String presetId, String name, String url) {
if (containsId(presetId)) {
// 已存在,检查 URL 是否有变化,有则更新
final RepositoryConfig existing = getById(presetId);
if (!url.equals(existing.getSvnUrl())) {
final String oldUrl = existing.getSvnUrl();
existing.setSvnUrl(url);
existing.setName(name);
save();
LOGGER.info("更新 SVN 预设 URL: id={} name={} oldUrl={} newUrl={}", presetId, name, oldUrl, url);
}
return;
}
final RepositoryConfig config = new RepositoryConfig();
config.setId(presetId);
config.setName(name);
config.setType("SVN");
config.setEnabled(true);
config.setSvnUrl(url);
config.setCreatedAt(System.currentTimeMillis());
configs.add(config);
save();
LOGGER.info("迁移 SVN 预设到仓库配置: id={} name={}", presetId, name);
}
public synchronized void reload() {
load();
}
private synchronized void save() {
try {
final Path configFile = outputFileService.resolveInOutput(CONFIG_FILE_NAME);
Files.createDirectories(configFile.getParent());
Files.write(configFile, gson.toJson(configs).getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
LOGGER.warn("保存仓库配置失败", e);
}
}
private synchronized void load() {
try {
final Path configFile = outputFileService.resolveInOutput(CONFIG_FILE_NAME);
if (!Files.exists(configFile) || !Files.isRegularFile(configFile)) {
LOGGER.info("仓库配置文件不存在,使用空列表: {}", configFile);
return;
}
final String json = new String(Files.readAllBytes(configFile), StandardCharsets.UTF_8);
final List<RepositoryConfig> loaded = gson.fromJson(
json,
new TypeToken<List<RepositoryConfig>>() { }.getType()
);
configs.clear();
if (loaded != null) {
configs.addAll(loaded);
}
LOGGER.info("加载仓库配置: {} 条", configs.size());
} catch (Exception e) {
LOGGER.warn("加载仓库配置失败", e);
}
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}
@@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.PersistedSettings;
import com.svnlog.web.model.RepositoryConfig;
@Service
public class SettingsService {
@@ -32,6 +33,7 @@ public class SettingsService {
private final OutputFileService outputFileService;
private final SettingsPersistenceService settingsPersistenceService;
private final SvnPresetService svnPresetService;
private final RepositoryConfigService repositoryConfigService;
private final Path bootstrapOutputRoot;
private volatile String runtimeApiKey;
private volatile String runtimeProvider;
@@ -68,10 +70,12 @@ public class SettingsService {
@Autowired
public SettingsService(OutputFileService outputFileService,
SettingsPersistenceService settingsPersistenceService,
SvnPresetService svnPresetService) {
SvnPresetService svnPresetService,
RepositoryConfigService repositoryConfigService) {
this.outputFileService = outputFileService;
this.settingsPersistenceService = settingsPersistenceService;
this.svnPresetService = svnPresetService;
this.repositoryConfigService = repositoryConfigService;
this.bootstrapOutputRoot = initBootstrapOutputRoot(outputFileService);
this.runtimeApiKey = initStartupApiKey();
this.runtimeProvider = PROVIDER_DEEPSEEK;
@@ -375,6 +379,24 @@ public class SettingsService {
return new SvnCredentials(username, password);
}
public SvnCredentials resolveSvnCredentials(String requestUsername, String requestPassword, String presetId) {
String username = resolveSvnUsername(requestUsername);
String password = resolveSvnPassword(requestPassword);
if (isBlank(username) || isBlank(password)) {
final SvnCredentials presetCredentials = resolveRepositoryCredentials(presetId);
if (isBlank(username)) {
username = presetCredentials.getUsername();
}
if (isBlank(password)) {
password = presetCredentials.getPassword();
}
}
if (isBlank(username) || isBlank(password)) {
throw new IllegalArgumentException("未配置 SVN 账号,请先到系统设置页填写 SVN 用户名和密码");
}
return new SvnCredentials(username, password);
}
public SvnCredentials getConfiguredSvnCredentials() {
return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null));
}
@@ -427,6 +449,21 @@ public class SettingsService {
return trim(System.getenv(ENV_SVN_PASSWORD));
}
private SvnCredentials resolveRepositoryCredentials(String presetId) {
if (isBlank(presetId) || repositoryConfigService == null) {
return new SvnCredentials("", "");
}
try {
final RepositoryConfig config = repositoryConfigService.getById(presetId);
final String username = trim(config.getSvnUsername());
final String password = trim(repositoryConfigService.decryptPassword(config));
return new SvnCredentials(username, password);
} catch (RuntimeException e) {
LOGGER.warn("Failed to resolve SVN credentials from repository config: presetId={}", presetId, e);
return new SvnCredentials("", "");
}
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
@@ -1,46 +1,47 @@
package com.svnlog.web.service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.RepositoryConfig;
import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.SvnPresetSummary;
@Service
public class SvnPresetService {
private static final Logger LOGGER = LoggerFactory.getLogger(SvnPresetService.class);
private final List<SvnPreset> presets;
private final RepositoryConfigService repositoryConfigService;
public SvnPresetService() {
final List<SvnPreset> list = new ArrayList<SvnPreset>();
list.add(new SvnPreset(
"preset-1",
"PRS-7050场站智慧管控",
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00"
));
list.add(new SvnPreset(
"preset-2",
"PRS-7950在线巡视",
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00"
));
list.add(new SvnPreset(
"preset-3",
"PRS-7950在线巡视电科院测试版",
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024"
));
this.presets = Collections.unmodifiableList(list);
public SvnPresetService(RepositoryConfigService repositoryConfigService) {
this.repositoryConfigService = repositoryConfigService;
}
@PostConstruct
public void init() {
migrateHardcodedPresets();
}
public List<SvnPreset> listPresets() {
final List<RepositoryConfig> configs = repositoryConfigService.listByType("SVN");
final List<SvnPreset> presets = new ArrayList<SvnPreset>();
for (RepositoryConfig config : configs) {
if (config.isEnabled()) {
presets.add(toSvnPreset(config));
}
}
return presets;
}
public List<SvnPresetSummary> listPresetSummaries() {
final List<SvnPresetSummary> summaries = new ArrayList<SvnPresetSummary>();
for (SvnPreset preset : presets) {
for (SvnPreset preset : listPresets()) {
summaries.add(new SvnPresetSummary(preset.getId(), preset.getName()));
}
return summaries;
@@ -50,25 +51,15 @@ public class SvnPresetService {
if (presetId == null || presetId.trim().isEmpty()) {
return false;
}
for (SvnPreset preset : presets) {
if (presetId.equals(preset.getId())) {
return true;
}
}
return false;
return repositoryConfigService.containsId(presetId);
}
public SvnPreset getById(String presetId) {
final String id = trim(presetId);
for (SvnPreset preset : presets) {
if (id.equals(preset.getId())) {
return preset;
}
}
throw new IllegalArgumentException("无效的 SVN 预设ID: " + presetId);
return toSvnPreset(repositoryConfigService.getById(trim(presetId)));
}
public String firstPresetId() {
final List<SvnPreset> presets = listPresets();
return presets.isEmpty() ? "" : presets.get(0).getId();
}
@@ -76,6 +67,53 @@ public class SvnPresetService {
return firstPresetId();
}
private SvnPreset toSvnPreset(RepositoryConfig config) {
return new SvnPreset(
config.getId(),
config.getName(),
config.getSvnUrl() == null ? "" : config.getSvnUrl()
);
}
private void migrateHardcodedPresets() {
final String[][] presets = {
{
"preset-1",
"PRS-7050场站智慧管控",
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00"
},
{
"preset-2",
"PRS-7950在线巡视",
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00"
},
{
"preset-3",
"PRS-7950在线巡视电科院测试版",
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024"
},
};
int migrated = 0;
int updated = 0;
for (String[] preset : presets) {
if (repositoryConfigService.containsId(preset[0])) {
if (repositoryConfigService.updatePresetUrl(preset[0], preset[2], preset[1])) {
updated++;
}
} else {
repositoryConfigService.migratePreset(preset[0], preset[1], preset[2]);
migrated++;
}
}
if (migrated > 0) {
LOGGER.info("已迁移 {} 个硬编码 SVN 预设到仓库配置", migrated);
}
if (updated > 0) {
LOGGER.info("已更新 {} 个 SVN 预设的 URL", updated);
}
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
@@ -2,8 +2,13 @@ package com.svnlog.web.service;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
@@ -19,6 +24,9 @@ import com.svnlog.web.model.TaskResult;
@Service
public class SvnWorkflowService {
private static final TimeZone RANGE_TIME_ZONE = TimeZone.getTimeZone("Asia/Shanghai");
private static final Pattern DATE_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})$");
private static final Pattern WEEK_PATTERN = Pattern.compile("^(\\d{4})-W(\\d{2})$");
private final OutputFileService outputFileService;
private final SettingsService settingsService;
@@ -36,7 +44,8 @@ public class SvnWorkflowService {
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
request.getPassword(),
request.getPresetId()
);
final SVNLogFetcher fetcher = new SVNLogFetcher(
preset.getUrl(),
@@ -50,17 +59,20 @@ public class SvnWorkflowService {
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
request.getPassword(),
request.getPresetId()
);
final SVNLogFetcher fetcher = new SVNLogFetcher(
preset.getUrl(),
credentials.getUsername(),
credentials.getPassword()
);
return fetcher.getVersionRangeByMonth(
request.getYear().intValue(),
request.getMonth().intValue(),
request.getClientTraceId()
final DateRange dateRange = resolveDateRange(request);
return fetcher.getVersionRangeByTimeRange(
dateRange.startInclusive,
dateRange.endExclusive,
request.getClientTraceId(),
dateRange.rangeType
);
}
@@ -68,7 +80,8 @@ public class SvnWorkflowService {
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
request.getPassword(),
request.getPresetId()
);
context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName());
final SVNLogFetcher fetcher = new SVNLogFetcher(
@@ -124,4 +137,95 @@ public class SvnWorkflowService {
private String safe(String value) {
return value == null ? "" : value;
}
private DateRange resolveDateRange(SvnVersionRangeRequest request) {
final String rangeType = safe(request.getRangeType()).trim();
if ("date".equals(rangeType)) {
return resolveDateRangeByDate(request.getDate());
}
if ("week".equals(rangeType)) {
return resolveDateRangeByWeek(request.getWeek());
}
return resolveDateRangeByMonth(request.getYear(), request.getMonth());
}
private DateRange resolveDateRangeByDate(String value) {
final Matcher matcher = DATE_PATTERN.matcher(safe(value).trim());
if (!matcher.matches()) {
throw new IllegalArgumentException("日期格式错误,应为 yyyy-MM-dd");
}
final Calendar start = newCalendar();
start.set(
parseNumber(matcher.group(1), "年份"),
parseNumber(matcher.group(2), "月份") - 1,
parseNumber(matcher.group(3), "日期"),
0,
0,
0
);
start.set(Calendar.MILLISECOND, 0);
final Calendar end = (Calendar) start.clone();
end.add(Calendar.DATE, 1);
return new DateRange("date", start.getTime(), end.getTime());
}
private DateRange resolveDateRangeByWeek(String value) {
final Matcher matcher = WEEK_PATTERN.matcher(safe(value).trim());
if (!matcher.matches()) {
throw new IllegalArgumentException("周格式错误,应为 yyyy-Www");
}
final int year = parseNumber(matcher.group(1), "年份");
final int week = parseNumber(matcher.group(2), "");
final GregorianCalendar start = new GregorianCalendar(RANGE_TIME_ZONE);
start.clear();
start.setFirstDayOfWeek(Calendar.MONDAY);
start.setMinimalDaysInFirstWeek(4);
start.setWeekDate(year, week, Calendar.MONDAY);
start.set(Calendar.HOUR_OF_DAY, 0);
start.set(Calendar.MINUTE, 0);
start.set(Calendar.SECOND, 0);
start.set(Calendar.MILLISECOND, 0);
final Calendar end = (Calendar) start.clone();
end.add(Calendar.DATE, 7);
return new DateRange("week", start.getTime(), end.getTime());
}
private DateRange resolveDateRangeByMonth(Integer yearValue, Integer monthValue) {
if (yearValue == null || monthValue == null) {
throw new IllegalArgumentException("月份范围缺少 year 或 month 参数");
}
final Calendar start = newCalendar();
start.set(yearValue.intValue(), monthValue.intValue() - 1, 1, 0, 0, 0);
start.set(Calendar.MILLISECOND, 0);
final Calendar end = (Calendar) start.clone();
end.add(Calendar.MONTH, 1);
return new DateRange("month", start.getTime(), end.getTime());
}
private Calendar newCalendar() {
final Calendar calendar = Calendar.getInstance(RANGE_TIME_ZONE);
calendar.setFirstDayOfWeek(Calendar.MONDAY);
calendar.setMinimalDaysInFirstWeek(4);
return calendar;
}
private int parseNumber(String value, String fieldName) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(fieldName + "格式错误", e);
}
}
private static class DateRange {
private final String rangeType;
private final Date startInclusive;
private final Date endExclusive;
private DateRange(String rangeType, Date startInclusive, Date endExclusive) {
this.rangeType = rangeType;
this.startInclusive = startInclusive;
this.endExclusive = endExclusive;
}
}
}
@@ -0,0 +1,66 @@
package com.svnlog.web.util;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public final class CryptoUtils {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
private static final int IV_LENGTH = 16;
private static final String SECRET_KEY_BASE64 = "U3ZuTG9nVG9vbFNlY3JldA==";
private CryptoUtils() {
}
public static String encrypt(String plaintext) {
if (plaintext == null || plaintext.isEmpty()) {
return "";
}
try {
final byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY_BASE64);
final SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
final byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
final IvParameterSpec ivSpec = new IvParameterSpec(iv);
final Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
final byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
final byte[] combined = new byte[IV_LENGTH + encrypted.length];
System.arraycopy(iv, 0, combined, 0, IV_LENGTH);
System.arraycopy(encrypted, 0, combined, IV_LENGTH, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new IllegalStateException("加密失败: " + e.getMessage(), e);
}
}
public static String decrypt(String ciphertext) {
if (ciphertext == null || ciphertext.isEmpty()) {
return "";
}
try {
final byte[] combined = Base64.getDecoder().decode(ciphertext);
if (combined.length < IV_LENGTH) {
throw new IllegalArgumentException("密文格式错误");
}
final byte[] iv = new byte[IV_LENGTH];
final byte[] encrypted = new byte[combined.length - IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, IV_LENGTH);
System.arraycopy(combined, IV_LENGTH, encrypted, 0, encrypted.length);
final byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY_BASE64);
final SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
final IvParameterSpec ivSpec = new IvParameterSpec(iv);
final Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new IllegalStateException("解密失败: " + e.getMessage(), e);
}
}
}
File diff suppressed because one or more lines are too long
@@ -17,19 +17,27 @@ class AiWorkflowServiceTest {
@TempDir
Path tempDir;
private AiApiService buildAiApiService(SettingsService settingsService) {
return new AiApiService(settingsService);
}
private ExcelExportService buildExcelExportService() {
return new ExcelExportService();
}
@Test
void shouldResolveDeepSeekProviderByDefault() {
final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = buildSettingsService(outputFileService);
final AiApiService aiApiService = buildAiApiService(settingsService);
final AiWorkflowService service = new AiWorkflowService(
buildOutputFileService(),
new SettingsService(
buildOutputFileService(),
new SettingsPersistenceService(),
new SvnPresetService()
),
new AiInputValidator()
outputFileService,
new AiInputValidator(),
aiApiService,
buildExcelExportService()
);
final AiWorkflowService.AiProviderContext context = service.resolveProviderContext(null);
final AiApiService.AiProviderContext context = aiApiService.resolveProviderContext(null);
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, context.getProvider());
Assertions.assertEquals("deepseek-chat", context.getStageOneModel());
@@ -39,11 +47,7 @@ class AiWorkflowServiceTest {
@Test
void shouldResolveOpenAiCompatibleModelsAndUrl() {
final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService()
);
final SettingsService settingsService = buildSettingsService(outputFileService);
settingsService.updateSettings(
null,
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
@@ -56,9 +60,12 @@ class AiWorkflowServiceTest {
null,
null
);
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
final AiApiService aiApiService = buildAiApiService(settingsService);
final AiWorkflowService service = new AiWorkflowService(
outputFileService, new AiInputValidator(), aiApiService, buildExcelExportService()
);
final AiWorkflowService.AiProviderContext context = service.resolveProviderContext(null);
final AiApiService.AiProviderContext context = aiApiService.resolveProviderContext(null);
Assertions.assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, context.getProvider());
Assertions.assertEquals("http://127.0.0.1:5001/v1/chat/completions", context.getApiUrl());
@@ -68,15 +75,14 @@ class AiWorkflowServiceTest {
@Test
void shouldFailFastWhenOpenAiCompatibleBaseUrlMissing() {
final AiWorkflowService service = new AiWorkflowService(
buildOutputFileService(),
new StubSettingsService(buildOutputFileService(), " ", "sk-openai-test"),
new AiInputValidator()
final OutputFileService outputFileService = buildOutputFileService();
final AiApiService aiApiService = buildAiApiService(
new StubSettingsService(outputFileService, " ", "sk-openai-test")
);
final IllegalStateException error = Assertions.assertThrows(
IllegalStateException.class,
() -> service.resolveProviderContext(null)
() -> aiApiService.resolveProviderContext(null)
);
Assertions.assertTrue(error.getMessage().contains("OpenAI兼容 Base URL"));
@@ -85,11 +91,7 @@ class AiWorkflowServiceTest {
@Test
void shouldParseCompatibleStreamWhenOnlyContentIsReturned() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService()
);
final SettingsService settingsService = buildSettingsService(outputFileService);
settingsService.updateSettings(
null,
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
@@ -102,8 +104,8 @@ class AiWorkflowServiceTest {
null,
null
);
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
final AiWorkflowService.AiProviderContext providerContext = service.resolveProviderContext(null);
final AiApiService aiApiService = buildAiApiService(settingsService);
final AiApiService.AiProviderContext providerContext = aiApiService.resolveProviderContext(null);
final TaskContext taskContext = new TaskContext(buildTaskInfo(), null, null);
final Buffer buffer = new Buffer()
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"items\\\":[\"}}]}\n")
@@ -111,7 +113,7 @@ class AiWorkflowServiceTest {
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"]}\"},\"finish_reason\":\"stop\"}]}\n")
.writeUtf8("data: [DONE]\n");
final AiWorkflowService.AiStreamResult result = service.readStreamingResponse(
final AiApiService.AiStreamResult result = aiApiService.readStreamingResponse(
buffer,
taskContext,
providerContext,
@@ -138,12 +140,30 @@ class AiWorkflowServiceTest {
return taskInfo;
}
private SettingsService buildSettingsService(OutputFileService outputFileService) {
final RepositoryConfigService repositoryConfigService = buildRepositoryConfigService(outputFileService);
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
return new SettingsService(outputFileService, new SettingsPersistenceService(), svnPresetService, repositoryConfigService);
}
private RepositoryConfigService buildRepositoryConfigService(OutputFileService outputFileService) {
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init();
return repositoryConfigService;
}
private static final class StubSettingsService extends SettingsService {
private final String openaiBaseUrl;
private final String openaiApiKey;
private StubSettingsService(OutputFileService outputFileService, String openaiBaseUrl, String openaiApiKey) {
super(outputFileService, new SettingsPersistenceService(), new SvnPresetService());
super(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService(new RepositoryConfigService(outputFileService)),
new RepositoryConfigService(outputFileService)
);
this.openaiBaseUrl = openaiBaseUrl;
this.openaiApiKey = openaiApiKey;
}
@@ -26,10 +26,15 @@ class HealthServiceTest {
void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService()
svnPresetService,
repositoryConfigService
);
taskService = new TaskService(new TaskPersistenceService(), outputFileService);
final HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
@@ -9,11 +9,15 @@ import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import com.google.gson.GsonBuilder;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.svnlog.web.model.RepositoryConfig;
import com.svnlog.web.util.CryptoUtils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -195,12 +199,58 @@ class SettingsServiceTest {
assertNotNull(settings.get("apiKeySource"));
}
@Test
void shouldResolveSvnCredentialsFromRepositoryConfigWhenSettingsAreEmpty() throws IOException {
useTempWorkingDirectory();
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
writeRepositoryConfig(outputFileService.getOutputRoot().resolve("repository-configs.json"));
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
svnPresetService,
repositoryConfigService
);
final SettingsService.SvnCredentials credentials =
settingsService.resolveSvnCredentials(null, null, "preset-json");
assertEquals("json-user", credentials.getUsername());
assertEquals("json-pass", credentials.getPassword());
assertTrue(svnPresetService.containsPresetId("preset-json"));
assertEquals("JSON SVN", svnPresetService.getById("preset-json").getName());
}
private SettingsService newSettingsService() {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsPersistenceService settingsPersistenceService = new SettingsPersistenceService();
final SvnPresetService svnPresetService = new SvnPresetService();
return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService);
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService, repositoryConfigService);
}
private void writeRepositoryConfig(Path configPath) throws IOException {
final RepositoryConfig config = new RepositoryConfig();
config.setId("preset-json");
config.setName("JSON SVN");
config.setType("SVN");
config.setEnabled(true);
config.setSvnUrl("https://example.invalid/svn/project");
config.setSvnUsername("json-user");
config.setSvnPasswordEncrypted(CryptoUtils.encrypt("json-pass"));
Files.createDirectories(configPath.getParent());
final String json = new GsonBuilder().setPrettyPrinting().create()
.toJson(java.util.Collections.singletonList(config));
Files.write(configPath, json.getBytes(StandardCharsets.UTF_8));
}
private void useTempWorkingDirectory() {