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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
Generated
+1229
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 & 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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user