diff --git a/.gitignore b/.gitignore index c06c52a..687b526 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ +# frontend-vue (Vue 3 + Vite) +frontend-vue/node_modules/ + +# frontend build output (auto-generated by Docker build) +src/main/resources/static/v2/ + +# Backup directory +outputs.nobody-backup-*/ + # Maven target/ pom.xml.tag diff --git a/Dockerfile b/Dockerfile index 343a011..6d4809b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,57 @@ -# syntax=docker/dockerfile:1.7 +# ============================================================ +# Docker 镜像仓库加速(默认使用 docker.1ms.run 国内代理) +# 如需切换回 Docker Hub: +# docker compose build --build-arg REGISTRY_MIRROR=docker.io/library +# ============================================================ +ARG REGISTRY_MIRROR=docker.1ms.run/library + +# ============================================================ +# Stage 1: 前端构建(Vue 3 + Vite) +# ============================================================ +FROM ${REGISTRY_MIRROR}/node:22-alpine AS frontend-builder + +WORKDIR /frontend + +COPY frontend-vue/package.json frontend-vue/package-lock.json ./ + +RUN npm ci + +COPY frontend-vue/ ./ + +RUN npm run build + +# ============================================================ +# Stage 2: 后端构建(Maven + Java 8) +# ============================================================ +FROM ${REGISTRY_MIRROR}/maven:3.9.6-eclipse-temurin-8 AS builder + +# Maven JVM 调优:增大堆内存、启用并行 +ENV MAVEN_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m -Djava.util.concurrent.ForkJoinPool.common.parallelism=4" -FROM maven:3.9.6-eclipse-temurin-8 AS builder WORKDIR /app +# 使用阿里云 Maven 镜像加速依赖下载(替换 Maven Central) +COPY maven-settings.xml /root/.m2/settings.xml + COPY pom.xml . RUN --mount=type=cache,target=/root/.m2 \ - mvn -B -DskipTests dependency:go-offline + mvn -B -DskipTests -T 1C dependency:go-offline COPY src ./src -RUN --mount=type=cache,target=/root/.m2 \ - mvn -B -DskipTests clean package +# 将前端构建产物注入静态资源目录(Maven 会自动打包进 jar) +# vite.config.js 中 outDir 为相对 __dirname 的路径,容器内 __dirname=/frontend +COPY --from=frontend-builder /src/main/resources/static/v2 /app/src/main/resources/static/v2 -FROM eclipse-temurin:8-jre +# -T 1C: 按 CPU 核数并行; -o: 离线模式(依赖已缓存,跳过元数据检查) +RUN --mount=type=cache,target=/root/.m2 \ + mvn -B -DskipTests -T 1C -o clean package + +# ============================================================ +# Stage 3: 运行镜像(最小化 JRE) +# ============================================================ +FROM ${REGISTRY_MIRROR}/eclipse-temurin:8-jre-alpine WORKDIR /app COPY --from=builder /app/target/svn-log-tool-1.0.0-jar-with-dependencies.jar app.jar diff --git a/Makefile b/Makefile index be2b1c2..00d26c6 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,10 @@ +# ============================================================ +# Docker Hub 镜像代理(默认使用 docker.1ms.run 加速) +# 如果需要切换回 Docker Hub: +# DOCKER_REGISTRY_MIRROR=docker.io/library make up +# ============================================================ +DOCKER_REGISTRY_MIRROR ?= docker.1ms.run/library + .PHONY: up down status COMPOSE_CMD := $(shell if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi) @@ -5,7 +12,7 @@ BUILD_ENV := DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 up: @if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi - @$(BUILD_ENV) $(COMPOSE_CMD) up -d --build + @REGISTRY_MIRROR=$(DOCKER_REGISTRY_MIRROR) $(BUILD_ENV) $(COMPOSE_CMD) up -d --build @echo "Application is starting at http://localhost:18088" down: diff --git a/docker-compose.yml b/docker-compose.yml index 949cde5..094728d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,12 @@ services: build: context: . dockerfile: Dockerfile + args: + # Docker 镜像加速(默认 Docker Hub,国内可设阿里云) + # 使用方式: REGISTRY_MIRROR=registry.cn-hangzhou.aliyuncs.com/library docker compose build + REGISTRY_MIRROR: ${REGISTRY_MIRROR:-docker.1ms.run/library} container_name: svn-log-tool - ports: - - "18088:18088" + network_mode: host volumes: - ./outputs:/app/outputs restart: unless-stopped diff --git a/docs/README_Web.md b/docs/README_Web.md index 0d3af14..9e04743 100644 --- a/docs/README_Web.md +++ b/docs/README_Web.md @@ -93,6 +93,7 @@ http://localhost:18088 - `POST /api/svn/test-connection` - `POST /api/svn/fetch` +- `POST /api/svn/version-range`:按 `rangeType=date|week|month` 查询版本范围;旧的 `year/month` 月份请求仍兼容 - `GET /api/svn/presets` - `POST /api/ai/analyze` - `GET /api/tasks` diff --git a/frontend-vue/index.html b/frontend-vue/index.html new file mode 100644 index 0000000..c58f512 --- /dev/null +++ b/frontend-vue/index.html @@ -0,0 +1,17 @@ + + + + + + + + SVN 日志工作台 v2 + + + + + +
+ + + diff --git a/frontend-vue/package-lock.json b/frontend-vue/package-lock.json new file mode 100644 index 0000000..aad3004 --- /dev/null +++ b/frontend-vue/package-lock.json @@ -0,0 +1,1229 @@ +{ + "name": "svn-log-tool-v2", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "svn-log-tool-v2", + "version": "2.0.0", + "dependencies": { + "vue": "^3.4.21", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "vite": "^5.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.35.tgz", + "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.35", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", + "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", + "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.35", + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", + "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.35.tgz", + "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.35.tgz", + "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", + "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/runtime-core": "3.5.35", + "@vue/shared": "3.5.35", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.35.tgz", + "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "vue": "3.5.35" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.35.tgz", + "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.35", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", + "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-sfc": "3.5.35", + "@vue/runtime-dom": "3.5.35", + "@vue/server-renderer": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend-vue/package.json b/frontend-vue/package.json new file mode 100644 index 0000000..8bdda91 --- /dev/null +++ b/frontend-vue/package.json @@ -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" + } +} diff --git a/frontend-vue/src/App.vue b/frontend-vue/src/App.vue new file mode 100644 index 0000000..d8f8f7c --- /dev/null +++ b/frontend-vue/src/App.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend-vue/src/components/AppSidebar.vue b/frontend-vue/src/components/AppSidebar.vue new file mode 100644 index 0000000..367ade4 --- /dev/null +++ b/frontend-vue/src/components/AppSidebar.vue @@ -0,0 +1,30 @@ + diff --git a/frontend-vue/src/composables/useApi.js b/frontend-vue/src/composables/useApi.js new file mode 100644 index 0000000..6a3ea1b --- /dev/null +++ b/frontend-vue/src/composables/useApi.js @@ -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 } +} diff --git a/frontend-vue/src/main.js b/frontend-vue/src/main.js new file mode 100644 index 0000000..78f0c7f --- /dev/null +++ b/frontend-vue/src/main.js @@ -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') diff --git a/frontend-vue/src/styles/main.css b/frontend-vue/src/styles/main.css new file mode 100644 index 0000000..15100aa --- /dev/null +++ b/frontend-vue/src/styles/main.css @@ -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); } +} diff --git a/frontend-vue/src/views/DashboardView.vue b/frontend-vue/src/views/DashboardView.vue new file mode 100644 index 0000000..f9d460a --- /dev/null +++ b/frontend-vue/src/views/DashboardView.vue @@ -0,0 +1,172 @@ + + + diff --git a/frontend-vue/src/views/HistoryView.vue b/frontend-vue/src/views/HistoryView.vue new file mode 100644 index 0000000..51f8ea6 --- /dev/null +++ b/frontend-vue/src/views/HistoryView.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend-vue/src/views/SettingsView.vue b/frontend-vue/src/views/SettingsView.vue new file mode 100644 index 0000000..68cf041 --- /dev/null +++ b/frontend-vue/src/views/SettingsView.vue @@ -0,0 +1,159 @@ + + + diff --git a/frontend-vue/src/views/SvnFetchView.vue b/frontend-vue/src/views/SvnFetchView.vue new file mode 100644 index 0000000..326d673 --- /dev/null +++ b/frontend-vue/src/views/SvnFetchView.vue @@ -0,0 +1,378 @@ + + + diff --git a/frontend-vue/vite.config.js b/frontend-vue/vite.config.js new file mode 100644 index 0000000..4475926 --- /dev/null +++ b/frontend-vue/vite.config.js @@ -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, + }, +}) diff --git a/maven-settings.xml b/maven-settings.xml new file mode 100644 index 0000000..79a4ba5 --- /dev/null +++ b/maven-settings.xml @@ -0,0 +1,18 @@ + + + + + + aliyun-maven + 阿里云 Maven 镜像 (Aliyun Maven Mirror) + https://maven.aliyun.com/repository/public + central + + + diff --git a/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java b/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java index 7448471..2192a53 100644 --- a/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java +++ b/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java @@ -24,7 +24,7 @@ import org.tmatesoft.svn.core.wc.SVNWCUtil; public class SVNLogFetcher { private static final Logger LOGGER = LoggerFactory.getLogger(SVNLogFetcher.class); - private static final TimeZone MONTH_TIME_ZONE = TimeZone.getTimeZone("Asia/Shanghai"); + private static final TimeZone RANGE_TIME_ZONE = TimeZone.getTimeZone("Asia/Shanghai"); private static final long DEFAULT_BOUNDARY_PADDING = 50L; private static final long FALLBACK_SCAN_PADDING = 2000L; @@ -138,16 +138,33 @@ public class SVNLogFetcher { } public long[] getVersionRangeByMonth(int year, int month, String traceId) throws SVNException { - final String trace = traceId == null ? "" : traceId; - - final Calendar startCal = Calendar.getInstance(MONTH_TIME_ZONE); + final Calendar startCal = Calendar.getInstance(RANGE_TIME_ZONE); startCal.set(year, month - 1, 1, 0, 0, 0); startCal.set(Calendar.MILLISECOND, 0); - final long monthStart = startCal.getTimeInMillis(); final Calendar nextMonthCal = (Calendar) startCal.clone(); nextMonthCal.add(Calendar.MONTH, 1); - final long nextMonthStart = nextMonthCal.getTimeInMillis(); + + return getVersionRangeByTimeRange(startCal.getTime(), nextMonthCal.getTime(), traceId, "month"); + } + + /** + * 获取指定时间范围的版本范围(基于时间边界,不过滤用户) + * + * @param rangeStartInclusive 开始时间(包含) + * @param rangeEndExclusive 结束时间(不包含) + * @param traceId 追踪ID + * @param rangeType 范围类型 + * @return 数组 [startRevision, endRevision],如果范围内无提交返回null + * @throws SVNException SVN异常 + */ + public long[] getVersionRangeByTimeRange(Date rangeStartInclusive, + Date rangeEndExclusive, + String traceId, + String rangeType) throws SVNException { + final String trace = traceId == null ? "" : traceId; + final long rangeStart = rangeStartInclusive.getTime(); + final long rangeEnd = rangeEndExclusive.getTime(); final long latestRevision = getLatestRevision(); if (latestRevision < 1L) { @@ -155,17 +172,16 @@ public class SVNLogFetcher { return null; } - final long startAnchor = repository.getDatedRevision(new Date(monthStart)); - final long endAnchor = repository.getDatedRevision(new Date(nextMonthStart - 1L)); + final long startAnchor = repository.getDatedRevision(rangeStartInclusive); + final long endAnchor = repository.getDatedRevision(new Date(rangeEnd - 1L)); LOGGER.info( - "[SVN_VERSION_RANGE][FETCHER] traceId={} queryMonth={}-{} tz={} monthStart={} nextMonthStart={} latestRevision={} startAnchor={} endAnchor={}", + "[SVN_VERSION_RANGE][FETCHER] traceId={} rangeType={} tz={} rangeStart={} rangeEnd={} latestRevision={} startAnchor={} endAnchor={}", trace, - year, - month, - MONTH_TIME_ZONE.getID(), - formatDate(new Date(monthStart)), - formatDate(new Date(nextMonthStart)), + rangeType, + RANGE_TIME_ZONE.getID(), + formatDate(rangeStartInclusive), + formatDate(rangeEndExclusive), latestRevision, startAnchor, endAnchor @@ -179,8 +195,8 @@ public class SVNLogFetcher { long[] exactRange = findRangeInWindow( Math.max(1L, startAnchor - DEFAULT_BOUNDARY_PADDING), Math.min(latestRevision, endAnchor + DEFAULT_BOUNDARY_PADDING), - monthStart, - nextMonthStart, + rangeStart, + rangeEnd, trace, "primary" ); @@ -194,8 +210,8 @@ public class SVNLogFetcher { exactRange = findRangeInWindow( Math.max(1L, startAnchor - FALLBACK_SCAN_PADDING), Math.min(latestRevision, endAnchor + FALLBACK_SCAN_PADDING), - monthStart, - nextMonthStart, + rangeStart, + rangeEnd, trace, "fallback" ); @@ -211,8 +227,8 @@ public class SVNLogFetcher { private long[] findRangeInWindow(long fromRevision, long toRevision, - long monthStart, - long nextMonthStart, + long rangeStart, + long rangeEnd, String trace, String strategyTag) throws SVNException { if (fromRevision > toRevision) { @@ -247,7 +263,7 @@ public class SVNLogFetcher { } long logTime = logDate.getTime(); - if (logTime >= monthStart && logTime < nextMonthStart) { + if (logTime >= rangeStart && logTime < rangeEnd) { long revision = entry.getRevision(); if (revision < minRevision) { minRevision = revision; diff --git a/src/main/java/com/svnlog/web/WebApplication.java b/src/main/java/com/svnlog/web/WebApplication.java index a6c20ab..dc48156 100644 --- a/src/main/java/com/svnlog/web/WebApplication.java +++ b/src/main/java/com/svnlog/web/WebApplication.java @@ -8,12 +8,16 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import java.security.cert.X509Certificate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication(scanBasePackages = "com.svnlog") public class WebApplication { + private static final Logger LOGGER = LoggerFactory.getLogger(WebApplication.class); + static { // 配置 Java 全局 SSL 上下文(用于内网 SVN 服务器) try { @@ -26,7 +30,7 @@ public class WebApplication { .replaceAll(",\\s*TLSv1\\.1", "") .replaceAll(",\\s*TLSv1", ""); java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms); - System.out.println("TLS configuration updated: " + disabledAlgorithms); + LOGGER.info("TLS configuration updated: {}", disabledAlgorithms); } // 配置信任所有证书的 SSL 上下文 @@ -54,9 +58,9 @@ public class WebApplication { } }); - System.out.println("SSL context configured to trust all certificates"); + LOGGER.info("SSL context configured to trust all certificates"); } catch (Exception e) { - System.err.println("Warning: Failed to configure SSL context: " + e.getMessage()); + LOGGER.warn("Failed to configure SSL context: {}", e.getMessage()); } // 配置 TLS 协议版本 diff --git a/src/main/java/com/svnlog/web/controller/AppController.java b/src/main/java/com/svnlog/web/controller/AppController.java index 13f5d91..73baa59 100644 --- a/src/main/java/com/svnlog/web/controller/AppController.java +++ b/src/main/java/com/svnlog/web/controller/AppController.java @@ -95,7 +95,7 @@ public class AppController { } /** - * 查询指定月份的SVN版本范围 + * 查询指定时间范围的SVN版本范围 */ @PostMapping("/svn/version-range") public Map getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception { @@ -104,19 +104,21 @@ public class AppController { final String url = preset.getUrl(); final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials( request.getUsername(), - request.getPassword() + request.getPassword(), + request.getPresetId() ); - final int year = request.getYear().intValue(); - final int month = request.getMonth().intValue(); LOGGER.info( - "[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} year={} month={} username={} password={}", + "[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} rangeType={} year={} month={} date={} week={} username={} password={}", traceId, request.getPresetId(), preset.getName(), url, - year, - month, + request.getRangeType(), + request.getYear(), + request.getMonth(), + request.getDate(), + request.getWeek(), credentials.getUsername(), maskPassword(credentials.getPassword()) ); @@ -127,8 +129,11 @@ public class AppController { response.put("presetId", request.getPresetId()); response.put("presetName", preset.getName()); response.put("resolvedSvnUrl", url); - response.put("year", year); - response.put("month", month); + response.put("rangeType", request.getRangeType()); + response.put("year", request.getYear()); + response.put("month", request.getMonth()); + response.put("date", request.getDate()); + response.put("week", request.getWeek()); response.put("traceId", traceId); if (range != null) { response.put("startRevision", range[0]); diff --git a/src/main/java/com/svnlog/web/controller/IndexController.java b/src/main/java/com/svnlog/web/controller/IndexController.java index 40676bc..8fc1cba 100644 --- a/src/main/java/com/svnlog/web/controller/IndexController.java +++ b/src/main/java/com/svnlog/web/controller/IndexController.java @@ -14,6 +14,20 @@ public class IndexController { @GetMapping(value = {"/", "/index.html"}) public ResponseEntity index() { + return htmlResponse("static/index.html"); + } + + @GetMapping("/v2") + public String v2Redirect() { + return "redirect:/v2/"; + } + + @GetMapping("/v2/") + public ResponseEntity v2Index() { + return htmlResponse("static/v2/index.html"); + } + + private ResponseEntity htmlResponse(String classpath) { final HttpHeaders headers = new HttpHeaders(); headers.setCacheControl(CacheControl.noStore().mustRevalidate().getHeaderValue()); headers.add(HttpHeaders.PRAGMA, "no-cache"); @@ -21,6 +35,6 @@ public class IndexController { return ResponseEntity.ok() .headers(headers) .contentType(MediaType.TEXT_HTML) - .body(new ClassPathResource("static/index.html")); + .body(new ClassPathResource(classpath)); } } diff --git a/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java b/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java index 47484c0..5ad5a58 100644 --- a/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java +++ b/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java @@ -1,7 +1,6 @@ package com.svnlog.web.dto; import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; public class SvnVersionRangeRequest { @@ -12,11 +11,16 @@ public class SvnVersionRangeRequest { private String password; - @NotNull private Integer year; - @NotNull private Integer month; + + private String rangeType; + + private String date; + + private String week; + private String clientTraceId; public String getPresetId() { @@ -59,6 +63,30 @@ public class SvnVersionRangeRequest { this.month = month; } + public String getRangeType() { + return rangeType; + } + + public void setRangeType(String rangeType) { + this.rangeType = rangeType; + } + + public String getDate() { + return date; + } + + public void setDate(String date) { + this.date = date; + } + + public String getWeek() { + return week; + } + + public void setWeek(String week) { + this.week = week; + } + public String getClientTraceId() { return clientTraceId; } diff --git a/src/main/java/com/svnlog/web/model/RepositoryConfig.java b/src/main/java/com/svnlog/web/model/RepositoryConfig.java new file mode 100644 index 0000000..55d1d84 --- /dev/null +++ b/src/main/java/com/svnlog/web/model/RepositoryConfig.java @@ -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); + } +} diff --git a/src/main/java/com/svnlog/web/service/AiApiService.java b/src/main/java/com/svnlog/web/service/AiApiService.java new file mode 100644 index 0000000..0dd52ec --- /dev/null +++ b/src/main/java/com/svnlog/web/service/AiApiService.java @@ -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 usagePayload = new LinkedHashMap(); + 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 buildTextPayload(String text) { + final Map payload = new LinkedHashMap(); + payload.put("text", text); + return payload; + } + + Map buildEventPayload(String message) { + final Map payload = new LinkedHashMap(); + 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; } + } +} diff --git a/src/main/java/com/svnlog/web/service/AiWorkflowService.java b/src/main/java/com/svnlog/web/service/AiWorkflowService.java index feb11f0..4d83a99 100644 --- a/src/main/java/com/svnlog/web/service/AiWorkflowService.java +++ b/src/main/java/com/svnlog/web/service/AiWorkflowService.java @@ -1,7 +1,6 @@ package com.svnlog.web.service; import java.io.IOException; -import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -13,25 +12,9 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; -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; @@ -39,23 +22,14 @@ import org.springframework.stereotype.Service; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import com.svnlog.web.dto.AiAnalyzeRequest; import com.svnlog.web.model.TaskResult; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - @Service public class AiWorkflowService { + private static final Logger LOGGER = LoggerFactory.getLogger(AiWorkflowService.class); - private 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 String DEFAULT_TEAM = "系统部"; private static final String DEFAULT_CONTACT = "杨志强\n(系统平台组)"; private static final String DEFAULT_DEVELOPER = "刘靖"; @@ -76,33 +50,23 @@ public class AiWorkflowService { private static final Pattern REVISION_REF_PATTERN = Pattern.compile("r\\d+"); private static final Pattern TOTAL_RECORDS_PATTERN = Pattern.compile("总记录数\\*\\*:\\s*(\\d+)\\s*条"); private static final Pattern REVISION_HEADING_PATTERN = Pattern.compile("^###\\s+r\\d+.*$"); - private static final int STREAM_PERSIST_INTERVAL = 8; private static final int SUMMARY_RECORDS_PER_ITEM = 4; private static final int SUMMARY_MIN_ITEMS = 2; private static final int SUMMARY_MAX_ITEMS = 200; - 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 EXCEL_CELL_MAX_LENGTH = 32767; - - private final OkHttpClient httpClient = new OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .readTimeout(180, TimeUnit.SECONDS) - .build(); private final OutputFileService outputFileService; - private final SettingsService settingsService; private final AiInputValidator aiInputValidator; - private final RetrySupport retrySupport = new RetrySupport(); + private final AiApiService aiApiService; + private final ExcelExportService excelExportService; public AiWorkflowService(OutputFileService outputFileService, - SettingsService settingsService, - AiInputValidator aiInputValidator) { + AiInputValidator aiInputValidator, + AiApiService aiApiService, + ExcelExportService excelExportService) { this.outputFileService = outputFileService; - this.settingsService = settingsService; this.aiInputValidator = aiInputValidator; + this.aiApiService = aiApiService; + this.excelExportService = excelExportService; } public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception { @@ -119,7 +83,7 @@ public class AiWorkflowService { final Map> projectCommits = buildProjectCommitMap(content); final Map projectLogCounts = buildProjectLogCountsFromCommits(projectCommits); final Map projectMinItems = buildProjectMinItems(projectLogCounts); - final AiProviderContext providerContext = resolveProviderContext(request.getApiKey()); + final AiApiService.AiProviderContext providerContext = aiApiService.resolveProviderContext(request.getApiKey()); context.setProgress(35, "正在请求 AI 分析"); final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty() @@ -139,24 +103,19 @@ public class AiWorkflowService { List stageOneItems = new ArrayList(); try { final String stageOnePrompt = buildProjectCompressionPrompt(project, sourceCommits, period, targetItems); - final String stageOneResponse = callAi(providerContext, stageOnePrompt, context, 1); + final String stageOneResponse = aiApiService.callAi(providerContext, stageOnePrompt, context, 1); stageOneItems = parseProjectSummaryItems(stageOneResponse, project); } catch (Exception ex) { LOGGER.warn("{} stage one failed, fallback to local compression: project={}", - providerContext.displayName, - project, - ex); - context.emitEvent("phase", buildEventPayload( - providerContext.displayName + " 压缩失败,已切换本地压缩: " + project - )); + providerContext.getProvider(), project, ex); + context.emitEvent("phase", phasePayload( + providerContext.getProvider() + " 压缩失败,已切换本地压缩: " + project)); } if (stageOneItems.isEmpty()) { final LinkedHashSet localStageOne = buildLocalSummariesFromCommits( - project, - sourceCommits, - Math.max(targetItems, Math.min(targetItems * 2, sourceCommits == null ? 0 : sourceCommits.size())) - ); + project, sourceCommits, + Math.max(targetItems, Math.min(targetItems * 2, sourceCommits == null ? 0 : sourceCommits.size()))); for (String item : localStageOne) { stageOneItems.add(new ProjectSummaryItem(item, new LinkedHashSet())); } @@ -165,17 +124,14 @@ public class AiWorkflowService { context.setProgress(stepProgress + 5, "正在执行 Think 精炼总结: " + project); try { final String stageTwoPrompt = buildProjectRefinePrompt(project, period, targetItems, sourceCommits, stageOneItems); - final String stageTwoResponse = callAi(providerContext, stageTwoPrompt, context, 2); + final String stageTwoResponse = aiApiService.callAi(providerContext, stageTwoPrompt, context, 2); final List stageTwoItems = parseProjectSummaryItems(stageTwoResponse, project); groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageTwoItems, targetItems)); } catch (Exception ex) { LOGGER.warn("{} stage two failed, fallback to stage one merge: project={}", - providerContext.displayName, - project, - ex); - context.emitEvent("phase", buildEventPayload( - providerContext.displayName + " 精炼失败,使用阶段一压缩结果: " + project - )); + providerContext.getProvider(), project, ex); + context.emitEvent("phase", phasePayload( + providerContext.getProvider() + " 精炼失败,使用阶段一压缩结果: " + project)); groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageOneItems, targetItems)); } } @@ -189,7 +145,10 @@ public class AiWorkflowService { final String relative = "excel/" + filename; final Path outputFile = outputFileService.resolveInOutput(relative); Files.createDirectories(outputFile.getParent()); - writeExcel(outputFile, payload, period); + + final String excelContent = buildContentFromPayload(payload); + excelExportService.writeExcel(outputFile, excelContent, period, + DEFAULT_TEAM, DEFAULT_CONTACT, DEFAULT_DEVELOPER, FIXED_PROJECT_VALUE); context.setProgress(100, "AI 分析已完成"); final TaskResult result = new TaskResult("工作量统计已生成"); @@ -197,6 +156,8 @@ public class AiWorkflowService { return result; } + // ========== File reading & resolution ========== + private String readMarkdownFiles(List filePaths) throws IOException { final StringBuilder builder = new StringBuilder(); for (Path path : filePaths) { @@ -208,10 +169,8 @@ public class AiWorkflowService { } private List resolveUserFiles(List userPaths) throws IOException { - java.util.ArrayList files = new java.util.ArrayList(); - if (userPaths == null) { - return files; - } + final List files = new ArrayList(); + if (userPaths == null) return files; for (String userPath : userPaths) { files.add(resolveUserFile(userPath)); } @@ -228,13 +187,11 @@ public class AiWorkflowService { final Path rootPath = Paths.get("").toAbsolutePath().normalize(); final Path docsRoot = rootPath.resolve("docs").normalize(); - // 优先按输出目录相对路径解析(例如 md/*.md、excel/*.xlsx) final Path outputCandidate = outputFileService.resolveInOutput(normalizedInput); if (Files.exists(outputCandidate) && Files.isRegularFile(outputCandidate)) { return outputCandidate; } - // 兼容绝对路径或历史路径输入,但仍限制在允许目录内 final Path raw = Paths.get(normalizedInput); final Path candidate = raw.isAbsolute() ? raw.normalize() : rootPath.resolve(raw).normalize(); @@ -244,120 +201,90 @@ public class AiWorkflowService { } } - final boolean outputCandidateExists = Files.exists(outputCandidate); - final boolean outputCandidateIsFile = outputCandidateExists && Files.isRegularFile(outputCandidate); - final boolean rootCandidateExists = Files.exists(candidate); - final boolean rootCandidateIsFile = rootCandidateExists && Files.isRegularFile(candidate); - throw new IllegalArgumentException( "文件不存在或不在允许目录:" + normalizedInput + " | outputCandidate=" + outputCandidate - + " (exists=" + outputCandidateExists + ", file=" + outputCandidateIsFile + ")" + + " (exists=" + Files.exists(outputCandidate) + + ", file=" + (Files.exists(outputCandidate) && Files.isRegularFile(outputCandidate)) + ")" + " | rootCandidate=" + candidate - + " (exists=" + rootCandidateExists + ", file=" + rootCandidateIsFile + ")" + + " (exists=" + Files.exists(candidate) + + ", file=" + (Files.exists(candidate) && Files.isRegularFile(candidate)) + ")" + " | outputRoot=" + outputRoot - + " | docsRoot=" + docsRoot - ); + + " | docsRoot=" + docsRoot); } private String joinPaths(List paths) { - if (paths == null || paths.isEmpty()) { - return "(empty)"; - } + if (paths == null || paths.isEmpty()) return "(empty)"; final StringBuilder builder = new StringBuilder(); for (int i = 0; i < paths.size(); i++) { - if (i > 0) { - builder.append(", "); - } + if (i > 0) builder.append(", "); builder.append(paths.get(i)); } return builder.toString(); } private String joinResolvedPaths(List paths) { - if (paths == null || paths.isEmpty()) { - return "(empty)"; - } + if (paths == null || paths.isEmpty()) return "(empty)"; final StringBuilder builder = new StringBuilder(); for (int i = 0; i < paths.size(); i++) { - if (i > 0) { - builder.append(", "); - } + if (i > 0) builder.append(", "); builder.append(paths.get(i).toString()); } return builder.toString(); } + // ========== Prompt building ========== + private String buildProjectCompressionPrompt(String project, - List sourceCommits, - String period, - int targetItems) { + List sourceCommits, + String period, int targetItems) { final StringBuilder workItems = new StringBuilder(); final LinkedHashSet revisionSet = new LinkedHashSet(); int index = 1; if (sourceCommits != null) { for (CommitEntry entry : sourceCommits) { - if (entry == null || entry.message.trim().isEmpty()) { - continue; - } + if (entry == null || entry.message.trim().isEmpty()) continue; revisionSet.add(entry.revision); - workItems.append(index++) - .append(". [") - .append(entry.revision) - .append("] ") - .append(entry.message.trim()) - .append('\n'); + workItems.append(index++).append(". [").append(entry.revision) + .append("] ").append(entry.message.trim()).append('\n'); } } if (workItems.length() == 0) { workItems.append("1. 本项目本周期存在代码提交,请按变更点进行归纳。\n"); } final String allRevisions = revisionSet.isEmpty() ? "(none)" : joinValues(revisionSet, ", "); - - final int compressedTarget = Math.max(targetItems, Math.min(Math.max(targetItems * 2, 6), Math.max(targetItems, revisionSet.size()))); - - return "你是项目管理助手,请仅根据以下“提交信息”先做压缩归纳。\n" - + "工作周期: " + period + "\n" - + "项目: " + project + "\n" + final int compressedTarget = Math.max(targetItems, + Math.min(Math.max(targetItems * 2, 6), Math.max(targetItems, revisionSet.size()))); + return "你是项目管理助手,请仅根据以下\"提交信息\"先做压缩归纳。\n" + + "工作周期: " + period + "\n项目: " + project + "\n" + "目标压缩条数: " + Math.max(1, compressedTarget) + "\n" + "总提交数: " + revisionSet.size() + "\n" + "必须覆盖全部 revision(不可遗漏): " + allRevisions + "\n" - + "要求:\n" - + "1. 仅输出 JSON,不要输出额外文字\n" + + "要求:\n1. 仅输出 JSON,不要输出额外文字\n" + "2. 不能包含 SVN 地址、账号、版本范围、作者、时间等元信息\n" + "3. 不要逐条复述提交,要把相近提交合并成压缩总结\n" + "4. 返回结构固定:{\"project\":\"" + project + "\",\"items\":[{\"summary\":\"...\",\"sources\":[\"r123\"]}]}\n" + "5. items 里每条都必须给出 sources;sources 只能来自上面给定 revision 列表\n" + "6. summary 仅描述功能开发/优化/修复,不输出流水账\n" - + "提交信息列表:\n" - + workItems.toString(); + + "提交信息列表:\n" + workItems.toString(); } - private String buildProjectRefinePrompt(String project, - String period, - int targetItems, - List sourceCommits, - List stageOneItems) { + private String buildProjectRefinePrompt(String project, String period, int targetItems, + List sourceCommits, + List stageOneItems) { final LinkedHashSet revisionSet = new LinkedHashSet(); if (sourceCommits != null) { for (CommitEntry commit : sourceCommits) { - if (commit == null || commit.revision == null || commit.revision.trim().isEmpty()) { - continue; - } + if (commit == null || commit.revision == null || commit.revision.trim().isEmpty()) continue; revisionSet.add(commit.revision.trim()); } } - final StringBuilder stageOneBuilder = new StringBuilder(); int index = 1; if (stageOneItems != null) { for (ProjectSummaryItem item : stageOneItems) { - if (item == null || item.summary == null || item.summary.trim().isEmpty()) { - continue; - } - stageOneBuilder.append(index++) - .append(". ") - .append(item.summary.trim()) + if (item == null || item.summary == null || item.summary.trim().isEmpty()) continue; + stageOneBuilder.append(index++).append(". ").append(item.summary.trim()) .append(" | sources=") .append(item.sources == null || item.sources.isEmpty() ? "[]" : joinValues(item.sources, ",")) .append('\n'); @@ -366,54 +293,41 @@ public class AiWorkflowService { if (stageOneBuilder.length() == 0) { stageOneBuilder.append("1. 本项目存在有效提交,请按目标条数进行归纳。\n"); } - - return "你是项目管理助手,请将“阶段一压缩结果”精炼为固定条数总结。\n" - + "工作周期: " + period + "\n" - + "项目: " + project + "\n" + return "你是项目管理助手,请将\"阶段一压缩结果\"精炼为固定条数总结。\n" + + "工作周期: " + period + "\n项目: " + project + "\n" + "最终条数(必须严格等于): " + Math.max(1, targetItems) + "\n" + "必须覆盖全部 revision(不可遗漏): " - + (revisionSet.isEmpty() ? "(none)" : joinValues(revisionSet, ", ")) - + "\n" - + "要求:\n" - + "1. 仅输出 JSON,不要输出额外文字\n" + + (revisionSet.isEmpty() ? "(none)" : joinValues(revisionSet, ", ")) + "\n" + + "要求:\n1. 仅输出 JSON,不要输出额外文字\n" + "2. 返回结构固定:{\"project\":\"" + project + "\",\"items\":[{\"summary\":\"...\",\"sources\":[\"r123\"]}]}\n" + "3. items 数量必须严格等于最终条数\n" + "4. 每条都必须有 sources,且 sources 仅可使用给定 revision\n" + "5. 不要逐条抄写提交,必须做合并归纳\n" - + "阶段一压缩结果:\n" - + stageOneBuilder.toString(); + + "阶段一压缩结果:\n" + stageOneBuilder.toString(); } + // ========== AI response parsing ========== + private List parseProjectSummaryItems(String aiResponse, String project) { final List items = new ArrayList(); - final JsonObject object = extractJson(aiResponse); + final JsonObject object = aiApiService.extractJson(aiResponse); if (object.has("items") && object.get("items").isJsonArray()) { final JsonArray rawItems = object.getAsJsonArray("items"); for (JsonElement item : rawItems) { - if (item == null || item.isJsonNull()) { - continue; - } + if (item == null || item.isJsonNull()) continue; if (item.isJsonObject()) { final JsonObject jsonItem = item.getAsJsonObject(); final String summary = cleanWorkItem( - firstNonBlank( - firstNonBlank( - optString(jsonItem, "summary"), - optString(jsonItem, "content") - ), - optString(jsonItem, "text") - ) - ); + firstNonBlank(firstNonBlank( + aiApiService.optString(jsonItem, "summary"), + aiApiService.optString(jsonItem, "content")), + aiApiService.optString(jsonItem, "text"))); final LinkedHashSet sources = parseSources(jsonItem.get("sources")); - if (!summary.isEmpty()) { - items.add(new ProjectSummaryItem(summary, sources)); - } + if (!summary.isEmpty()) items.add(new ProjectSummaryItem(summary, sources)); } else { final String summary = cleanWorkItem(item.getAsString()); - if (!summary.isEmpty()) { - items.add(new ProjectSummaryItem(summary, new LinkedHashSet())); - } + if (!summary.isEmpty()) items.add(new ProjectSummaryItem(summary, new LinkedHashSet())); } } } @@ -433,14 +347,10 @@ public class AiWorkflowService { if (object.has("records") && object.get("records").isJsonArray()) { final JsonArray records = object.getAsJsonArray("records"); for (JsonElement element : records) { - if (element == null || !element.isJsonObject()) { - continue; - } + if (element == null || !element.isJsonObject()) continue; final JsonObject record = element.getAsJsonObject(); - final String content = optString(record, "content"); - if (content == null || content.trim().isEmpty()) { - continue; - } + final String content = aiApiService.optString(record, "content"); + if (content == null || content.trim().isEmpty()) continue; final Map> tmp = createGroupedItems(); collectItems(tmp, project, content); final LinkedHashSet parsed = tmp.get(project); @@ -454,19 +364,17 @@ public class AiWorkflowService { return items; } + // ========== Merge & normalize ========== + private LinkedHashSet mergeProjectItems(String project, - List sourceCommits, - List aiItems, - int targetItems) { + List sourceCommits, + List aiItems, + int targetItems) { final LinkedHashMap revisionToMessage = new LinkedHashMap(); if (sourceCommits != null) { for (CommitEntry commit : sourceCommits) { - if (commit == null || commit.revision == null || commit.revision.isEmpty()) { - continue; - } - if (commit.message == null || commit.message.trim().isEmpty()) { - continue; - } + if (commit == null || commit.revision == null || commit.revision.isEmpty()) continue; + if (commit.message == null || commit.message.trim().isEmpty()) continue; revisionToMessage.put(commit.revision, commit.message); } } @@ -477,14 +385,10 @@ public class AiWorkflowService { final LinkedHashSet aiItemsWithoutSources = new LinkedHashSet(); if (aiItems != null) { for (ProjectSummaryItem aiItem : aiItems) { - if (aiItem == null || aiItem.summary == null || aiItem.summary.trim().isEmpty()) { - continue; - } + if (aiItem == null || aiItem.summary == null || aiItem.summary.trim().isEmpty()) continue; final LinkedHashSet validSources = new LinkedHashSet(); for (String source : aiItem.sources) { - if (allRevisions.contains(source)) { - validSources.add(source); - } + if (allRevisions.contains(source)) validSources.add(source); } if (validSources.isEmpty()) { aiItemsWithoutSources.add(cleanWorkItem(aiItem.summary)); @@ -505,15 +409,12 @@ public class AiWorkflowService { final List uncoveredCommits = new ArrayList(); for (String revision : uncovered) { final String message = revisionToMessage.get(revision); - if (message == null || message.trim().isEmpty()) { - continue; - } + if (message == null || message.trim().isEmpty()) continue; uncoveredCommits.add(new CommitEntry(revision, message)); } if (!uncoveredCommits.isEmpty()) { merged.addAll(buildLocalSummariesFromCommits(project, uncoveredCommits, Math.max(1, targetItems))); } - if (merged.isEmpty()) { merged.addAll(buildCommitFallbackItems(project, sourceCommits, targetItems)); } @@ -521,26 +422,20 @@ public class AiWorkflowService { } private LinkedHashSet buildCommitFallbackItems(String project, List sourceCommits, int targetItems) { - final LinkedHashSet items = buildLocalSummariesFromCommits(project, sourceCommits, Math.max(1, targetItems)); - return normalizeToTargetItemCount(project, items, targetItems); + return normalizeToTargetItemCount(project, + buildLocalSummariesFromCommits(project, sourceCommits, Math.max(1, targetItems)), targetItems); } - private LinkedHashSet buildLocalSummariesFromCommits(String project, - List commits, - int targetItems) { + private LinkedHashSet buildLocalSummariesFromCommits(String project, List commits, int targetItems) { final LinkedHashSet items = new LinkedHashSet(); final List messages = new ArrayList(); if (commits != null) { for (CommitEntry entry : commits) { - if (entry == null || entry.message == null || entry.message.trim().isEmpty()) { - continue; - } + if (entry == null || entry.message == null || entry.message.trim().isEmpty()) continue; messages.add(entry.message.trim()); } } - if (messages.isEmpty()) { - return items; - } + if (messages.isEmpty()) return items; final int summaryCount = Math.max(1, Math.min(Math.max(1, targetItems), messages.size())); final int chunkSize = Math.max(1, (messages.size() + summaryCount - 1) / summaryCount); @@ -550,51 +445,37 @@ public class AiWorkflowService { final LinkedHashSet condensed = new LinkedHashSet(); for (int j = i; j < to; j++) { final String value = condenseMessageForSummary(messages.get(j)); - if (!value.isEmpty()) { - condensed.add(value); - } - } - if (condensed.isEmpty()) { - continue; + if (!value.isEmpty()) condensed.add(value); } + if (condensed.isEmpty()) continue; final String summary = joinValues(condensed, ";"); items.add(cleanWorkItem(summary)); - if (items.size() >= summaryCount) { - break; - } + if (items.size() >= summaryCount) break; } return items; } - private LinkedHashSet normalizeToTargetItemCount(String project, - LinkedHashSet rawItems, - int targetItems) { + private LinkedHashSet normalizeToTargetItemCount(String project, LinkedHashSet rawItems, int targetItems) { final int safeTarget = Math.max(1, targetItems); final List values = new ArrayList(); if (rawItems != null) { for (String item : rawItems) { final String cleaned = cleanWorkItem(item); - if (!cleaned.isEmpty()) { - values.add(cleaned); - } + if (!cleaned.isEmpty()) values.add(cleaned); } } final LinkedHashSet result = new LinkedHashSet(); if (values.isEmpty()) { int index = 1; - while (result.size() < safeTarget) { - result.add(buildFallbackItem(project, index++)); - } + while (result.size() < safeTarget) result.add(buildFallbackItem(project, index++)); return result; } if (values.size() <= safeTarget) { result.addAll(values); int index = 1; - while (result.size() < safeTarget) { - result.add(buildFallbackItem(project, index++)); - } + while (result.size() < safeTarget) result.add(buildFallbackItem(project, index++)); return result; } @@ -602,28 +483,18 @@ public class AiWorkflowService { for (int i = 0; i < values.size(); i += chunkSize) { final int to = Math.min(values.size(), i + chunkSize); final LinkedHashSet condensed = new LinkedHashSet(); - for (int j = i; j < to; j++) { - condensed.add(condenseMessageForSummary(values.get(j))); - } + for (int j = i; j < to; j++) condensed.add(condenseMessageForSummary(values.get(j))); final String merged = cleanWorkItem(joinValues(condensed, ";")); - if (!merged.isEmpty()) { - result.add(merged); - } - if (result.size() >= safeTarget) { - break; - } + if (!merged.isEmpty()) result.add(merged); + if (result.size() >= safeTarget) break; } int index = 1; - while (result.size() < safeTarget) { - result.add(buildFallbackItem(project, index++)); - } + while (result.size() < safeTarget) result.add(buildFallbackItem(project, index++)); return result; } private String condenseMessageForSummary(String message) { - if (message == null) { - return ""; - } + if (message == null) return ""; String value = message.trim(); value = value.replaceAll("(?i)^(feat|fix|chore|refactor|test|docs)(\\([^)]*\\))?:\\s*", ""); value = value.replaceAll("\\s+", " "); @@ -631,16 +502,14 @@ public class AiWorkflowService { return cleanWorkItem(value); } + // ========== Source parsing ========== + private LinkedHashSet parseSources(JsonElement element) { final LinkedHashSet sources = new LinkedHashSet(); - if (element == null || element.isJsonNull()) { - return sources; - } + if (element == null || element.isJsonNull()) return sources; if (element.isJsonArray()) { for (JsonElement source : element.getAsJsonArray()) { - if (source == null || source.isJsonNull()) { - continue; - } + if (source == null || source.isJsonNull()) continue; addRevisionFromText(sources, source.getAsString()); } return sources; @@ -650,40 +519,30 @@ public class AiWorkflowService { } private void addRevisionFromText(LinkedHashSet sources, String text) { - if (sources == null || text == null || text.trim().isEmpty()) { - return; - } + if (sources == null || text == null || text.trim().isEmpty()) return; final Matcher matcher = REVISION_REF_PATTERN.matcher(text); - while (matcher.find()) { - sources.add(matcher.group().toLowerCase()); - } + while (matcher.find()) sources.add(matcher.group().toLowerCase()); } + // ========== Commit map building ========== + private Map> buildProjectCommitMap(String markdownContent) { final Map> grouped = createProjectCommitMap(); - if (markdownContent == null || markdownContent.trim().isEmpty()) { - return grouped; - } + if (markdownContent == null || markdownContent.trim().isEmpty()) return grouped; final Matcher fileMatcher = FILE_SECTION_PATTERN.matcher(markdownContent); while (fileMatcher.find()) { final String filename = fileMatcher.group(1); final String section = fileMatcher.group(2); final String project = normalizeProject(filename); - if (project == null) { - continue; - } + if (project == null) continue; final List commits = grouped.get(project); - if (commits == null) { - continue; - } + if (commits == null) continue; final Matcher commitMatcher = COMMIT_ENTRY_PATTERN.matcher(section == null ? "" : section); while (commitMatcher.find()) { final String revision = "r" + commitMatcher.group(1); final String message = normalizeCommitMessage(commitMatcher.group(2)); - if (message.isEmpty()) { - continue; - } + if (message.isEmpty()) continue; commits.add(new CommitEntry(revision, message)); } } @@ -701,34 +560,26 @@ public class AiWorkflowService { private Map> createProjectCommitMap() { final Map> map = new LinkedHashMap>(); - for (String project : FIXED_PROJECTS) { - map.put(project, new ArrayList()); - } + for (String project : FIXED_PROJECTS) map.put(project, new ArrayList()); return map; } private String normalizeCommitMessage(String raw) { - if (raw == null) { - return ""; - } + if (raw == null) return ""; String cleaned = raw.replace("\r", "\n").trim(); cleaned = cleaned.replaceAll("(?m)^\\s*[-*]\\s*", ""); cleaned = cleaned.replaceAll("\\s+", " "); return cleanWorkItem(cleaned); } + // ========== Utility helpers ========== + private String joinValues(Iterable values, String delimiter) { final StringBuilder builder = new StringBuilder(); - if (values == null) { - return ""; - } + if (values == null) return ""; for (String value : values) { - if (value == null || value.trim().isEmpty()) { - continue; - } - if (builder.length() > 0) { - builder.append(delimiter); - } + if (value == null || value.trim().isEmpty()) continue; + if (builder.length() > 0) builder.append(delimiter); builder.append(value.trim()); } return builder.toString(); @@ -740,7 +591,6 @@ public class AiWorkflowService { payload.addProperty("contact", DEFAULT_CONTACT); payload.addProperty("developer", DEFAULT_DEVELOPER); payload.addProperty("period", period); - final JsonArray records = new JsonArray(); final JsonObject record = new JsonObject(); record.addProperty("sequence", 1); @@ -751,46 +601,18 @@ public class AiWorkflowService { return payload; } - private Map buildProjectLogCounts(List markdownFiles) throws IOException { - final Map counts = new LinkedHashMap(); - for (String project : FIXED_PROJECTS) { - counts.put(project, Integer.valueOf(0)); - } - for (Path path : markdownFiles) { - final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - final String project = normalizeProject(path.getFileName().toString()); - if (project == null) { - continue; - } - int count = extractTotalRecordsCount(content); - if (count <= 0) { - count = estimateRevisionCount(content); - } - counts.put(project, Integer.valueOf(Math.max(count, 0))); - } - return counts; - } - private Map buildProjectMinItems(Map projectLogCounts) { final Map minimums = new LinkedHashMap(); for (String project : FIXED_PROJECTS) { final int logCount = projectLogCounts.containsKey(project) - ? projectLogCounts.get(project).intValue() - : 0; + ? projectLogCounts.get(project).intValue() : 0; minimums.put(project, Integer.valueOf(computeRequiredSummaryItems( - logCount, - SUMMARY_RECORDS_PER_ITEM, - SUMMARY_MIN_ITEMS, - SUMMARY_MAX_ITEMS - ))); + logCount, SUMMARY_RECORDS_PER_ITEM, SUMMARY_MIN_ITEMS, SUMMARY_MAX_ITEMS))); } return minimums; } - private int computeRequiredSummaryItems(int totalRecords, - int recordsPerItem, - int minItems, - int maxItems) { + private int computeRequiredSummaryItems(int totalRecords, int recordsPerItem, int minItems, int maxItems) { final int safePerItem = Math.max(1, recordsPerItem); final int safeMin = Math.max(0, minItems); final int safeMax = Math.max(safeMin, maxItems); @@ -798,320 +620,209 @@ public class AiWorkflowService { return Math.max(safeMin, Math.min(raw, safeMax)); } - private int extractTotalRecordsCount(String markdown) { - if (markdown == null || markdown.trim().isEmpty()) { - return 0; + private String buildContentFromPayload(JsonObject payload) { + final Map> groupedItems = createGroupedItems(); + final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray(); + for (JsonElement element : records) { + if (element == null || !element.isJsonObject()) continue; + final JsonObject record = element.getAsJsonObject(); + final String recordProject = record.has("project") && !record.get("project").isJsonNull() + ? record.get("project").getAsString() : ""; + final String recordContent = record.has("content") && !record.get("content").isJsonNull() + ? record.get("content").getAsString() : ""; + collectItems(groupedItems, recordProject, recordContent); } - final Matcher matcher = TOTAL_RECORDS_PATTERN.matcher(markdown); - if (matcher.find()) { - try { - return Integer.parseInt(matcher.group(1)); - } catch (Exception ignored) { - return 0; - } + + final StringBuilder builder = new StringBuilder(); + for (String project : FIXED_PROJECTS) { + final LinkedHashSet items = groupedItems.get(project); + if (items == null || items.isEmpty()) continue; + if (builder.length() > 0) builder.append("\n\n"); + builder.append(project).append('\n'); + int index = 1; + for (String item : items) builder.append(index++).append(". ").append(item).append('\n'); } - return 0; + return builder.toString().trim(); } - private int estimateRevisionCount(String markdown) { - if (markdown == null || markdown.trim().isEmpty()) { - return 0; + private void enforceMinimumSummaryItems(JsonObject payload, String markdownContent, + Map projectMinItems) { + final Map> groupedFromPayload = createGroupedItems(); + final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray(); + for (JsonElement element : records) { + if (element == null || !element.isJsonObject()) continue; + final JsonObject record = element.getAsJsonObject(); + collectItems(groupedFromPayload, + record.has("project") && !record.get("project").isJsonNull() + ? record.get("project").getAsString() : "", + record.has("content") && !record.get("content").isJsonNull() + ? record.get("content").getAsString() : ""); } - int count = 0; - final String[] lines = markdown.split("\\r?\\n"); - for (String line : lines) { - if (line == null) { + + final Map> groupedFromMarkdown = parseItemsFromMarkdown(markdownContent); + for (String project : FIXED_PROJECTS) { + final int targetItems = projectMinItems.containsKey(project) + ? projectMinItems.get(project).intValue() : 2; + final LinkedHashSet current = groupedFromPayload.get(project); + final LinkedHashSet source = groupedFromMarkdown.get(project); + if (current == null) continue; + if (current.isEmpty() && source != null) { + for (String item : source) current.add(item); + } + final LinkedHashSet normalized = normalizeToTargetItemCount(project, current, targetItems); + current.clear(); + current.addAll(normalized); + } + applyGroupedItemsToPayload(payload, groupedFromPayload); + } + + private Map> parseItemsFromMarkdown(String markdownContent) { + final Map> groupedItems = createGroupedItems(); + String currentProject = null; + final String[] lines = markdownContent == null ? new String[0] : markdownContent.split("\\r?\\n"); + for (String rawLine : lines) { + final String line = rawLine == null ? "" : rawLine.trim(); + if (line.isEmpty()) continue; + if (line.startsWith("=== 文件:")) { + currentProject = normalizeProject(line); continue; } - if (REVISION_HEADING_PATTERN.matcher(line.trim()).matches()) { - count++; - } + final String headingProject = parseHeadingProject(line); + if (headingProject != null) { currentProject = headingProject; continue; } + final String extracted = parseWorkItem(line); + if (extracted == null || extracted.isEmpty()) continue; + final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject; + groupedItems.get(targetProject).add(extracted); } - return count; + return groupedItems; } - String normalizeChatCompletionsUrl(String baseUrl) { - final String normalized = baseUrl == null ? "" : baseUrl.trim(); - if (normalized.isEmpty()) { - return ""; + private void applyGroupedItemsToPayload(JsonObject payload, Map> groupedItems) { + final StringBuilder mergedContent = new StringBuilder(); + for (String project : FIXED_PROJECTS) { + final LinkedHashSet items = groupedItems.get(project); + if (items == null || items.isEmpty()) continue; + if (mergedContent.length() > 0) mergedContent.append("\n\n"); + mergedContent.append(project).append('\n'); + int index = 1; + for (String item : items) mergedContent.append(index++).append(". ").append(item).append('\n'); } - String value = normalized; - while (value.endsWith("/")) { - value = value.substring(0, value.length() - 1); + final String merged = mergedContent.toString().trim(); + + JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : null; + if (records == null) { records = new JsonArray(); payload.add("records", records); } + JsonObject record; + if (records.size() > 0 && records.get(0).isJsonObject()) { + record = records.get(0).getAsJsonObject(); + } else { + record = new JsonObject(); + record.addProperty("sequence", 1); + records.add(record); } - if (value.endsWith("/chat/completions")) { - return value; - } - return value + "/chat/completions"; + record.addProperty("project", FIXED_PROJECT_VALUE); + record.addProperty("content", merged); } - 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 - ); + private String buildFallbackItem(String project, int index) { + return "补充总结" + index + ":基于" + project + "本周期提交记录提炼的功能优化与问题修复工作"; } - private 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; - } + private Map> createGroupedItems() { + final Map> groupedItems = + new LinkedHashMap>(); + for (String project : FIXED_PROJECTS) groupedItems.put(project, new LinkedHashSet()); + return groupedItems; + } - // 输出达到 token 上限但 JSON 已完整时直接使用,避免误报失败。 - 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 void collectItems(Map> groupedItems, String projectText, String content) { + final String fallbackProject = normalizeProject(projectText); + String currentProject = fallbackProject; + final String[] lines = content == null ? new String[0] : content.split("\\r?\\n"); + for (String rawLine : lines) { + final String line = rawLine == null ? "" : rawLine.trim(); + if (line.isEmpty()) continue; + final String headingProject = parseHeadingProject(line); + if (headingProject != null) { currentProject = headingProject; continue; } + final String extracted = parseWorkItem(line); + if (extracted == null || extracted.isEmpty()) continue; + final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject; + groupedItems.get(targetProject).add(extracted); } } - 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 (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); + private String parseHeadingProject(String line) { + if (line.startsWith("#")) { + final String stripped = line.replaceFirst("^#+\\s*", ""); + return normalizeProject(stripped); } + return normalizeProject(line); } - 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 usagePayload = new LinkedHashMap(); - 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 - + " | api_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); + private String parseWorkItem(String line) { + if (isMetaLine(line)) return null; + Matcher matcher = NUMBERED_ITEM_PATTERN.matcher(line); + if (matcher.matches()) return cleanWorkItem(matcher.group(2)); + matcher = BULLET_ITEM_PATTERN.matcher(line); + if (matcher.matches()) return cleanWorkItem(matcher.group(1)); + matcher = REVISION_ITEM_PATTERN.matcher(line); + if (matcher.matches()) return cleanWorkItem(matcher.group(1)); + if (line.length() > 6 && !line.startsWith("=") && !line.startsWith("```")) + return cleanWorkItem(line); + return null; } - private boolean isValidJsonObjectText(String text) { - if (text == null || text.trim().isEmpty()) { - return false; - } - try { - extractJson(text); - return true; - } catch (Exception ignored) { - return false; - } + private boolean isMetaLine(String line) { + return line.startsWith("SVN") || line.startsWith("仓库") || line.startsWith("分支") + || line.startsWith("版本") || line.startsWith("提交总数") || line.startsWith("日志详情") + || line.startsWith("作者") || line.startsWith("时间") || line.startsWith("消息") + || line.startsWith("文件") || line.startsWith("=== 文件:"); } + private String cleanWorkItem(String item) { + String cleaned = item == null ? "" : item.trim(); + cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", ""); + cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", ""); + cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", ""); + cleaned = cleaned.replaceAll("\\s+", " "); + return cleaned.trim(); + } + + private String normalizeProject(String value) { + if (value == null) return null; + final String input = value.trim(); + if (input.isEmpty()) return null; + if (input.contains("7050")) return "PRS-7050场站智慧管控"; + if (input.contains("电科院")) return "PRS-7950在线巡视电科院测试版"; + if (input.contains("7950")) return "PRS-7950在线巡视"; + return null; + } + + private String buildOutputFilename(String outputFileName) { + if (outputFileName != null && !outputFileName.trim().isEmpty()) { + String name = outputFileName.trim(); + if (!name.toLowerCase().endsWith(".xlsx")) name = name + ".xlsx"; + return sanitize(name); + } + return new SimpleDateFormat("yyyyMM").format(new Date()) + "工作量统计.xlsx"; + } + + private String sanitize(String value) { + return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_"); + } + + private String firstNonBlank(String preferred, String fallback) { + if (preferred != null && !preferred.trim().isEmpty()) return preferred.trim(); + return fallback == null ? "" : fallback; + } + + private Map phasePayload(String message) { + final Map payload = new LinkedHashMap(); + payload.put("message", message); + return payload; + } + + // ========== Inner classes ========== + private static final class CommitEntry { private final String revision; private final String message; @@ -1131,569 +842,4 @@ public class AiWorkflowService { this.sources = sources == null ? new LinkedHashSet() : sources; } } - - 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; - - private 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; - - private AiStreamResult(String answer, String finishReason) { - this.answer = answer == null ? "" : answer; - this.finishReason = finishReason == null ? "" : finishReason; - } - - String getAnswer() { - return answer; - } - - String getFinishReason() { - return finishReason; - } - } - - private Map buildTextPayload(String text) { - final Map payload = new LinkedHashMap(); - payload.put("text", text); - return payload; - } - - private Map buildEventPayload(String message) { - final Map payload = new LinkedHashMap(); - 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; - } - } - - private 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 String buildOutputFilename(String outputFileName) { - if (outputFileName != null && !outputFileName.trim().isEmpty()) { - String name = outputFileName.trim(); - if (!name.toLowerCase().endsWith(".xlsx")) { - name = name + ".xlsx"; - } - return sanitize(name); - } - return new SimpleDateFormat("yyyyMM").format(new Date()) + "工作量统计.xlsx"; - } - - private void writeExcel(Path outputFile, JsonObject payload, String defaultPeriod) throws IOException { - final String period = payload.has("period") - ? firstNonBlank(optString(payload, "period"), defaultPeriod) - : defaultPeriod; - final String team = DEFAULT_TEAM; - final String contact = DEFAULT_CONTACT; - final String developer = DEFAULT_DEVELOPER; - final String project = FIXED_PROJECT_VALUE; - final String content = buildContentFromPayload(payload); - 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 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 void createCell(Row row, int idx, int value, CellStyle style) { - final Cell cell = row.createCell(idx); - cell.setCellValue(value); - cell.setCellStyle(style); - } - - private int getAsInt(JsonElement element, int defaultValue) { - if (element == null) { - return defaultValue; - } - try { - return element.getAsInt(); - } catch (Exception e) { - return defaultValue; - } - } - - private String optString(JsonObject object, String key) { - if (object == null || !object.has(key) || object.get(key).isJsonNull()) { - return ""; - } - return object.get(key).getAsString(); - } - - private String buildContentFromPayload(JsonObject payload) { - final Map> groupedItems = createGroupedItems(); - final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray(); - for (JsonElement element : records) { - if (element == null || !element.isJsonObject()) { - continue; - } - final JsonObject record = element.getAsJsonObject(); - final String recordProject = optString(record, "project"); - final String recordContent = optString(record, "content"); - collectItems(groupedItems, recordProject, recordContent); - } - - final StringBuilder builder = new StringBuilder(); - for (String project : FIXED_PROJECTS) { - final LinkedHashSet items = groupedItems.get(project); - if (items == null || items.isEmpty()) { - continue; - } - if (builder.length() > 0) { - builder.append("\n\n"); - } - builder.append(project).append('\n'); - int index = 1; - for (String item : items) { - builder.append(index++).append(". ").append(item).append('\n'); - } - } - return builder.toString().trim(); - } - - private void enforceMinimumSummaryItems(JsonObject payload, - String markdownContent, - Map projectMinItems) { - final Map> groupedFromPayload = createGroupedItems(); - final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray(); - for (JsonElement element : records) { - if (element == null || !element.isJsonObject()) { - continue; - } - final JsonObject record = element.getAsJsonObject(); - collectItems( - groupedFromPayload, - optString(record, "project"), - optString(record, "content") - ); - } - - final Map> groupedFromMarkdown = parseItemsFromMarkdown(markdownContent); - for (String project : FIXED_PROJECTS) { - final int targetItems = projectMinItems.containsKey(project) - ? projectMinItems.get(project).intValue() - : 2; - final LinkedHashSet current = groupedFromPayload.get(project); - final LinkedHashSet source = groupedFromMarkdown.get(project); - if (current == null) { - continue; - } - if (current.isEmpty() && source != null) { - for (String item : source) { - current.add(item); - } - } - final LinkedHashSet normalized = normalizeToTargetItemCount(project, current, targetItems); - current.clear(); - current.addAll(normalized); - } - - applyGroupedItemsToPayload(payload, groupedFromPayload); - } - - private Map> parseItemsFromMarkdown(String markdownContent) { - final Map> groupedItems = createGroupedItems(); - String currentProject = null; - final String[] lines = markdownContent == null ? new String[0] : markdownContent.split("\\r?\\n"); - for (String rawLine : lines) { - final String line = rawLine == null ? "" : rawLine.trim(); - if (line.isEmpty()) { - continue; - } - - if (line.startsWith("=== 文件:")) { - currentProject = normalizeProject(line); - continue; - } - - final String headingProject = parseHeadingProject(line); - if (headingProject != null) { - currentProject = headingProject; - continue; - } - - final String extracted = parseWorkItem(line); - if (extracted == null || extracted.isEmpty()) { - continue; - } - final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject; - groupedItems.get(targetProject).add(extracted); - } - return groupedItems; - } - - private void applyGroupedItemsToPayload(JsonObject payload, Map> groupedItems) { - final StringBuilder mergedContent = new StringBuilder(); - for (String project : FIXED_PROJECTS) { - final LinkedHashSet items = groupedItems.get(project); - if (items == null || items.isEmpty()) { - continue; - } - if (mergedContent.length() > 0) { - mergedContent.append("\n\n"); - } - mergedContent.append(project).append('\n'); - int index = 1; - for (String item : items) { - mergedContent.append(index++).append(". ").append(item).append('\n'); - } - } - final String merged = mergedContent.toString().trim(); - - JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : null; - if (records == null) { - records = new JsonArray(); - payload.add("records", records); - } - JsonObject record; - if (records.size() > 0 && records.get(0).isJsonObject()) { - record = records.get(0).getAsJsonObject(); - } else { - record = new JsonObject(); - record.addProperty("sequence", 1); - records.add(record); - } - record.addProperty("project", FIXED_PROJECT_VALUE); - record.addProperty("content", merged); - } - - private String buildFallbackItem(String project, int index) { - return "补充总结" + index + ":基于" + project + "本周期提交记录提炼的功能优化与问题修复工作"; - } - - private Map> createGroupedItems() { - final Map> groupedItems = - new LinkedHashMap>(); - for (String project : FIXED_PROJECTS) { - groupedItems.put(project, new LinkedHashSet()); - } - return groupedItems; - } - - private void collectItems(Map> groupedItems, String projectText, String content) { - final String fallbackProject = normalizeProject(projectText); - String currentProject = fallbackProject; - final String[] lines = content == null ? new String[0] : content.split("\\r?\\n"); - for (String rawLine : lines) { - final String line = rawLine == null ? "" : rawLine.trim(); - if (line.isEmpty()) { - continue; - } - - final String headingProject = parseHeadingProject(line); - if (headingProject != null) { - currentProject = headingProject; - continue; - } - - final String extracted = parseWorkItem(line); - if (extracted == null || extracted.isEmpty()) { - continue; - } - final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject; - groupedItems.get(targetProject).add(extracted); - } - } - - private String parseHeadingProject(String line) { - if (line.startsWith("#")) { - final String stripped = line.replaceFirst("^#+\\s*", ""); - return normalizeProject(stripped); - } - return normalizeProject(line); - } - - private String parseWorkItem(String line) { - if (isMetaLine(line)) { - return null; - } - - Matcher matcher = NUMBERED_ITEM_PATTERN.matcher(line); - if (matcher.matches()) { - return cleanWorkItem(matcher.group(2)); - } - - matcher = BULLET_ITEM_PATTERN.matcher(line); - if (matcher.matches()) { - return cleanWorkItem(matcher.group(1)); - } - - matcher = REVISION_ITEM_PATTERN.matcher(line); - if (matcher.matches()) { - return cleanWorkItem(matcher.group(1)); - } - - if (line.length() > 6 && !line.startsWith("=") && !line.startsWith("```")) { - return cleanWorkItem(line); - } - return null; - } - - private boolean isMetaLine(String line) { - return line.startsWith("SVN") - || line.startsWith("仓库") - || line.startsWith("分支") - || line.startsWith("版本") - || line.startsWith("提交总数") - || line.startsWith("日志详情") - || line.startsWith("作者") - || line.startsWith("时间") - || line.startsWith("消息") - || line.startsWith("文件") - || line.startsWith("=== 文件:"); - } - - private String cleanWorkItem(String item) { - String cleaned = item == null ? "" : item.trim(); - cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", ""); - cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", ""); - cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", ""); - cleaned = cleaned.replaceAll("\\s+", " "); - return cleaned.trim(); - } - - private String normalizeProject(String value) { - if (value == null) { - return null; - } - final String input = value.trim(); - if (input.isEmpty()) { - return null; - } - if (input.contains("7050")) { - return "PRS-7050场站智慧管控"; - } - if (input.contains("电科院")) { - return "PRS-7950在线巡视电科院测试版"; - } - if (input.contains("7950")) { - return "PRS-7950在线巡视"; - } - return null; - } - - 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); - } - - private String firstNonBlank(String preferred, String fallback) { - if (preferred != null && !preferred.trim().isEmpty()) { - return preferred.trim(); - } - return fallback == null ? "" : fallback; - } - - private String sanitize(String value) { - return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_"); - } } diff --git a/src/main/java/com/svnlog/web/service/ExcelExportService.java b/src/main/java/com/svnlog/web/service/ExcelExportService.java new file mode 100644 index 0000000..87834f7 --- /dev/null +++ b/src/main/java/com/svnlog/web/service/ExcelExportService.java @@ -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); + } +} diff --git a/src/main/java/com/svnlog/web/service/RepositoryConfigService.java b/src/main/java/com/svnlog/web/service/RepositoryConfigService.java new file mode 100644 index 0000000..138180b --- /dev/null +++ b/src/main/java/com/svnlog/web/service/RepositoryConfigService.java @@ -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 configs; + + public RepositoryConfigService(OutputFileService outputFileService) { + this.outputFileService = outputFileService; + this.gson = new GsonBuilder().setPrettyPrinting().create(); + this.configs = new ArrayList(); + } + + @PostConstruct + public void init() { + load(); + } + + public synchronized List listAll() { + return Collections.unmodifiableList(new ArrayList(configs)); + } + + public synchronized List listByType(String type) { + final List result = new ArrayList(); + 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 loaded = gson.fromJson( + json, + new TypeToken>() { }.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(); + } +} diff --git a/src/main/java/com/svnlog/web/service/SettingsService.java b/src/main/java/com/svnlog/web/service/SettingsService.java index 131e775..aab302d 100644 --- a/src/main/java/com/svnlog/web/service/SettingsService.java +++ b/src/main/java/com/svnlog/web/service/SettingsService.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.svnlog.web.model.PersistedSettings; +import com.svnlog.web.model.RepositoryConfig; @Service public class SettingsService { @@ -32,6 +33,7 @@ public class SettingsService { private final OutputFileService outputFileService; private final SettingsPersistenceService settingsPersistenceService; private final SvnPresetService svnPresetService; + private final RepositoryConfigService repositoryConfigService; private final Path bootstrapOutputRoot; private volatile String runtimeApiKey; private volatile String runtimeProvider; @@ -68,10 +70,12 @@ public class SettingsService { @Autowired public SettingsService(OutputFileService outputFileService, SettingsPersistenceService settingsPersistenceService, - SvnPresetService svnPresetService) { + SvnPresetService svnPresetService, + RepositoryConfigService repositoryConfigService) { this.outputFileService = outputFileService; this.settingsPersistenceService = settingsPersistenceService; this.svnPresetService = svnPresetService; + this.repositoryConfigService = repositoryConfigService; this.bootstrapOutputRoot = initBootstrapOutputRoot(outputFileService); this.runtimeApiKey = initStartupApiKey(); this.runtimeProvider = PROVIDER_DEEPSEEK; @@ -375,6 +379,24 @@ public class SettingsService { return new SvnCredentials(username, password); } + public SvnCredentials resolveSvnCredentials(String requestUsername, String requestPassword, String presetId) { + String username = resolveSvnUsername(requestUsername); + String password = resolveSvnPassword(requestPassword); + if (isBlank(username) || isBlank(password)) { + final SvnCredentials presetCredentials = resolveRepositoryCredentials(presetId); + if (isBlank(username)) { + username = presetCredentials.getUsername(); + } + if (isBlank(password)) { + password = presetCredentials.getPassword(); + } + } + if (isBlank(username) || isBlank(password)) { + throw new IllegalArgumentException("未配置 SVN 账号,请先到系统设置页填写 SVN 用户名和密码"); + } + return new SvnCredentials(username, password); + } + public SvnCredentials getConfiguredSvnCredentials() { return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null)); } @@ -427,6 +449,21 @@ public class SettingsService { return trim(System.getenv(ENV_SVN_PASSWORD)); } + private SvnCredentials resolveRepositoryCredentials(String presetId) { + if (isBlank(presetId) || repositoryConfigService == null) { + return new SvnCredentials("", ""); + } + try { + final RepositoryConfig config = repositoryConfigService.getById(presetId); + final String username = trim(config.getSvnUsername()); + final String password = trim(repositoryConfigService.decryptPassword(config)); + return new SvnCredentials(username, password); + } catch (RuntimeException e) { + LOGGER.warn("Failed to resolve SVN credentials from repository config: presetId={}", presetId, e); + return new SvnCredentials("", ""); + } + } + private String trim(String value) { return value == null ? "" : value.trim(); } diff --git a/src/main/java/com/svnlog/web/service/SvnPresetService.java b/src/main/java/com/svnlog/web/service/SvnPresetService.java index c814e6f..41598c1 100644 --- a/src/main/java/com/svnlog/web/service/SvnPresetService.java +++ b/src/main/java/com/svnlog/web/service/SvnPresetService.java @@ -1,46 +1,47 @@ package com.svnlog.web.service; import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import javax.annotation.PostConstruct; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import com.svnlog.web.model.RepositoryConfig; import com.svnlog.web.model.SvnPreset; import com.svnlog.web.model.SvnPresetSummary; @Service public class SvnPresetService { + private static final Logger LOGGER = LoggerFactory.getLogger(SvnPresetService.class); - private final List presets; + private final RepositoryConfigService repositoryConfigService; - public SvnPresetService() { - final List list = new ArrayList(); - list.add(new SvnPreset( - "preset-1", - "PRS-7050场站智慧管控", - "https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00" - )); - list.add(new SvnPreset( - "preset-2", - "PRS-7950在线巡视", - "https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00" - )); - list.add(new SvnPreset( - "preset-3", - "PRS-7950在线巡视电科院测试版", - "https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024" - )); - this.presets = Collections.unmodifiableList(list); + public SvnPresetService(RepositoryConfigService repositoryConfigService) { + this.repositoryConfigService = repositoryConfigService; + } + + @PostConstruct + public void init() { + migrateHardcodedPresets(); } public List listPresets() { + final List configs = repositoryConfigService.listByType("SVN"); + final List presets = new ArrayList(); + for (RepositoryConfig config : configs) { + if (config.isEnabled()) { + presets.add(toSvnPreset(config)); + } + } return presets; } public List listPresetSummaries() { final List summaries = new ArrayList(); - for (SvnPreset preset : presets) { + for (SvnPreset preset : listPresets()) { summaries.add(new SvnPresetSummary(preset.getId(), preset.getName())); } return summaries; @@ -50,25 +51,15 @@ public class SvnPresetService { if (presetId == null || presetId.trim().isEmpty()) { return false; } - for (SvnPreset preset : presets) { - if (presetId.equals(preset.getId())) { - return true; - } - } - return false; + return repositoryConfigService.containsId(presetId); } public SvnPreset getById(String presetId) { - final String id = trim(presetId); - for (SvnPreset preset : presets) { - if (id.equals(preset.getId())) { - return preset; - } - } - throw new IllegalArgumentException("无效的 SVN 预设ID: " + presetId); + return toSvnPreset(repositoryConfigService.getById(trim(presetId))); } public String firstPresetId() { + final List presets = listPresets(); return presets.isEmpty() ? "" : presets.get(0).getId(); } @@ -76,6 +67,53 @@ public class SvnPresetService { return firstPresetId(); } + private SvnPreset toSvnPreset(RepositoryConfig config) { + return new SvnPreset( + config.getId(), + config.getName(), + config.getSvnUrl() == null ? "" : config.getSvnUrl() + ); + } + + private void migrateHardcodedPresets() { + final String[][] presets = { + { + "preset-1", + "PRS-7050场站智慧管控", + "https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00" + }, + { + "preset-2", + "PRS-7950在线巡视", + "https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00" + }, + { + "preset-3", + "PRS-7950在线巡视电科院测试版", + "https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024" + }, + }; + + int migrated = 0; + int updated = 0; + for (String[] preset : presets) { + if (repositoryConfigService.containsId(preset[0])) { + if (repositoryConfigService.updatePresetUrl(preset[0], preset[2], preset[1])) { + updated++; + } + } else { + repositoryConfigService.migratePreset(preset[0], preset[1], preset[2]); + migrated++; + } + } + if (migrated > 0) { + LOGGER.info("已迁移 {} 个硬编码 SVN 预设到仓库配置", migrated); + } + if (updated > 0) { + LOGGER.info("已更新 {} 个 SVN 预设的 URL", updated); + } + } + private String trim(String value) { return value == null ? "" : value.trim(); } diff --git a/src/main/java/com/svnlog/web/service/SvnWorkflowService.java b/src/main/java/com/svnlog/web/service/SvnWorkflowService.java index 827f76a..720c4cd 100644 --- a/src/main/java/com/svnlog/web/service/SvnWorkflowService.java +++ b/src/main/java/com/svnlog/web/service/SvnWorkflowService.java @@ -2,8 +2,13 @@ package com.svnlog.web.service; import java.nio.file.Path; import java.text.SimpleDateFormat; +import java.util.Calendar; import java.util.Date; +import java.util.GregorianCalendar; import java.util.List; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.springframework.stereotype.Service; import org.tmatesoft.svn.core.SVNException; @@ -19,6 +24,9 @@ import com.svnlog.web.model.TaskResult; @Service public class SvnWorkflowService { + private static final TimeZone RANGE_TIME_ZONE = TimeZone.getTimeZone("Asia/Shanghai"); + private static final Pattern DATE_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})$"); + private static final Pattern WEEK_PATTERN = Pattern.compile("^(\\d{4})-W(\\d{2})$"); private final OutputFileService outputFileService; private final SettingsService settingsService; @@ -36,7 +44,8 @@ public class SvnWorkflowService { final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials( request.getUsername(), - request.getPassword() + request.getPassword(), + request.getPresetId() ); final SVNLogFetcher fetcher = new SVNLogFetcher( preset.getUrl(), @@ -50,17 +59,20 @@ public class SvnWorkflowService { final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials( request.getUsername(), - request.getPassword() + request.getPassword(), + request.getPresetId() ); final SVNLogFetcher fetcher = new SVNLogFetcher( preset.getUrl(), credentials.getUsername(), credentials.getPassword() ); - return fetcher.getVersionRangeByMonth( - request.getYear().intValue(), - request.getMonth().intValue(), - request.getClientTraceId() + final DateRange dateRange = resolveDateRange(request); + return fetcher.getVersionRangeByTimeRange( + dateRange.startInclusive, + dateRange.endExclusive, + request.getClientTraceId(), + dateRange.rangeType ); } @@ -68,7 +80,8 @@ public class SvnWorkflowService { final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials( request.getUsername(), - request.getPassword() + request.getPassword(), + request.getPresetId() ); context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName()); final SVNLogFetcher fetcher = new SVNLogFetcher( @@ -124,4 +137,95 @@ public class SvnWorkflowService { private String safe(String value) { return value == null ? "" : value; } + + private DateRange resolveDateRange(SvnVersionRangeRequest request) { + final String rangeType = safe(request.getRangeType()).trim(); + if ("date".equals(rangeType)) { + return resolveDateRangeByDate(request.getDate()); + } + if ("week".equals(rangeType)) { + return resolveDateRangeByWeek(request.getWeek()); + } + return resolveDateRangeByMonth(request.getYear(), request.getMonth()); + } + + private DateRange resolveDateRangeByDate(String value) { + final Matcher matcher = DATE_PATTERN.matcher(safe(value).trim()); + if (!matcher.matches()) { + throw new IllegalArgumentException("日期格式错误,应为 yyyy-MM-dd"); + } + final Calendar start = newCalendar(); + start.set( + parseNumber(matcher.group(1), "年份"), + parseNumber(matcher.group(2), "月份") - 1, + parseNumber(matcher.group(3), "日期"), + 0, + 0, + 0 + ); + start.set(Calendar.MILLISECOND, 0); + final Calendar end = (Calendar) start.clone(); + end.add(Calendar.DATE, 1); + return new DateRange("date", start.getTime(), end.getTime()); + } + + private DateRange resolveDateRangeByWeek(String value) { + final Matcher matcher = WEEK_PATTERN.matcher(safe(value).trim()); + if (!matcher.matches()) { + throw new IllegalArgumentException("周格式错误,应为 yyyy-Www"); + } + final int year = parseNumber(matcher.group(1), "年份"); + final int week = parseNumber(matcher.group(2), "周"); + final GregorianCalendar start = new GregorianCalendar(RANGE_TIME_ZONE); + start.clear(); + start.setFirstDayOfWeek(Calendar.MONDAY); + start.setMinimalDaysInFirstWeek(4); + start.setWeekDate(year, week, Calendar.MONDAY); + start.set(Calendar.HOUR_OF_DAY, 0); + start.set(Calendar.MINUTE, 0); + start.set(Calendar.SECOND, 0); + start.set(Calendar.MILLISECOND, 0); + final Calendar end = (Calendar) start.clone(); + end.add(Calendar.DATE, 7); + return new DateRange("week", start.getTime(), end.getTime()); + } + + private DateRange resolveDateRangeByMonth(Integer yearValue, Integer monthValue) { + if (yearValue == null || monthValue == null) { + throw new IllegalArgumentException("月份范围缺少 year 或 month 参数"); + } + final Calendar start = newCalendar(); + start.set(yearValue.intValue(), monthValue.intValue() - 1, 1, 0, 0, 0); + start.set(Calendar.MILLISECOND, 0); + final Calendar end = (Calendar) start.clone(); + end.add(Calendar.MONTH, 1); + return new DateRange("month", start.getTime(), end.getTime()); + } + + private Calendar newCalendar() { + final Calendar calendar = Calendar.getInstance(RANGE_TIME_ZONE); + calendar.setFirstDayOfWeek(Calendar.MONDAY); + calendar.setMinimalDaysInFirstWeek(4); + return calendar; + } + + private int parseNumber(String value, String fieldName) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(fieldName + "格式错误", e); + } + } + + private static class DateRange { + private final String rangeType; + private final Date startInclusive; + private final Date endExclusive; + + private DateRange(String rangeType, Date startInclusive, Date endExclusive) { + this.rangeType = rangeType; + this.startInclusive = startInclusive; + this.endExclusive = endExclusive; + } + } } diff --git a/src/main/java/com/svnlog/web/util/CryptoUtils.java b/src/main/java/com/svnlog/web/util/CryptoUtils.java new file mode 100644 index 0000000..6b81de2 --- /dev/null +++ b/src/main/java/com/svnlog/web/util/CryptoUtils.java @@ -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); + } + } +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 6ea14df..02e47be 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -1,640 +1,4 @@ - - - - - - SVN 日志工作台 - - - - - - - - - -
- - - - -
-
-

工作台

-

查看系统状态与最近产物

-
- - -
- - -
-
-
- 任务总数 - 0 -
-
- 执行中 - 0 -
-
- 失败任务 - 0 -
-
- 系统状态 - - -
-
- 健康检查详情 - 加载中... -
-
-
- - -
-
-

最近任务

-
-
    -
    -
    -
    -

    最近文件

    -
    -
      -
      -
      -
      -
      - - -
      -
      -

      SVN 批量抓取参数

      - -
      - 默认已填充 3 个常用项目路径,可选择月份自动填充版本号,或手动填写。 -
      - -
      -

      智能版本号辅助

      -
      - - -
      -
      - -
      -
      -

      项目 1:PRS-7050 场站智慧管控

      -
      - - -
      -
      - -
      -

      项目 2:PRS-7950 在线巡视

      -
      - - -
      -
      - -
      -

      项目 3:PRS-7950 在线巡视电科院测试版

      -
      - - -
      -
      - - - - - -
      - - -
      -
      -
      - - -
      -

      执行进度面板

      -
      -
      -
      AI 思考过程
      -
      -

      等待思考输出...

      -
      -
      -
      -
      最终分析输出
      -
      -

      等待答案输出...

      -
      -
      -
      -
      -
      系统控制台
      -
      -

      等待任务开始...

      -
      -
      -
      -
      - - -
      -
      -

      任务列表

      -
      - - - - -
      -
      -
      -
      -
      -

      输出文件归档

      -
      -
      -
      - - -
      -
      -

      系统配置

      -
      - -
      - -
      - - - - - - - -
      - -
      -
      - -
      -
      - - -
      -
      -
      - - - - - - - + } \ No newline at end of file diff --git a/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java b/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java index b5232ed..0a40bba 100644 --- a/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java +++ b/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java @@ -17,19 +17,27 @@ class AiWorkflowServiceTest { @TempDir Path tempDir; + private AiApiService buildAiApiService(SettingsService settingsService) { + return new AiApiService(settingsService); + } + + private ExcelExportService buildExcelExportService() { + return new ExcelExportService(); + } + @Test void shouldResolveDeepSeekProviderByDefault() { + final OutputFileService outputFileService = buildOutputFileService(); + final SettingsService settingsService = buildSettingsService(outputFileService); + final AiApiService aiApiService = buildAiApiService(settingsService); final AiWorkflowService service = new AiWorkflowService( - buildOutputFileService(), - new SettingsService( - buildOutputFileService(), - new SettingsPersistenceService(), - new SvnPresetService() - ), - new AiInputValidator() + outputFileService, + new AiInputValidator(), + aiApiService, + buildExcelExportService() ); - final AiWorkflowService.AiProviderContext context = service.resolveProviderContext(null); + final AiApiService.AiProviderContext context = aiApiService.resolveProviderContext(null); Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, context.getProvider()); Assertions.assertEquals("deepseek-chat", context.getStageOneModel()); @@ -39,11 +47,7 @@ class AiWorkflowServiceTest { @Test void shouldResolveOpenAiCompatibleModelsAndUrl() { final OutputFileService outputFileService = buildOutputFileService(); - final SettingsService settingsService = new SettingsService( - outputFileService, - new SettingsPersistenceService(), - new SvnPresetService() - ); + final SettingsService settingsService = buildSettingsService(outputFileService); settingsService.updateSettings( null, SettingsService.PROVIDER_OPENAI_COMPATIBLE, @@ -56,9 +60,12 @@ class AiWorkflowServiceTest { null, null ); - final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator()); + final AiApiService aiApiService = buildAiApiService(settingsService); + final AiWorkflowService service = new AiWorkflowService( + outputFileService, new AiInputValidator(), aiApiService, buildExcelExportService() + ); - final AiWorkflowService.AiProviderContext context = service.resolveProviderContext(null); + final AiApiService.AiProviderContext context = aiApiService.resolveProviderContext(null); Assertions.assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, context.getProvider()); Assertions.assertEquals("http://127.0.0.1:5001/v1/chat/completions", context.getApiUrl()); @@ -68,15 +75,14 @@ class AiWorkflowServiceTest { @Test void shouldFailFastWhenOpenAiCompatibleBaseUrlMissing() { - final AiWorkflowService service = new AiWorkflowService( - buildOutputFileService(), - new StubSettingsService(buildOutputFileService(), " ", "sk-openai-test"), - new AiInputValidator() + final OutputFileService outputFileService = buildOutputFileService(); + final AiApiService aiApiService = buildAiApiService( + new StubSettingsService(outputFileService, " ", "sk-openai-test") ); final IllegalStateException error = Assertions.assertThrows( IllegalStateException.class, - () -> service.resolveProviderContext(null) + () -> aiApiService.resolveProviderContext(null) ); Assertions.assertTrue(error.getMessage().contains("OpenAI兼容 Base URL")); @@ -85,11 +91,7 @@ class AiWorkflowServiceTest { @Test void shouldParseCompatibleStreamWhenOnlyContentIsReturned() throws Exception { final OutputFileService outputFileService = buildOutputFileService(); - final SettingsService settingsService = new SettingsService( - outputFileService, - new SettingsPersistenceService(), - new SvnPresetService() - ); + final SettingsService settingsService = buildSettingsService(outputFileService); settingsService.updateSettings( null, SettingsService.PROVIDER_OPENAI_COMPATIBLE, @@ -102,8 +104,8 @@ class AiWorkflowServiceTest { null, null ); - final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator()); - final AiWorkflowService.AiProviderContext providerContext = service.resolveProviderContext(null); + final AiApiService aiApiService = buildAiApiService(settingsService); + final AiApiService.AiProviderContext providerContext = aiApiService.resolveProviderContext(null); final TaskContext taskContext = new TaskContext(buildTaskInfo(), null, null); final Buffer buffer = new Buffer() .writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"items\\\":[\"}}]}\n") @@ -111,7 +113,7 @@ class AiWorkflowServiceTest { .writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"]}\"},\"finish_reason\":\"stop\"}]}\n") .writeUtf8("data: [DONE]\n"); - final AiWorkflowService.AiStreamResult result = service.readStreamingResponse( + final AiApiService.AiStreamResult result = aiApiService.readStreamingResponse( buffer, taskContext, providerContext, @@ -138,12 +140,30 @@ class AiWorkflowServiceTest { return taskInfo; } + private SettingsService buildSettingsService(OutputFileService outputFileService) { + final RepositoryConfigService repositoryConfigService = buildRepositoryConfigService(outputFileService); + final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService); + svnPresetService.init(); + return new SettingsService(outputFileService, new SettingsPersistenceService(), svnPresetService, repositoryConfigService); + } + + private RepositoryConfigService buildRepositoryConfigService(OutputFileService outputFileService) { + final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService); + repositoryConfigService.init(); + return repositoryConfigService; + } + private static final class StubSettingsService extends SettingsService { private final String openaiBaseUrl; private final String openaiApiKey; private StubSettingsService(OutputFileService outputFileService, String openaiBaseUrl, String openaiApiKey) { - super(outputFileService, new SettingsPersistenceService(), new SvnPresetService()); + super( + outputFileService, + new SettingsPersistenceService(), + new SvnPresetService(new RepositoryConfigService(outputFileService)), + new RepositoryConfigService(outputFileService) + ); this.openaiBaseUrl = openaiBaseUrl; this.openaiApiKey = openaiApiKey; } diff --git a/src/test/java/com/svnlog/web/service/HealthServiceTest.java b/src/test/java/com/svnlog/web/service/HealthServiceTest.java index ef8f3c9..b991637 100644 --- a/src/test/java/com/svnlog/web/service/HealthServiceTest.java +++ b/src/test/java/com/svnlog/web/service/HealthServiceTest.java @@ -26,10 +26,15 @@ class HealthServiceTest { void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception { final OutputFileService outputFileService = new OutputFileService(); outputFileService.setOutputRoot(tempDir.resolve("outputs").toString()); + final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService); + repositoryConfigService.init(); + final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService); + svnPresetService.init(); final SettingsService settingsService = new SettingsService( outputFileService, new SettingsPersistenceService(), - new SvnPresetService() + svnPresetService, + repositoryConfigService ); taskService = new TaskService(new TaskPersistenceService(), outputFileService); final HealthService healthService = new HealthService(outputFileService, settingsService, taskService); diff --git a/src/test/java/com/svnlog/web/service/SettingsServiceTest.java b/src/test/java/com/svnlog/web/service/SettingsServiceTest.java index 3f6cbee..8d0b6b5 100644 --- a/src/test/java/com/svnlog/web/service/SettingsServiceTest.java +++ b/src/test/java/com/svnlog/web/service/SettingsServiceTest.java @@ -9,11 +9,15 @@ import java.util.EnumSet; import java.util.Map; import java.util.Set; +import com.google.gson.GsonBuilder; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.svnlog.web.model.RepositoryConfig; +import com.svnlog.web.util.CryptoUtils; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -195,12 +199,58 @@ class SettingsServiceTest { assertNotNull(settings.get("apiKeySource")); } + @Test + void shouldResolveSvnCredentialsFromRepositoryConfigWhenSettingsAreEmpty() throws IOException { + useTempWorkingDirectory(); + final OutputFileService outputFileService = new OutputFileService(); + outputFileService.setOutputRoot(tempDir.resolve("outputs").toString()); + writeRepositoryConfig(outputFileService.getOutputRoot().resolve("repository-configs.json")); + + final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService); + repositoryConfigService.init(); + final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService); + svnPresetService.init(); + final SettingsService settingsService = new SettingsService( + outputFileService, + new SettingsPersistenceService(), + svnPresetService, + repositoryConfigService + ); + + final SettingsService.SvnCredentials credentials = + settingsService.resolveSvnCredentials(null, null, "preset-json"); + + assertEquals("json-user", credentials.getUsername()); + assertEquals("json-pass", credentials.getPassword()); + assertTrue(svnPresetService.containsPresetId("preset-json")); + assertEquals("JSON SVN", svnPresetService.getById("preset-json").getName()); + } + private SettingsService newSettingsService() { final OutputFileService outputFileService = new OutputFileService(); outputFileService.setOutputRoot(tempDir.resolve("outputs").toString()); final SettingsPersistenceService settingsPersistenceService = new SettingsPersistenceService(); - final SvnPresetService svnPresetService = new SvnPresetService(); - return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService); + final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService); + repositoryConfigService.init(); + final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService); + svnPresetService.init(); + return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService, repositoryConfigService); + } + + private void writeRepositoryConfig(Path configPath) throws IOException { + final RepositoryConfig config = new RepositoryConfig(); + config.setId("preset-json"); + config.setName("JSON SVN"); + config.setType("SVN"); + config.setEnabled(true); + config.setSvnUrl("https://example.invalid/svn/project"); + config.setSvnUsername("json-user"); + config.setSvnPasswordEncrypted(CryptoUtils.encrypt("json-pass")); + + Files.createDirectories(configPath.getParent()); + final String json = new GsonBuilder().setPrettyPrinting().create() + .toJson(java.util.Collections.singletonList(config)); + Files.write(configPath, json.getBytes(StandardCharsets.UTF_8)); } private void useTempWorkingDirectory() {