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
|
# Maven
|
||||||
target/
|
target/
|
||||||
pom.xml.tag
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 使用阿里云 Maven 镜像加速依赖下载(替换 Maven Central)
|
||||||
|
COPY maven-settings.xml /root/.m2/settings.xml
|
||||||
|
|
||||||
COPY pom.xml .
|
COPY pom.xml .
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.m2 \
|
RUN --mount=type=cache,target=/root/.m2 \
|
||||||
mvn -B -DskipTests dependency:go-offline
|
mvn -B -DskipTests -T 1C dependency:go-offline
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.m2 \
|
# 将前端构建产物注入静态资源目录(Maven 会自动打包进 jar)
|
||||||
mvn -B -DskipTests clean package
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/target/svn-log-tool-1.0.0-jar-with-dependencies.jar app.jar
|
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
|
.PHONY: up down status
|
||||||
|
|
||||||
COMPOSE_CMD := $(shell if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
|
COMPOSE_CMD := $(shell if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
|
||||||
@@ -5,7 +12,7 @@ BUILD_ENV := DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1
|
|||||||
|
|
||||||
up:
|
up:
|
||||||
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
|
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
|
||||||
@$(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"
|
@echo "Application is starting at http://localhost:18088"
|
||||||
|
|
||||||
down:
|
down:
|
||||||
|
|||||||
+5
-2
@@ -3,9 +3,12 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
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
|
container_name: svn-log-tool
|
||||||
ports:
|
network_mode: host
|
||||||
- "18088:18088"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./outputs:/app/outputs
|
- ./outputs:/app/outputs
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ http://localhost:18088
|
|||||||
|
|
||||||
- `POST /api/svn/test-connection`
|
- `POST /api/svn/test-connection`
|
||||||
- `POST /api/svn/fetch`
|
- `POST /api/svn/fetch`
|
||||||
|
- `POST /api/svn/version-range`:按 `rangeType=date|week|month` 查询版本范围;旧的 `year/month` 月份请求仍兼容
|
||||||
- `GET /api/svn/presets`
|
- `GET /api/svn/presets`
|
||||||
- `POST /api/ai/analyze`
|
- `POST /api/ai/analyze`
|
||||||
- `GET /api/tasks`
|
- `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 {
|
public class SVNLogFetcher {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(SVNLogFetcher.class);
|
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 DEFAULT_BOUNDARY_PADDING = 50L;
|
||||||
private static final long FALLBACK_SCAN_PADDING = 2000L;
|
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 {
|
public long[] getVersionRangeByMonth(int year, int month, String traceId) throws SVNException {
|
||||||
final String trace = traceId == null ? "" : traceId;
|
final Calendar startCal = Calendar.getInstance(RANGE_TIME_ZONE);
|
||||||
|
|
||||||
final Calendar startCal = Calendar.getInstance(MONTH_TIME_ZONE);
|
|
||||||
startCal.set(year, month - 1, 1, 0, 0, 0);
|
startCal.set(year, month - 1, 1, 0, 0, 0);
|
||||||
startCal.set(Calendar.MILLISECOND, 0);
|
startCal.set(Calendar.MILLISECOND, 0);
|
||||||
final long monthStart = startCal.getTimeInMillis();
|
|
||||||
|
|
||||||
final Calendar nextMonthCal = (Calendar) startCal.clone();
|
final Calendar nextMonthCal = (Calendar) startCal.clone();
|
||||||
nextMonthCal.add(Calendar.MONTH, 1);
|
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();
|
final long latestRevision = getLatestRevision();
|
||||||
if (latestRevision < 1L) {
|
if (latestRevision < 1L) {
|
||||||
@@ -155,17 +172,16 @@ public class SVNLogFetcher {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final long startAnchor = repository.getDatedRevision(new Date(monthStart));
|
final long startAnchor = repository.getDatedRevision(rangeStartInclusive);
|
||||||
final long endAnchor = repository.getDatedRevision(new Date(nextMonthStart - 1L));
|
final long endAnchor = repository.getDatedRevision(new Date(rangeEnd - 1L));
|
||||||
|
|
||||||
LOGGER.info(
|
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,
|
trace,
|
||||||
year,
|
rangeType,
|
||||||
month,
|
RANGE_TIME_ZONE.getID(),
|
||||||
MONTH_TIME_ZONE.getID(),
|
formatDate(rangeStartInclusive),
|
||||||
formatDate(new Date(monthStart)),
|
formatDate(rangeEndExclusive),
|
||||||
formatDate(new Date(nextMonthStart)),
|
|
||||||
latestRevision,
|
latestRevision,
|
||||||
startAnchor,
|
startAnchor,
|
||||||
endAnchor
|
endAnchor
|
||||||
@@ -179,8 +195,8 @@ public class SVNLogFetcher {
|
|||||||
long[] exactRange = findRangeInWindow(
|
long[] exactRange = findRangeInWindow(
|
||||||
Math.max(1L, startAnchor - DEFAULT_BOUNDARY_PADDING),
|
Math.max(1L, startAnchor - DEFAULT_BOUNDARY_PADDING),
|
||||||
Math.min(latestRevision, endAnchor + DEFAULT_BOUNDARY_PADDING),
|
Math.min(latestRevision, endAnchor + DEFAULT_BOUNDARY_PADDING),
|
||||||
monthStart,
|
rangeStart,
|
||||||
nextMonthStart,
|
rangeEnd,
|
||||||
trace,
|
trace,
|
||||||
"primary"
|
"primary"
|
||||||
);
|
);
|
||||||
@@ -194,8 +210,8 @@ public class SVNLogFetcher {
|
|||||||
exactRange = findRangeInWindow(
|
exactRange = findRangeInWindow(
|
||||||
Math.max(1L, startAnchor - FALLBACK_SCAN_PADDING),
|
Math.max(1L, startAnchor - FALLBACK_SCAN_PADDING),
|
||||||
Math.min(latestRevision, endAnchor + FALLBACK_SCAN_PADDING),
|
Math.min(latestRevision, endAnchor + FALLBACK_SCAN_PADDING),
|
||||||
monthStart,
|
rangeStart,
|
||||||
nextMonthStart,
|
rangeEnd,
|
||||||
trace,
|
trace,
|
||||||
"fallback"
|
"fallback"
|
||||||
);
|
);
|
||||||
@@ -211,8 +227,8 @@ public class SVNLogFetcher {
|
|||||||
|
|
||||||
private long[] findRangeInWindow(long fromRevision,
|
private long[] findRangeInWindow(long fromRevision,
|
||||||
long toRevision,
|
long toRevision,
|
||||||
long monthStart,
|
long rangeStart,
|
||||||
long nextMonthStart,
|
long rangeEnd,
|
||||||
String trace,
|
String trace,
|
||||||
String strategyTag) throws SVNException {
|
String strategyTag) throws SVNException {
|
||||||
if (fromRevision > toRevision) {
|
if (fromRevision > toRevision) {
|
||||||
@@ -247,7 +263,7 @@ public class SVNLogFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
long logTime = logDate.getTime();
|
long logTime = logDate.getTime();
|
||||||
if (logTime >= monthStart && logTime < nextMonthStart) {
|
if (logTime >= rangeStart && logTime < rangeEnd) {
|
||||||
long revision = entry.getRevision();
|
long revision = entry.getRevision();
|
||||||
if (revision < minRevision) {
|
if (revision < minRevision) {
|
||||||
minRevision = revision;
|
minRevision = revision;
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import javax.net.ssl.TrustManager;
|
|||||||
import javax.net.ssl.X509TrustManager;
|
import javax.net.ssl.X509TrustManager;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
@SpringBootApplication(scanBasePackages = "com.svnlog")
|
@SpringBootApplication(scanBasePackages = "com.svnlog")
|
||||||
public class WebApplication {
|
public class WebApplication {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(WebApplication.class);
|
||||||
|
|
||||||
static {
|
static {
|
||||||
// 配置 Java 全局 SSL 上下文(用于内网 SVN 服务器)
|
// 配置 Java 全局 SSL 上下文(用于内网 SVN 服务器)
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +30,7 @@ public class WebApplication {
|
|||||||
.replaceAll(",\\s*TLSv1\\.1", "")
|
.replaceAll(",\\s*TLSv1\\.1", "")
|
||||||
.replaceAll(",\\s*TLSv1", "");
|
.replaceAll(",\\s*TLSv1", "");
|
||||||
java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms);
|
java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms);
|
||||||
System.out.println("TLS configuration updated: " + disabledAlgorithms);
|
LOGGER.info("TLS configuration updated: {}", disabledAlgorithms);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置信任所有证书的 SSL 上下文
|
// 配置信任所有证书的 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) {
|
} catch (Exception e) {
|
||||||
System.err.println("Warning: Failed to configure SSL context: " + e.getMessage());
|
LOGGER.warn("Failed to configure SSL context: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置 TLS 协议版本
|
// 配置 TLS 协议版本
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ public class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询指定月份的SVN版本范围
|
* 查询指定时间范围的SVN版本范围
|
||||||
*/
|
*/
|
||||||
@PostMapping("/svn/version-range")
|
@PostMapping("/svn/version-range")
|
||||||
public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception {
|
public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception {
|
||||||
@@ -104,19 +104,21 @@ public class AppController {
|
|||||||
final String url = preset.getUrl();
|
final String url = preset.getUrl();
|
||||||
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
|
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
|
||||||
request.getUsername(),
|
request.getUsername(),
|
||||||
request.getPassword()
|
request.getPassword(),
|
||||||
|
request.getPresetId()
|
||||||
);
|
);
|
||||||
final int year = request.getYear().intValue();
|
|
||||||
final int month = request.getMonth().intValue();
|
|
||||||
|
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} year={} month={} username={} password={}",
|
"[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} rangeType={} year={} month={} date={} week={} username={} password={}",
|
||||||
traceId,
|
traceId,
|
||||||
request.getPresetId(),
|
request.getPresetId(),
|
||||||
preset.getName(),
|
preset.getName(),
|
||||||
url,
|
url,
|
||||||
year,
|
request.getRangeType(),
|
||||||
month,
|
request.getYear(),
|
||||||
|
request.getMonth(),
|
||||||
|
request.getDate(),
|
||||||
|
request.getWeek(),
|
||||||
credentials.getUsername(),
|
credentials.getUsername(),
|
||||||
maskPassword(credentials.getPassword())
|
maskPassword(credentials.getPassword())
|
||||||
);
|
);
|
||||||
@@ -127,8 +129,11 @@ public class AppController {
|
|||||||
response.put("presetId", request.getPresetId());
|
response.put("presetId", request.getPresetId());
|
||||||
response.put("presetName", preset.getName());
|
response.put("presetName", preset.getName());
|
||||||
response.put("resolvedSvnUrl", url);
|
response.put("resolvedSvnUrl", url);
|
||||||
response.put("year", year);
|
response.put("rangeType", request.getRangeType());
|
||||||
response.put("month", month);
|
response.put("year", request.getYear());
|
||||||
|
response.put("month", request.getMonth());
|
||||||
|
response.put("date", request.getDate());
|
||||||
|
response.put("week", request.getWeek());
|
||||||
response.put("traceId", traceId);
|
response.put("traceId", traceId);
|
||||||
if (range != null) {
|
if (range != null) {
|
||||||
response.put("startRevision", range[0]);
|
response.put("startRevision", range[0]);
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ public class IndexController {
|
|||||||
|
|
||||||
@GetMapping(value = {"/", "/index.html"})
|
@GetMapping(value = {"/", "/index.html"})
|
||||||
public ResponseEntity<Resource> index() {
|
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();
|
final HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setCacheControl(CacheControl.noStore().mustRevalidate().getHeaderValue());
|
headers.setCacheControl(CacheControl.noStore().mustRevalidate().getHeaderValue());
|
||||||
headers.add(HttpHeaders.PRAGMA, "no-cache");
|
headers.add(HttpHeaders.PRAGMA, "no-cache");
|
||||||
@@ -21,6 +35,6 @@ public class IndexController {
|
|||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.contentType(MediaType.TEXT_HTML)
|
.contentType(MediaType.TEXT_HTML)
|
||||||
.body(new ClassPathResource("static/index.html"));
|
.body(new ClassPathResource(classpath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.svnlog.web.dto;
|
package com.svnlog.web.dto;
|
||||||
|
|
||||||
import javax.validation.constraints.NotBlank;
|
import javax.validation.constraints.NotBlank;
|
||||||
import javax.validation.constraints.NotNull;
|
|
||||||
|
|
||||||
public class SvnVersionRangeRequest {
|
public class SvnVersionRangeRequest {
|
||||||
|
|
||||||
@@ -12,11 +11,16 @@ public class SvnVersionRangeRequest {
|
|||||||
|
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
@NotNull
|
|
||||||
private Integer year;
|
private Integer year;
|
||||||
|
|
||||||
@NotNull
|
|
||||||
private Integer month;
|
private Integer month;
|
||||||
|
|
||||||
|
private String rangeType;
|
||||||
|
|
||||||
|
private String date;
|
||||||
|
|
||||||
|
private String week;
|
||||||
|
|
||||||
private String clientTraceId;
|
private String clientTraceId;
|
||||||
|
|
||||||
public String getPresetId() {
|
public String getPresetId() {
|
||||||
@@ -59,6 +63,30 @@ public class SvnVersionRangeRequest {
|
|||||||
this.month = month;
|
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() {
|
public String getClientTraceId() {
|
||||||
return clientTraceId;
|
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 org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.svnlog.web.model.PersistedSettings;
|
import com.svnlog.web.model.PersistedSettings;
|
||||||
|
import com.svnlog.web.model.RepositoryConfig;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SettingsService {
|
public class SettingsService {
|
||||||
@@ -32,6 +33,7 @@ public class SettingsService {
|
|||||||
private final OutputFileService outputFileService;
|
private final OutputFileService outputFileService;
|
||||||
private final SettingsPersistenceService settingsPersistenceService;
|
private final SettingsPersistenceService settingsPersistenceService;
|
||||||
private final SvnPresetService svnPresetService;
|
private final SvnPresetService svnPresetService;
|
||||||
|
private final RepositoryConfigService repositoryConfigService;
|
||||||
private final Path bootstrapOutputRoot;
|
private final Path bootstrapOutputRoot;
|
||||||
private volatile String runtimeApiKey;
|
private volatile String runtimeApiKey;
|
||||||
private volatile String runtimeProvider;
|
private volatile String runtimeProvider;
|
||||||
@@ -68,10 +70,12 @@ public class SettingsService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
public SettingsService(OutputFileService outputFileService,
|
public SettingsService(OutputFileService outputFileService,
|
||||||
SettingsPersistenceService settingsPersistenceService,
|
SettingsPersistenceService settingsPersistenceService,
|
||||||
SvnPresetService svnPresetService) {
|
SvnPresetService svnPresetService,
|
||||||
|
RepositoryConfigService repositoryConfigService) {
|
||||||
this.outputFileService = outputFileService;
|
this.outputFileService = outputFileService;
|
||||||
this.settingsPersistenceService = settingsPersistenceService;
|
this.settingsPersistenceService = settingsPersistenceService;
|
||||||
this.svnPresetService = svnPresetService;
|
this.svnPresetService = svnPresetService;
|
||||||
|
this.repositoryConfigService = repositoryConfigService;
|
||||||
this.bootstrapOutputRoot = initBootstrapOutputRoot(outputFileService);
|
this.bootstrapOutputRoot = initBootstrapOutputRoot(outputFileService);
|
||||||
this.runtimeApiKey = initStartupApiKey();
|
this.runtimeApiKey = initStartupApiKey();
|
||||||
this.runtimeProvider = PROVIDER_DEEPSEEK;
|
this.runtimeProvider = PROVIDER_DEEPSEEK;
|
||||||
@@ -375,6 +379,24 @@ public class SettingsService {
|
|||||||
return new SvnCredentials(username, password);
|
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() {
|
public SvnCredentials getConfiguredSvnCredentials() {
|
||||||
return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null));
|
return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null));
|
||||||
}
|
}
|
||||||
@@ -427,6 +449,21 @@ public class SettingsService {
|
|||||||
return trim(System.getenv(ENV_SVN_PASSWORD));
|
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) {
|
private String trim(String value) {
|
||||||
return value == null ? "" : value.trim();
|
return value == null ? "" : value.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,47 @@
|
|||||||
package com.svnlog.web.service;
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.svnlog.web.model.RepositoryConfig;
|
||||||
import com.svnlog.web.model.SvnPreset;
|
import com.svnlog.web.model.SvnPreset;
|
||||||
import com.svnlog.web.model.SvnPresetSummary;
|
import com.svnlog.web.model.SvnPresetSummary;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SvnPresetService {
|
public class SvnPresetService {
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(SvnPresetService.class);
|
||||||
|
|
||||||
private final List<SvnPreset> presets;
|
private final RepositoryConfigService repositoryConfigService;
|
||||||
|
|
||||||
public SvnPresetService() {
|
public SvnPresetService(RepositoryConfigService repositoryConfigService) {
|
||||||
final List<SvnPreset> list = new ArrayList<SvnPreset>();
|
this.repositoryConfigService = repositoryConfigService;
|
||||||
list.add(new SvnPreset(
|
}
|
||||||
"preset-1",
|
|
||||||
"PRS-7050场站智慧管控",
|
@PostConstruct
|
||||||
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00"
|
public void init() {
|
||||||
));
|
migrateHardcodedPresets();
|
||||||
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 List<SvnPreset> listPresets() {
|
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;
|
return presets;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SvnPresetSummary> listPresetSummaries() {
|
public List<SvnPresetSummary> listPresetSummaries() {
|
||||||
final List<SvnPresetSummary> summaries = new ArrayList<SvnPresetSummary>();
|
final List<SvnPresetSummary> summaries = new ArrayList<SvnPresetSummary>();
|
||||||
for (SvnPreset preset : presets) {
|
for (SvnPreset preset : listPresets()) {
|
||||||
summaries.add(new SvnPresetSummary(preset.getId(), preset.getName()));
|
summaries.add(new SvnPresetSummary(preset.getId(), preset.getName()));
|
||||||
}
|
}
|
||||||
return summaries;
|
return summaries;
|
||||||
@@ -50,25 +51,15 @@ public class SvnPresetService {
|
|||||||
if (presetId == null || presetId.trim().isEmpty()) {
|
if (presetId == null || presetId.trim().isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (SvnPreset preset : presets) {
|
return repositoryConfigService.containsId(presetId);
|
||||||
if (presetId.equals(preset.getId())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public SvnPreset getById(String presetId) {
|
public SvnPreset getById(String presetId) {
|
||||||
final String id = trim(presetId);
|
return toSvnPreset(repositoryConfigService.getById(trim(presetId)));
|
||||||
for (SvnPreset preset : presets) {
|
|
||||||
if (id.equals(preset.getId())) {
|
|
||||||
return preset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("无效的 SVN 预设ID: " + presetId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String firstPresetId() {
|
public String firstPresetId() {
|
||||||
|
final List<SvnPreset> presets = listPresets();
|
||||||
return presets.isEmpty() ? "" : presets.get(0).getId();
|
return presets.isEmpty() ? "" : presets.get(0).getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +67,53 @@ public class SvnPresetService {
|
|||||||
return firstPresetId();
|
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) {
|
private String trim(String value) {
|
||||||
return value == null ? "" : value.trim();
|
return value == null ? "" : value.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ package com.svnlog.web.service;
|
|||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.Calendar;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.GregorianCalendar;
|
||||||
import java.util.List;
|
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.springframework.stereotype.Service;
|
||||||
import org.tmatesoft.svn.core.SVNException;
|
import org.tmatesoft.svn.core.SVNException;
|
||||||
@@ -19,6 +24,9 @@ import com.svnlog.web.model.TaskResult;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SvnWorkflowService {
|
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 OutputFileService outputFileService;
|
||||||
private final SettingsService settingsService;
|
private final SettingsService settingsService;
|
||||||
@@ -36,7 +44,8 @@ public class SvnWorkflowService {
|
|||||||
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||||
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
|
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
|
||||||
request.getUsername(),
|
request.getUsername(),
|
||||||
request.getPassword()
|
request.getPassword(),
|
||||||
|
request.getPresetId()
|
||||||
);
|
);
|
||||||
final SVNLogFetcher fetcher = new SVNLogFetcher(
|
final SVNLogFetcher fetcher = new SVNLogFetcher(
|
||||||
preset.getUrl(),
|
preset.getUrl(),
|
||||||
@@ -50,17 +59,20 @@ public class SvnWorkflowService {
|
|||||||
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||||
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
|
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
|
||||||
request.getUsername(),
|
request.getUsername(),
|
||||||
request.getPassword()
|
request.getPassword(),
|
||||||
|
request.getPresetId()
|
||||||
);
|
);
|
||||||
final SVNLogFetcher fetcher = new SVNLogFetcher(
|
final SVNLogFetcher fetcher = new SVNLogFetcher(
|
||||||
preset.getUrl(),
|
preset.getUrl(),
|
||||||
credentials.getUsername(),
|
credentials.getUsername(),
|
||||||
credentials.getPassword()
|
credentials.getPassword()
|
||||||
);
|
);
|
||||||
return fetcher.getVersionRangeByMonth(
|
final DateRange dateRange = resolveDateRange(request);
|
||||||
request.getYear().intValue(),
|
return fetcher.getVersionRangeByTimeRange(
|
||||||
request.getMonth().intValue(),
|
dateRange.startInclusive,
|
||||||
request.getClientTraceId()
|
dateRange.endExclusive,
|
||||||
|
request.getClientTraceId(),
|
||||||
|
dateRange.rangeType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +80,8 @@ public class SvnWorkflowService {
|
|||||||
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||||
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
|
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
|
||||||
request.getUsername(),
|
request.getUsername(),
|
||||||
request.getPassword()
|
request.getPassword(),
|
||||||
|
request.getPresetId()
|
||||||
);
|
);
|
||||||
context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName());
|
context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName());
|
||||||
final SVNLogFetcher fetcher = new SVNLogFetcher(
|
final SVNLogFetcher fetcher = new SVNLogFetcher(
|
||||||
@@ -124,4 +137,95 @@ public class SvnWorkflowService {
|
|||||||
private String safe(String value) {
|
private String safe(String value) {
|
||||||
return value == null ? "" : 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
|
@TempDir
|
||||||
Path tempDir;
|
Path tempDir;
|
||||||
|
|
||||||
|
private AiApiService buildAiApiService(SettingsService settingsService) {
|
||||||
|
return new AiApiService(settingsService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExcelExportService buildExcelExportService() {
|
||||||
|
return new ExcelExportService();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldResolveDeepSeekProviderByDefault() {
|
void shouldResolveDeepSeekProviderByDefault() {
|
||||||
|
final OutputFileService outputFileService = buildOutputFileService();
|
||||||
|
final SettingsService settingsService = buildSettingsService(outputFileService);
|
||||||
|
final AiApiService aiApiService = buildAiApiService(settingsService);
|
||||||
final AiWorkflowService service = new AiWorkflowService(
|
final AiWorkflowService service = new AiWorkflowService(
|
||||||
buildOutputFileService(),
|
outputFileService,
|
||||||
new SettingsService(
|
new AiInputValidator(),
|
||||||
buildOutputFileService(),
|
aiApiService,
|
||||||
new SettingsPersistenceService(),
|
buildExcelExportService()
|
||||||
new SvnPresetService()
|
|
||||||
),
|
|
||||||
new AiInputValidator()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final AiWorkflowService.AiProviderContext context = service.resolveProviderContext(null);
|
final AiApiService.AiProviderContext context = aiApiService.resolveProviderContext(null);
|
||||||
|
|
||||||
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, context.getProvider());
|
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, context.getProvider());
|
||||||
Assertions.assertEquals("deepseek-chat", context.getStageOneModel());
|
Assertions.assertEquals("deepseek-chat", context.getStageOneModel());
|
||||||
@@ -39,11 +47,7 @@ class AiWorkflowServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldResolveOpenAiCompatibleModelsAndUrl() {
|
void shouldResolveOpenAiCompatibleModelsAndUrl() {
|
||||||
final OutputFileService outputFileService = buildOutputFileService();
|
final OutputFileService outputFileService = buildOutputFileService();
|
||||||
final SettingsService settingsService = new SettingsService(
|
final SettingsService settingsService = buildSettingsService(outputFileService);
|
||||||
outputFileService,
|
|
||||||
new SettingsPersistenceService(),
|
|
||||||
new SvnPresetService()
|
|
||||||
);
|
|
||||||
settingsService.updateSettings(
|
settingsService.updateSettings(
|
||||||
null,
|
null,
|
||||||
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
|
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
|
||||||
@@ -56,9 +60,12 @@ class AiWorkflowServiceTest {
|
|||||||
null,
|
null,
|
||||||
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(SettingsService.PROVIDER_OPENAI_COMPATIBLE, context.getProvider());
|
||||||
Assertions.assertEquals("http://127.0.0.1:5001/v1/chat/completions", context.getApiUrl());
|
Assertions.assertEquals("http://127.0.0.1:5001/v1/chat/completions", context.getApiUrl());
|
||||||
@@ -68,15 +75,14 @@ class AiWorkflowServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldFailFastWhenOpenAiCompatibleBaseUrlMissing() {
|
void shouldFailFastWhenOpenAiCompatibleBaseUrlMissing() {
|
||||||
final AiWorkflowService service = new AiWorkflowService(
|
final OutputFileService outputFileService = buildOutputFileService();
|
||||||
buildOutputFileService(),
|
final AiApiService aiApiService = buildAiApiService(
|
||||||
new StubSettingsService(buildOutputFileService(), " ", "sk-openai-test"),
|
new StubSettingsService(outputFileService, " ", "sk-openai-test")
|
||||||
new AiInputValidator()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final IllegalStateException error = Assertions.assertThrows(
|
final IllegalStateException error = Assertions.assertThrows(
|
||||||
IllegalStateException.class,
|
IllegalStateException.class,
|
||||||
() -> service.resolveProviderContext(null)
|
() -> aiApiService.resolveProviderContext(null)
|
||||||
);
|
);
|
||||||
|
|
||||||
Assertions.assertTrue(error.getMessage().contains("OpenAI兼容 Base URL"));
|
Assertions.assertTrue(error.getMessage().contains("OpenAI兼容 Base URL"));
|
||||||
@@ -85,11 +91,7 @@ class AiWorkflowServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldParseCompatibleStreamWhenOnlyContentIsReturned() throws Exception {
|
void shouldParseCompatibleStreamWhenOnlyContentIsReturned() throws Exception {
|
||||||
final OutputFileService outputFileService = buildOutputFileService();
|
final OutputFileService outputFileService = buildOutputFileService();
|
||||||
final SettingsService settingsService = new SettingsService(
|
final SettingsService settingsService = buildSettingsService(outputFileService);
|
||||||
outputFileService,
|
|
||||||
new SettingsPersistenceService(),
|
|
||||||
new SvnPresetService()
|
|
||||||
);
|
|
||||||
settingsService.updateSettings(
|
settingsService.updateSettings(
|
||||||
null,
|
null,
|
||||||
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
|
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
|
||||||
@@ -102,8 +104,8 @@ class AiWorkflowServiceTest {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
|
final AiApiService aiApiService = buildAiApiService(settingsService);
|
||||||
final AiWorkflowService.AiProviderContext providerContext = service.resolveProviderContext(null);
|
final AiApiService.AiProviderContext providerContext = aiApiService.resolveProviderContext(null);
|
||||||
final TaskContext taskContext = new TaskContext(buildTaskInfo(), null, null);
|
final TaskContext taskContext = new TaskContext(buildTaskInfo(), null, null);
|
||||||
final Buffer buffer = new Buffer()
|
final Buffer buffer = new Buffer()
|
||||||
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"items\\\":[\"}}]}\n")
|
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"items\\\":[\"}}]}\n")
|
||||||
@@ -111,7 +113,7 @@ class AiWorkflowServiceTest {
|
|||||||
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"]}\"},\"finish_reason\":\"stop\"}]}\n")
|
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"]}\"},\"finish_reason\":\"stop\"}]}\n")
|
||||||
.writeUtf8("data: [DONE]\n");
|
.writeUtf8("data: [DONE]\n");
|
||||||
|
|
||||||
final AiWorkflowService.AiStreamResult result = service.readStreamingResponse(
|
final AiApiService.AiStreamResult result = aiApiService.readStreamingResponse(
|
||||||
buffer,
|
buffer,
|
||||||
taskContext,
|
taskContext,
|
||||||
providerContext,
|
providerContext,
|
||||||
@@ -138,12 +140,30 @@ class AiWorkflowServiceTest {
|
|||||||
return taskInfo;
|
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 static final class StubSettingsService extends SettingsService {
|
||||||
private final String openaiBaseUrl;
|
private final String openaiBaseUrl;
|
||||||
private final String openaiApiKey;
|
private final String openaiApiKey;
|
||||||
|
|
||||||
private StubSettingsService(OutputFileService outputFileService, String openaiBaseUrl, String openaiApiKey) {
|
private StubSettingsService(OutputFileService outputFileService, String openaiBaseUrl, String openaiApiKey) {
|
||||||
super(outputFileService, new SettingsPersistenceService(), new SvnPresetService());
|
super(
|
||||||
|
outputFileService,
|
||||||
|
new SettingsPersistenceService(),
|
||||||
|
new SvnPresetService(new RepositoryConfigService(outputFileService)),
|
||||||
|
new RepositoryConfigService(outputFileService)
|
||||||
|
);
|
||||||
this.openaiBaseUrl = openaiBaseUrl;
|
this.openaiBaseUrl = openaiBaseUrl;
|
||||||
this.openaiApiKey = openaiApiKey;
|
this.openaiApiKey = openaiApiKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,15 @@ class HealthServiceTest {
|
|||||||
void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception {
|
void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception {
|
||||||
final OutputFileService outputFileService = new OutputFileService();
|
final OutputFileService outputFileService = new OutputFileService();
|
||||||
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
|
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
|
||||||
|
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
|
||||||
|
repositoryConfigService.init();
|
||||||
|
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
|
||||||
|
svnPresetService.init();
|
||||||
final SettingsService settingsService = new SettingsService(
|
final SettingsService settingsService = new SettingsService(
|
||||||
outputFileService,
|
outputFileService,
|
||||||
new SettingsPersistenceService(),
|
new SettingsPersistenceService(),
|
||||||
new SvnPresetService()
|
svnPresetService,
|
||||||
|
repositoryConfigService
|
||||||
);
|
);
|
||||||
taskService = new TaskService(new TaskPersistenceService(), outputFileService);
|
taskService = new TaskService(new TaskPersistenceService(), outputFileService);
|
||||||
final HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
|
final HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import java.util.EnumSet;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
import org.junit.jupiter.api.Assumptions;
|
import org.junit.jupiter.api.Assumptions;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import 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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
@@ -195,12 +199,58 @@ class SettingsServiceTest {
|
|||||||
assertNotNull(settings.get("apiKeySource"));
|
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() {
|
private SettingsService newSettingsService() {
|
||||||
final OutputFileService outputFileService = new OutputFileService();
|
final OutputFileService outputFileService = new OutputFileService();
|
||||||
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
|
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
|
||||||
final SettingsPersistenceService settingsPersistenceService = new SettingsPersistenceService();
|
final SettingsPersistenceService settingsPersistenceService = new SettingsPersistenceService();
|
||||||
final SvnPresetService svnPresetService = new SvnPresetService();
|
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
|
||||||
return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService);
|
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() {
|
private void useTempWorkingDirectory() {
|
||||||
|
|||||||
Reference in New Issue
Block a user