From 25029e5c745d8917cc2514c5994c0d250994fe0b Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Wed, 21 Jan 2026 21:25:51 +0800 Subject: [PATCH 01/35] fix: CSP error --- src/css/openai.css | 18 +++++++++++------- src/middleware/security.js | 2 ++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/css/openai.css b/src/css/openai.css index 21a6ee2..27ef63d 100644 --- a/src/css/openai.css +++ b/src/css/openai.css @@ -837,7 +837,7 @@ /* Reduced Input Height */ .openai-chat-input-wrapper { - padding: 6px; + padding: 0px 6px 6px 6px; /* Less vertical padding */ background: var(--bg-secondary); /* border-top: 1px solid var(--border-color); */ @@ -1759,17 +1759,17 @@ /* Modern Input Styling */ .openai-chat-input-wrapper { background: transparent !important; - padding: 16px !important; + /* padding: 16px !important; */ } .input-panel { background: rgba(255, 255, 255, 0.75) !important; backdrop-filter: blur(12px); border: 1px solid rgba(0, 0, 0, 0.08) !important; - border-radius: 24px !important; + /* border-radius: 24px !important; */ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05) !important; - padding: 6px 12px !important; - max-width: 900px; + padding: 6px !important; + /* max-width: 900px; */ margin: 0 auto; /* Center on large screens */ } @@ -2342,11 +2342,15 @@ .chat-attachments-preview { display: flex; gap: 12px; - padding: 12px; - background: rgba(0, 0, 0, 0.1); + padding: 12px 16px; + background: linear-gradient(135deg, rgba(16, 163, 127, 0.08) 0%, rgba(16, 163, 127, 0.02) 100%); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(16, 163, 127, 0.15); border-bottom: 1px solid var(--border-color); overflow-x: auto; border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } .attachment-item { diff --git a/src/middleware/security.js b/src/middleware/security.js index 4145f46..d2fbb32 100644 --- a/src/middleware/security.js +++ b/src/middleware/security.js @@ -50,6 +50,8 @@ function configureHelmet(options = {}) { 'ws:', 'https:', 'http:', // 允许所有 HTTP/HTTPS 连接 + 'data:', // 允许 data URL(图片上传使用) + 'blob:', // 允许 blob URL ], objectSrc: ["'none'"], frameAncestors: ["'self'"], From 0e0e9d6b7f9a08c7729f64df67f67cb7197ed176 Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Wed, 21 Jan 2026 21:46:54 +0800 Subject: [PATCH 02/35] feat: Add multi-architecture Docker image CI/CD pipeline, modify Dockerfile, and introduce a new transitions JavaScript module. --- .github/workflows/docker-publish.yml | 184 +++++++++++++++++++++------ Dockerfile | 42 +++--- src/js/modules/transitions.js | 5 + 3 files changed, 178 insertions(+), 53 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 82e6d66..f5df56f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,6 +6,10 @@ # - 推送到其他分支 → dev 标签 # - 创建版本标签(如 v1.0.0) # - 手动触发(workflow_dispatch) +# +# 构建策略: +# - amd64 和 arm64 分别在原生 runner 上构建(避免 QEMU 问题) +# - 使用 docker manifest 合并多架构镜像 name: Build and Publish Docker Image @@ -25,7 +29,7 @@ on: required: false default: "latest" platforms: - description: "构建平台(可选)" + description: "构建平台(可选,逗号分隔)" required: false default: "linux/amd64,linux/arm64" @@ -39,27 +43,32 @@ env: DOCKERHUB_REPO: ${{ github.repository_owner }}/api-monitor jobs: - build-and-push: - runs-on: ubuntu-latest + # ========================================== + # 阶段 1: 分架构构建(原生 runner,避免 QEMU) + # ========================================== + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + suffix: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + suffix: arm64 + + runs-on: ${{ matrix.runner }} + outputs: + tags: ${{ steps.meta.outputs.tags }} steps: - # 检出代码 - name: Checkout repository uses: actions/checkout@v4 - # 设置 QEMU(用于多架构构建) - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - # 设置 Docker Buildx - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - image=moby/buildkit:latest - network=host - # 登录 GitHub Container Registry - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -67,7 +76,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # 登录 Docker Hub (请确保已在 GitHub Secrets 中配置 DOCKERHUB_USERNAME 和 DOCKERHUB_TOKEN) - name: Login to Docker Hub if: github.event_name != 'pull_request' uses: docker/login-action@v3 @@ -75,7 +83,7 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # 提取元数据(标签、标签等) + # 提取元数据(用于生成标签) - name: Extract metadata id: meta uses: docker/metadata-action@v5 @@ -84,19 +92,13 @@ jobs: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} ${{ env.DOCKERHUB_REPO }} tags: | - # 默认 latest 标签(推送到 main 分支或版本标签时都生成) type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} - # dev 标签(非主分支时生成) type=raw,value=dev,enable=${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/v') }} - # 分支名标签(非主分支时,便于区分不同分支的构建) type=ref,event=branch,enable=${{ github.ref != 'refs/heads/main' }} - # Git commit SHA(仅在推送到 main 分支时生成) type=sha,prefix=,enable=${{ github.ref == 'refs/heads/main' }} - # 版本标签(如 v1.0.0 -> 1.0.0) type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - # 手动触发时使用输入的标签 type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }} labels: | org.opencontainers.image.title=API Monitor @@ -104,22 +106,104 @@ jobs: org.opencontainers.image.vendor=iwvw maintainer=iwvw - # 构建并推送镜像 - - name: Build and push Docker image + # 构建并推送单架构镜像(带后缀) + - name: Build and push by digest + id: build uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile + platforms: ${{ matrix.platform }} push: true - platforms: ${{ github.event.inputs.platforms != '' && github.event.inputs.platforms || 'linux/amd64,linux/arm64' }} - tags: ${{ steps.meta.outputs.tags }} + # 使用带架构后缀的临时标签 + tags: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:build-${{ github.run_id }}-${{ matrix.suffix }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} build-args: | NODE_ENV=production - # 输出镜像信息 + - name: Export digest + run: | + mkdir -p /tmp/digests + echo "${{ steps.build.outputs.digest }}" > /tmp/digests/${{ matrix.suffix }} + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.suffix }} + path: /tmp/digests/* + retention-days: 1 + + # ========================================== + # 阶段 2: 合并多架构 manifest + # ========================================== + merge: + runs-on: ubuntu-latest + needs: build + + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + ${{ env.DOCKERHUB_REPO }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=dev,enable=${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/v') }} + type=ref,event=branch,enable=${{ github.ref != 'refs/heads/main' }} + type=sha,prefix=,enable=${{ github.ref == 'refs/heads/main' }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }} + + # 创建并推送多架构 manifest + - name: Create and push manifest + run: | + # 读取各架构 digest + AMD64_DIGEST=$(cat /tmp/digests/amd64) + ARM64_DIGEST=$(cat /tmp/digests/arm64) + + echo "AMD64 Digest: $AMD64_DIGEST" + echo "ARM64 Digest: $ARM64_DIGEST" + + # 为每个目标标签创建 manifest + TAGS="${{ steps.meta.outputs.tags }}" + echo "$TAGS" | while IFS= read -r TAG; do + [ -z "$TAG" ] && continue + echo "Creating manifest for: $TAG" + docker buildx imagetools create -t "$TAG" \ + "${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@${AMD64_DIGEST}" \ + "${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@${ARM64_DIGEST}" + done + - name: Image digest run: | echo "## 🐳 Docker 镜像构建成功" >> $GITHUB_STEP_SUMMARY @@ -129,6 +213,8 @@ jobs: echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "**支持架构:** linux/amd64, linux/arm64" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo "**拉取命令:**" >> $GITHUB_STEP_SUMMARY echo '```bash' >> $GITHUB_STEP_SUMMARY echo "# 从 GHCR 拉取" >> $GITHUB_STEP_SUMMARY @@ -137,24 +223,46 @@ jobs: echo "# 从 Docker Hub 拉取" >> $GITHUB_STEP_SUMMARY echo "docker pull ${{ env.DOCKERHUB_REPO }}:latest" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**使用 Docker Compose 运行:**" >> $GITHUB_STEP_SUMMARY - echo '```bash' >> $GITHUB_STEP_SUMMARY - echo "docker-compose up -d" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - # 镜像安全扫描(可选) + # ========================================== + # 阶段 3: 清理临时镜像(可选) + # ========================================== + cleanup: + runs-on: ubuntu-latest + needs: merge + if: always() + continue-on-error: true + + steps: + - name: Delete temporary build images + uses: actions/delete-package-versions@v5 + with: + package-name: ${{ env.IMAGE_NAME }} + package-type: container + min-versions-to-keep: 0 + delete-only-untagged-versions: false + # 只删除临时构建标签 + ignore-versions: "^(?!build-${{ github.run_id }}).*$" + continue-on-error: true + + # ========================================== + # 阶段 4: 安全扫描(可选) + # ========================================== + security-scan: + runs-on: ubuntu-latest + needs: merge + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + continue-on-error: true + + steps: - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: image-ref: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest format: "sarif" output: "trivy-results.sarif" - continue-on-error: true - # 上传扫描结果(可选) - name: Upload Trivy scan results to GitHub Security tab uses: github/codeql-action/upload-sarif@v3 with: sarif_file: "trivy-results.sarif" - continue-on-error: true diff --git a/Dockerfile b/Dockerfile index 917ec5a..0b844ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ # =================================== # API Monitor Docker Image # =================================== -# 多阶段构建:Builder -> Runner +# 多阶段构建:Builder -> Native Deps Builder -> Runner -# 阶段 1: 构建前端 (Builder) +# 阶段 1: 构建前端 (Builder) - 始终在构建主机平台运行 FROM --platform=$BUILDPLATFORM node:20-alpine AS builder # 安装构建工具 RUN apk add --no-cache python3 make g++ @@ -47,7 +47,22 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o agent-lin # 构建 Windows amd64 RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o agent-windows-amd64.exe -# 阶段 3: 运行时镜像 (Runner) +# 阶段 3: 预构建生产依赖 (Native Deps Builder) +# 为目标平台安装原生模块 +FROM --platform=$TARGETPLATFORM node:20-alpine AS deps-builder +# 安装构建工具 (用于编译 better-sqlite3 等原生模块,以防预编译不可用) +RUN apk add --no-cache python3 make g++ curl +WORKDIR /app +# 复制依赖定义 +COPY package.json package-lock.json ./ +# 设置镜像源 +RUN npm config set registry https://registry.npmmirror.com +# 尝试使用预编译二进制,如果不可用则编译 +# better-sqlite3 支持 prebuild,会自动下载预编译的 .node 文件 +ENV npm_config_build_from_source=false +RUN npm install --omit=dev --legacy-peer-deps && npm cache clean --force + +# 阶段 4: 运行时镜像 (Runner) - 纯净的运行环境 FROM --platform=$TARGETPLATFORM node:20-alpine AS runner LABEL org.opencontainers.image.title="API Monitor" @@ -65,25 +80,22 @@ WORKDIR /app # 创建数据目录 RUN mkdir -p /app/config /app/data && chown -R nodejs:nodejs /app -# 1. 复制依赖定义 -COPY package.json package-lock.json ./ - -# 设置镜像源 -RUN npm config set registry https://registry.npmmirror.com +# 1. 从 deps-builder 复制预构建的 node_modules (避免在 runner 中编译) +COPY --from=deps-builder --chown=nodejs:nodejs /app/node_modules ./node_modules +COPY --from=deps-builder --chown=nodejs:nodejs /app/package.json ./ -# 2. 仅安装生产依赖 (减小体积) -RUN npm install --omit=dev --legacy-peer-deps && npm cache clean --force - -# 3. 从各阶段复制构建好的资源 +# 2. 从 builder 复制构建好的前端资源 COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist -# 将 Go Agent 二进制文件放入 dist/agent 目录以便静态服务 + +# 3. 将 Go Agent 二进制文件放入 dist/agent 目录以便静态服务 RUN mkdir -p /app/dist/agent COPY --from=agent-builder --chown=nodejs:nodejs /app/agent-go/agent-linux-amd64 /app/dist/agent/ COPY --from=agent-builder --chown=nodejs:nodejs /app/agent-go/agent-linux-arm64 /app/dist/agent/ COPY --from=agent-builder --chown=nodejs:nodejs /app/agent-go/agent-windows-amd64.exe /app/dist/agent/ -# 4. 复制后端源码 -COPY --chown=nodejs:nodejs . . +# 4. 复制后端源码 (不包含 node_modules) +COPY --chown=nodejs:nodejs server.js ./ +COPY --chown=nodejs:nodejs src ./src ENV NODE_ENV=production \ PORT=3000 \ diff --git a/src/js/modules/transitions.js b/src/js/modules/transitions.js index ab4e16c..ea5d896 100644 --- a/src/js/modules/transitions.js +++ b/src/js/modules/transitions.js @@ -6,6 +6,11 @@ export const transitionsMethods = { // 获取标签页动画类 getTabAnimationClass(tabName) { + // 只有当前激活的标签页才应用动画类 + if (this.mainActiveTab !== tabName) { + return ''; + } + if (!this.previousMainTab) { // 首次加载,使用淡入动画 return 'fade-in'; From f41369060432f00cee92cd9746006290aa41786a Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Wed, 21 Jan 2026 22:07:29 +0800 Subject: [PATCH 03/35] feat: add API model configuration matrix and OpenAI module. --- .../antigravity-api/antigravity-matrix.json | 22 ++++++++---- src/js/modules/openai.js | 36 +++++++++++++++++-- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/modules/antigravity-api/antigravity-matrix.json b/modules/antigravity-api/antigravity-matrix.json index c1550d1..ff63cd8 100644 --- a/modules/antigravity-api/antigravity-matrix.json +++ b/modules/antigravity-api/antigravity-matrix.json @@ -1,8 +1,8 @@ { "gemini-2.5-pro": { "base": true, - "fakeStream": true, - "antiTrunc": true + "fakeStream": false, + "antiTrunc": false }, "gemini-2.5-flash": { "base": true, @@ -11,8 +11,8 @@ }, "gemini-2.5-flash-lite": { "base": true, - "fakeStream": true, - "antiTrunc": true + "fakeStream": false, + "antiTrunc": false }, "gemini-3-pro-high": { "base": true, @@ -31,8 +31,8 @@ }, "gpt-oss-120b-medium": { "base": true, - "fakeStream": true, - "antiTrunc": true + "fakeStream": false, + "antiTrunc": false }, "gemini-3-pro-image": { "base": true, @@ -58,5 +58,15 @@ "base": true, "fakeStream": false, "antiTrunc": false + }, + "gemini-2.5-flash-thinking": { + "base": false, + "fakeStream": false, + "antiTrunc": false + }, + "tab_flash_lite_preview": { + "base": false, + "fakeStream": false, + "antiTrunc": false } } \ No newline at end of file diff --git a/src/js/modules/openai.js b/src/js/modules/openai.js index 81c8813..7d1b2ee 100644 --- a/src/js/modules/openai.js +++ b/src/js/modules/openai.js @@ -12,10 +12,30 @@ const imageUploadCache = new Map(); // 图片上传缓存 import { renderMarkdown } from './utils.js'; export const openaiMethods = { + // 从内容中提取思考标签(支持各种变体如 , , 等) + extractThinkingContent(content) { + if (!content || typeof content !== 'string') return { thinking: '', cleaned: content || '' }; + + // 匹配各种思考标签变体: , , , etc. + const thinkingPattern = /<(think(?:ing|_\w+)?)\s*>([\s\S]*?)<\/\1>/gi; + let thinking = ''; + let cleaned = content; + + let match; + while ((match = thinkingPattern.exec(content)) !== null) { + thinking += match[2].trim() + '\n'; + } + + // 移除所有思考标签 + cleaned = content.replace(thinkingPattern, '').trim(); + + return { thinking: thinking.trim(), cleaned }; + }, + // 带缓存的消息渲染(避免 Base64 图片导致的重复计算) getCachedMessageHtml(msg, field = 'content') { if (!msg) return ''; - const content = msg[field]; + let content = msg[field]; if (content === undefined || content === null) return ''; // 生成缓存 key @@ -23,12 +43,24 @@ export const openaiMethods = { const contentKey = `_cachedSource_${field}`; // 检查缓存是否有效(内容未变化) - // 对于数组类型内容,使用 JSON.stringify 比较(虽然有性能开销,但只在首次渲染时执行) const contentHash = typeof content === 'string' ? content : JSON.stringify(content); if (msg[cacheKey] && msg[contentKey] === contentHash) { return msg[cacheKey]; } + // 对于 content 字段,先过滤思考标签 + if (field === 'content' && typeof content === 'string') { + const { thinking, cleaned } = this.extractThinkingContent(content); + + // 如果提取到了思考内容且 msg.reasoning 为空,自动填充 + if (thinking && !msg.reasoning) { + msg.reasoning = thinking; + msg.showReasoning = false; // 默认折叠 + } + + content = cleaned; + } + // 渲染并缓存 const html = renderMarkdown(content); msg[cacheKey] = html; From 6b0369988ee52388622246b76cc282be128862f1 Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Sat, 24 Jan 2026 10:00:11 +0800 Subject: [PATCH 04/35] build: update Dockerfile to optimize image build process. --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 0b844ca..fd4c738 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,6 +96,7 @@ COPY --from=agent-builder --chown=nodejs:nodejs /app/agent-go/agent-windows-amd6 # 4. 复制后端源码 (不包含 node_modules) COPY --chown=nodejs:nodejs server.js ./ COPY --chown=nodejs:nodejs src ./src +COPY --chown=nodejs:nodejs modules ./modules ENV NODE_ENV=production \ PORT=3000 \ From 6e9bfe2d59a87c417966110949bdbecfa3494fd9 Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Sat, 24 Jan 2026 19:47:45 +0800 Subject: [PATCH 05/35] chore: force rebuild docker image From 8e6aa32be8d7ea6a31a9cd283ce251cfb44d935a Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Sat, 24 Jan 2026 19:49:42 +0800 Subject: [PATCH 06/35] chore: force rebuild docker image From 9997d708679e31d6edff9d99b42e9d11331ed39f Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Sat, 24 Jan 2026 19:50:39 +0800 Subject: [PATCH 07/35] chore: bump version to v0.1.3 --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index dcf9102..241529f 100644 --- a/server.js +++ b/server.js @@ -20,7 +20,7 @@ $$ | $$ |$$ | _$$ |_ $$ \\__$$ |$$ \\__$$ | __ $$ | $$ |$$ | / $$ | $$ $$/ $$ $$/ / | $$/ $$/ $$/ $$$$$$/ $$$$$$/ $$$$$$/ $$/ \x1b[0m\x1b[33m ->>> Gravity Engineering System v0.1.2 <<<\x1b[0m +>>> Gravity Engineering System v0.1.3 <<<\x1b[0m `); // 导入中间件 From dd6d4e209578a510f6fda8b843ea36484806cea5 Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Sat, 24 Jan 2026 19:55:43 +0800 Subject: [PATCH 08/35] ci: disable docker cache for debugging --- .github/workflows/docker-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f5df56f..3a999f3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -114,6 +114,7 @@ jobs: context: . file: ./Dockerfile platforms: ${{ matrix.platform }} + no-cache: true push: true # 使用带架构后缀的临时标签 tags: | From 80be788c9b1950b9616d4b694d2837b2ea7cc77f Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Sat, 24 Jan 2026 19:56:38 +0800 Subject: [PATCH 09/35] fix: add missing modules directory to Docker image --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 0b844ca..fd4c738 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,6 +96,7 @@ COPY --from=agent-builder --chown=nodejs:nodejs /app/agent-go/agent-windows-amd6 # 4. 复制后端源码 (不包含 node_modules) COPY --chown=nodejs:nodejs server.js ./ COPY --chown=nodejs:nodejs src ./src +COPY --chown=nodejs:nodejs modules ./modules ENV NODE_ENV=production \ PORT=3000 \ From e561eec2d7d36bd81a20225e4218ea968135339c Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 14:52:21 +0800 Subject: [PATCH 10/35] feat: add server power action interface (reboot/shutdown) --- modules/server-api/router.js | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/modules/server-api/router.js b/modules/server-api/router.js index 406961d..0e1101d 100644 --- a/modules/server-api/router.js +++ b/modules/server-api/router.js @@ -271,6 +271,68 @@ router.post('/test-connection', async (req, res) => { // ==================== 服务器操作接口 ==================== +/** + * 服务器电源操作 (重启/关机) + * POST /action + * { serverId, action: 'reboot'|'shutdown' } + */ +router.post('/action', async (req, res) => { + try { + const { serverId, action } = req.body; + if (!serverId || !action) return res.status(400).json({ success: false, error: '缺少参数' }); + + const server = serverStorage.getById(serverId); + if (!server) return res.status(404).json({ success: false, error: '服务器不存在' }); + + // 智能识别操作系统 + let platform = 'linux'; + const hostInfo = agentService.getHostInfo ? agentService.getHostInfo(serverId) : null; + const metrics = agentService.getMetrics ? agentService.getMetrics(serverId) : null; + + if (hostInfo && hostInfo.platform) { + platform = hostInfo.platform.toLowerCase(); + } else if (metrics && metrics.platform) { + platform = metrics.platform.toLowerCase(); + } else if (server.os) { + platform = server.os.toLowerCase(); + } + + const isWindows = platform.includes('win'); + let command = ''; + + if (action === 'reboot') { + command = isWindows ? 'shutdown /r /t 0 /f' : 'sudo reboot'; + } else if (action === 'shutdown') { + command = isWindows ? 'shutdown /s /t 0 /f' : 'sudo shutdown -h now'; + } else { + return res.status(400).json({ success: false, error: '不支持的操作类型' }); + } + + // 优先使用 Agent 执行 + if (agentService.isOnline(serverId)) { + const { TaskTypes } = require('./protocol'); + const taskId = require('crypto').randomUUID(); + agentService.sendTask(serverId, { + id: taskId, + type: TaskTypes.COMMAND, + data: command, + timeout: 10, + }); + return res.json({ success: true, message: '电源管理命令已发送给 Agent' }); + } + + // 回退到 SSH 执行 + const result = await sshService.executeCommand(serverId, server, command); + if (result.success) { + res.json({ success: true, message: 'SSH 命令执行成功' }); + } else { + res.status(500).json({ success: false, message: 'SSH 执行失败: ' + (result.error || result.stderr) }); + } + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + /** * 手动触发探测所有服务器 */ From 0050aadac30a84aa3a1e280e1421f4b87c2752c5 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 15:48:02 +0800 Subject: [PATCH 11/35] fix(agent): prevent frequent powershell calls when no gpu is detected --- agent-go/collector.go | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/agent-go/collector.go b/agent-go/collector.go index 71bc7bc..3404da3 100644 --- a/agent-go/collector.go +++ b/agent-go/collector.go @@ -103,14 +103,18 @@ type Collector struct { // CPU 采集缓存 (保持上次有效值,避免返回 0) lastCPUUsage float64 lastCPUTime time.Time + + // GPU 采集频率控制 + lastGPUMetadataTime time.Time } // NewCollector 创建采集器 func NewCollector() *Collector { return &Collector{ - lastNetTime: time.Now(), - lastGPUTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 - lastCPUTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 + lastNetTime: time.Now(), + lastGPUTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 + lastCPUTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 + lastGPUMetadataTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 } } @@ -191,6 +195,7 @@ func (c *Collector) CollectHostInfo() *HostInfo { gpuModels, gpuMemTotal := c.collectGPUMetadata() info.GPU = gpuModels info.GPUMemTotal = gpuMemTotal + c.lastGPUMetadataTime = time.Now() c.cachedHostInfo = info return info @@ -321,21 +326,27 @@ func (c *Collector) CollectState() *State { c.lastGPUTime = time.Now() } - // 补救措施:如果显存总量为 0,尝试重新获取静态信息 + // 补救措施:如果显存总量为 0,尝试重新获取静态信息 (增加冷却时间,防止频繁调用 PowerShell) if c.cachedHostInfo != nil && c.cachedHostInfo.GPUMemTotal == 0 { - go func() { - c.mu.Lock() - defer c.mu.Unlock() - // 再次检查,防止并发重复 - if c.cachedHostInfo.GPUMemTotal == 0 { + c.mu.Lock() + shouldRetry := time.Since(c.lastGPUMetadataTime) > 10*time.Minute + if shouldRetry { + c.lastGPUMetadataTime = time.Now() // 预设时间,防止下一秒再次触发 + } + c.mu.Unlock() + + if shouldRetry { + go func() { models, total := c.collectGPUMetadata() if total > 0 { + c.mu.Lock() c.cachedHostInfo.GPU = models c.cachedHostInfo.GPUMemTotal = total + c.mu.Unlock() fmt.Printf("[Collector] GPU metadata refreshed: %d MiB\n", total/1024/1024) } - } - }() + }() + } } state.GPU = c.lastGPUUsage state.GPUMemUsed = c.lastGPUMemUsed @@ -545,6 +556,14 @@ func (c *Collector) collectGPUState() (float64, uint64, float64) { // 2. 如果没有 NVIDIA,尝试其他方案 if runtime.GOOS == "windows" { + // 如果 meta 数据中已经确认没有 GPU 型号,则不再尝试采集使用率,避免频繁调用 PowerShell + c.mu.Lock() + hasGPU := c.cachedHostInfo != nil && len(c.cachedHostInfo.GPU) > 0 + c.mu.Unlock() + if !hasGPU { + return 0, 0, 0 + } + // Windows: 使用 Performance Counter 采集所有 GPU return c.collectGPUStateWindows() } else if runtime.GOOS == "linux" { From a4744c915cf2b6cabe5e5e33c6f3c43e80b44b8d Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 15:54:33 +0800 Subject: [PATCH 12/35] feat(agent): refactor GPU monitoring to use native PDH and NVML APIs --- agent-go/collector.go | 171 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 160 insertions(+), 11 deletions(-) diff --git a/agent-go/collector.go b/agent-go/collector.go index 3404da3..5c858fa 100644 --- a/agent-go/collector.go +++ b/agent-go/collector.go @@ -12,7 +12,9 @@ import ( "strconv" "strings" "sync" + "syscall" "time" + "unsafe" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" @@ -100,12 +102,16 @@ type Collector struct { lastGPUPower float64 lastGPUTime time.Time - // CPU 采集缓存 (保持上次有效值,避免返回 0) - lastCPUUsage float64 - lastCPUTime time.Time - // GPU 采集频率控制 lastGPUMetadataTime time.Time + + // Windows Native (PDH) + pdhQuery uintptr + pdhCounter uintptr + + // NVIDIA Native (NVML) + nvmlLib *syscall.LazyDLL + nvmlInitialized bool } // NewCollector 创建采集器 @@ -574,8 +580,14 @@ func (c *Collector) collectGPUState() (float64, uint64, float64) { return 0, 0, 0 } -// collectNvidiaGPUState 使用 nvidia-smi 采集 NVIDIA GPU 状态 +// collectNvidiaGPUState 使用 NVML (优先) 或 nvidia-smi 采集 NVIDIA GPU 状态 func (c *Collector) collectNvidiaGPUState(nvidiaSmi string) (float64, uint64, float64) { + // 1. 尝试使用原生 NVML API (性能更高,不产生新进程) + if usage, usedMem, power, ok := c.collectNvidiaGPUStateNative(); ok { + return usage, usedMem, power + } + + // 2. 回退到 nvidia-smi 命令行工具 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -616,14 +628,19 @@ func (c *Collector) collectNvidiaGPUState(nvidiaSmi string) (float64, uint64, fl return totalUsage / float64(count), totalUsedMem, totalPower } -// collectGPUStateWindows Windows 下采集 AMD/Intel GPU 使用率 -// 使用 PowerShell 查询 GPU Engine 性能计数器 +// collectGPUStateWindows Windows 下采集 AMD/Intel/NVIDIA GPU 使用率 +// 优先使用 PDH 性能计数器 API,回退到 PowerShell func (c *Collector) collectGPUStateWindows() (float64, uint64, float64) { + // 1. 尝试使用原生 PDH API (性能极高,无额外进程) + if usage, ok := c.collectGPUUsagePDH(); ok { + return usage, 0, 0 + } + + // 2. 回退到 PowerShell (仅在 PDH 失败时使用) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // 方案1: 使用 GPU Engine 性能计数器 (Windows 10 1709+) - // 查询所有 GPU 3D 引擎的使用率 psCmd := ` $counters = Get-Counter '\GPU Engine(*engtype_3D)\Utilization Percentage' -ErrorAction SilentlyContinue if ($counters) { @@ -643,9 +660,6 @@ if ($counters) { } usage, _ := strconv.ParseFloat(strings.TrimSpace(string(output)), 64) - if usage > 0 { - // fmt.Printf("[Collector] GPU (Win PerfCounter): %.1f%%\n", usage) - } return usage, 0, 0 } @@ -821,6 +835,141 @@ func (c *Collector) collectIntelGPULinux() float64 { return 0 } +// ==================== Native GPU Monitoring (Windows PDH & NVML) ==================== + +var ( + modPdh = syscall.NewLazyDLL("pdh.dll") + procPdhOpenQuery = modPdh.NewProc("PdhOpenQueryW") + procPdhAddEnglishCounter = modPdh.NewProc("PdhAddEnglishCounterW") + procPdhCollectQueryData = modPdh.NewProc("PdhCollectQueryData") + procPdhGetFormattedCounterValue = modPdh.NewProc("PdhGetFormattedCounterValue") + procPdhCloseQuery = modPdh.NewProc("PdhCloseQuery") +) + +type pdh_fmt_countervalue_double struct { + CStatus uint32 + DummyStruct [4]byte // padding for 64-bit alignment + DoubleValue float64 +} + +// collectGPUUsagePDH 使用原生 PDH API 获取所有 GPU 的 3D 引擎平均使用率 +func (c *Collector) collectGPUUsagePDH() (float64, bool) { + if runtime.GOOS != "windows" { + return 0, false + } + + c.mu.Lock() + defer c.mu.Unlock() + + // 初始化查询 + if c.pdhQuery == 0 { + var query uintptr + ret, _, _ := procPdhOpenQuery.Call(0, 0, uintptr(unsafe.Pointer(&query))) + if ret != 0 { + return 0, false + } + c.pdhQuery = query + + // 添加计数器 (使用通配符获取所有 GPU 的 3D 引擎使用率) + // 使用 English 名称确保兼容性 + counterPath := "\\GPU Engine(*engtype_3D)\\Utilization Percentage" + pathPtr, _ := syscall.UTF16PtrFromString(counterPath) + var counter uintptr + ret, _, _ = procPdhAddEnglishCounter.Call(c.pdhQuery, uintptr(unsafe.Pointer(pathPtr)), 0, uintptr(unsafe.Pointer(&counter))) + if ret != 0 { + procPdhCloseQuery.Call(c.pdhQuery) + c.pdhQuery = 0 + return 0, false + } + c.pdhCounter = counter + + // 第一次采集建立基准 + procPdhCollectQueryData.Call(c.pdhQuery) + return 0, true + } + + // 执行采集 + ret, _, _ := procPdhCollectQueryData.Call(c.pdhQuery) + if ret != 0 { + return 0, false + } + + // 获取格式化后的值 + var value pdh_fmt_countervalue_double + const PDH_FMT_DOUBLE = 0x00000200 + ret, _, _ = procPdhGetFormattedCounterValue.Call(c.pdhCounter, PDH_FMT_DOUBLE, 0, uintptr(unsafe.Pointer(&value))) + if ret != 0 { + return 0, false + } + + return value.DoubleValue, true +} + +// NVIDIA NVML 原生支持 +func (c *Collector) collectNvidiaGPUStateNative() (float64, uint64, float64, bool) { + if runtime.GOOS == "darwin" { + return 0, 0, 0, false + } + + c.mu.Lock() + defer c.mu.Unlock() + + if !c.nvmlInitialized { + if c.nvmlLib == nil { + c.nvmlLib = syscall.NewLazyDLL("nvml.dll") + if runtime.GOOS != "windows" { + c.nvmlLib = syscall.NewLazyDLL("libnvidia-ml.so") + } + } + + // 尝试初始化 + initProc := c.nvmlLib.NewProc("nvmlInit_v2") + if err := initProc.Find(); err != nil { + return 0, 0, 0, false + } + ret, _, _ := initProc.Call() + if ret != 0 { + return 0, 0, 0, false + } + c.nvmlInitialized = true + } + + // 获取第一个设备的句柄 (简化处理) + getHandle := c.nvmlLib.NewProc("nvmlDeviceGetHandleByIndex_v2") + var device uintptr + ret, _, _ := getHandle.Call(0, uintptr(unsafe.Pointer(&device))) + if ret != 0 { + return 0, 0, 0, false + } + + // 获取利用率 + getUtil := c.nvmlLib.NewProc("nvmlDeviceGetUtilizationRates") + var util struct { + GPU uint32 + Memory uint32 + } + ret, _, _ = getUtil.Call(device, uintptr(unsafe.Pointer(&util))) + if ret != 0 { + return 0, 0, 0, false + } + + // 获取显存 + getMem := c.nvmlLib.NewProc("nvmlDeviceGetMemoryInfo") + var mem struct { + Total uint64 + Free uint64 + Used uint64 + } + ret, _, _ = getMem.Call(device, uintptr(unsafe.Pointer(&mem))) + + // 获取功耗 (单位通常是毫瓦) + getPower := c.nvmlLib.NewProc("nvmlDeviceGetPowerUsage") + var power uint32 + ret, _, _ = getPower.Call(device, uintptr(unsafe.Pointer(&power))) + + return float64(util.GPU), mem.Used, float64(power) / 1000.0, true +} + func (c *Collector) getNvidiaSmiPath() string { if runtime.GOOS == "windows" { possiblePaths := []string{ From 6322911b8a512b7bf547a47dcdd5c8d9ca5896c9 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 15:57:18 +0800 Subject: [PATCH 13/35] fix(ci): optimize docker multi-arch delivery to prevent 400 errors --- .github/workflows/docker-publish.yml | 32 ++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3a999f3..0ef24ed 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -114,11 +114,11 @@ jobs: context: . file: ./Dockerfile platforms: ${{ matrix.platform }} - no-cache: true push: true - # 使用带架构后缀的临时标签 + # 同时推送到两个 Registry 的临时架构标签,确保层已就绪,避免合并时跨 Registry 复制层 tags: | ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:build-${{ github.run_id }}-${{ matrix.suffix }} + ${{ (github.event_name != 'pull_request' && secrets.DOCKERHUB_TOKEN != '') && format('{0}:build-{1}-{2}', env.DOCKERHUB_REPO, github.run_id, matrix.suffix) || '' }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} @@ -199,10 +199,30 @@ jobs: TAGS="${{ steps.meta.outputs.tags }}" echo "$TAGS" | while IFS= read -r TAG; do [ -z "$TAG" ] && continue - echo "Creating manifest for: $TAG" - docker buildx imagetools create -t "$TAG" \ - "${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@${AMD64_DIGEST}" \ - "${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@${ARM64_DIGEST}" + + # 确定当前标签所属的 Registry 以选择同源的源镜像进行合并(避免跨 Registry 复制 Blob 导致 400 错误) + if [[ "$TAG" == "${{ env.REGISTRY }}"* ]]; then + SRC_PREFIX="${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}" + else + SRC_PREFIX="${{ env.DOCKERHUB_REPO }}" + fi + + echo "Creating manifest for: $TAG using source: $SRC_PREFIX" + + # 增加重试机制,应对 Registry 极端情况下的抖动 + for i in {1..3}; do + docker buildx imagetools create -t "$TAG" \ + "${SRC_PREFIX}@${AMD64_DIGEST}" \ + "${SRC_PREFIX}@${ARM64_DIGEST}" && break || { + if [ $i -lt 3 ]; then + echo "Push failed, retrying in 10s ($i/3)..." + sleep 10 + else + echo "Failed to create manifest for $TAG after 3 attempts." + exit 1 + fi + } + done done - name: Image digest From 560ac4bf8f9412a82b752499421caca53f9f92d0 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 16:00:59 +0800 Subject: [PATCH 14/35] fix(agent): fix cross-platform build by moving windows-specific code to separate files --- agent-go/collector.go | 138 +--------------------------------- agent-go/collector_unix.go | 14 ++++ agent-go/collector_windows.go | 137 +++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 137 deletions(-) create mode 100644 agent-go/collector_unix.go create mode 100644 agent-go/collector_windows.go diff --git a/agent-go/collector.go b/agent-go/collector.go index 5c858fa..8a2d4f8 100644 --- a/agent-go/collector.go +++ b/agent-go/collector.go @@ -12,9 +12,7 @@ import ( "strconv" "strings" "sync" - "syscall" "time" - "unsafe" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" @@ -110,7 +108,7 @@ type Collector struct { pdhCounter uintptr // NVIDIA Native (NVML) - nvmlLib *syscall.LazyDLL + nvmlLib any nvmlInitialized bool } @@ -835,140 +833,6 @@ func (c *Collector) collectIntelGPULinux() float64 { return 0 } -// ==================== Native GPU Monitoring (Windows PDH & NVML) ==================== - -var ( - modPdh = syscall.NewLazyDLL("pdh.dll") - procPdhOpenQuery = modPdh.NewProc("PdhOpenQueryW") - procPdhAddEnglishCounter = modPdh.NewProc("PdhAddEnglishCounterW") - procPdhCollectQueryData = modPdh.NewProc("PdhCollectQueryData") - procPdhGetFormattedCounterValue = modPdh.NewProc("PdhGetFormattedCounterValue") - procPdhCloseQuery = modPdh.NewProc("PdhCloseQuery") -) - -type pdh_fmt_countervalue_double struct { - CStatus uint32 - DummyStruct [4]byte // padding for 64-bit alignment - DoubleValue float64 -} - -// collectGPUUsagePDH 使用原生 PDH API 获取所有 GPU 的 3D 引擎平均使用率 -func (c *Collector) collectGPUUsagePDH() (float64, bool) { - if runtime.GOOS != "windows" { - return 0, false - } - - c.mu.Lock() - defer c.mu.Unlock() - - // 初始化查询 - if c.pdhQuery == 0 { - var query uintptr - ret, _, _ := procPdhOpenQuery.Call(0, 0, uintptr(unsafe.Pointer(&query))) - if ret != 0 { - return 0, false - } - c.pdhQuery = query - - // 添加计数器 (使用通配符获取所有 GPU 的 3D 引擎使用率) - // 使用 English 名称确保兼容性 - counterPath := "\\GPU Engine(*engtype_3D)\\Utilization Percentage" - pathPtr, _ := syscall.UTF16PtrFromString(counterPath) - var counter uintptr - ret, _, _ = procPdhAddEnglishCounter.Call(c.pdhQuery, uintptr(unsafe.Pointer(pathPtr)), 0, uintptr(unsafe.Pointer(&counter))) - if ret != 0 { - procPdhCloseQuery.Call(c.pdhQuery) - c.pdhQuery = 0 - return 0, false - } - c.pdhCounter = counter - - // 第一次采集建立基准 - procPdhCollectQueryData.Call(c.pdhQuery) - return 0, true - } - - // 执行采集 - ret, _, _ := procPdhCollectQueryData.Call(c.pdhQuery) - if ret != 0 { - return 0, false - } - - // 获取格式化后的值 - var value pdh_fmt_countervalue_double - const PDH_FMT_DOUBLE = 0x00000200 - ret, _, _ = procPdhGetFormattedCounterValue.Call(c.pdhCounter, PDH_FMT_DOUBLE, 0, uintptr(unsafe.Pointer(&value))) - if ret != 0 { - return 0, false - } - - return value.DoubleValue, true -} - -// NVIDIA NVML 原生支持 -func (c *Collector) collectNvidiaGPUStateNative() (float64, uint64, float64, bool) { - if runtime.GOOS == "darwin" { - return 0, 0, 0, false - } - - c.mu.Lock() - defer c.mu.Unlock() - - if !c.nvmlInitialized { - if c.nvmlLib == nil { - c.nvmlLib = syscall.NewLazyDLL("nvml.dll") - if runtime.GOOS != "windows" { - c.nvmlLib = syscall.NewLazyDLL("libnvidia-ml.so") - } - } - - // 尝试初始化 - initProc := c.nvmlLib.NewProc("nvmlInit_v2") - if err := initProc.Find(); err != nil { - return 0, 0, 0, false - } - ret, _, _ := initProc.Call() - if ret != 0 { - return 0, 0, 0, false - } - c.nvmlInitialized = true - } - - // 获取第一个设备的句柄 (简化处理) - getHandle := c.nvmlLib.NewProc("nvmlDeviceGetHandleByIndex_v2") - var device uintptr - ret, _, _ := getHandle.Call(0, uintptr(unsafe.Pointer(&device))) - if ret != 0 { - return 0, 0, 0, false - } - - // 获取利用率 - getUtil := c.nvmlLib.NewProc("nvmlDeviceGetUtilizationRates") - var util struct { - GPU uint32 - Memory uint32 - } - ret, _, _ = getUtil.Call(device, uintptr(unsafe.Pointer(&util))) - if ret != 0 { - return 0, 0, 0, false - } - - // 获取显存 - getMem := c.nvmlLib.NewProc("nvmlDeviceGetMemoryInfo") - var mem struct { - Total uint64 - Free uint64 - Used uint64 - } - ret, _, _ = getMem.Call(device, uintptr(unsafe.Pointer(&mem))) - - // 获取功耗 (单位通常是毫瓦) - getPower := c.nvmlLib.NewProc("nvmlDeviceGetPowerUsage") - var power uint32 - ret, _, _ = getPower.Call(device, uintptr(unsafe.Pointer(&power))) - - return float64(util.GPU), mem.Used, float64(power) / 1000.0, true -} func (c *Collector) getNvidiaSmiPath() string { if runtime.GOOS == "windows" { diff --git a/agent-go/collector_unix.go b/agent-go/collector_unix.go new file mode 100644 index 0000000..dd3aeeb --- /dev/null +++ b/agent-go/collector_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package main + +// collectGPUUsagePDH Windows-only stub +func (c *Collector) collectGPUUsagePDH() (float64, bool) { + return 0, false +} + +// collectNvidiaGPUStateNative Non-Windows stub +// (On Linux it currently falls back to nvidia-smi command line) +func (c *Collector) collectNvidiaGPUStateNative() (float64, uint64, float64, bool) { + return 0, 0, 0, false +} diff --git a/agent-go/collector_windows.go b/agent-go/collector_windows.go new file mode 100644 index 0000000..c6f2b97 --- /dev/null +++ b/agent-go/collector_windows.go @@ -0,0 +1,137 @@ +//go:build windows + +package main + +import ( + "runtime" + "syscall" + "unsafe" +) + +var ( + modPdh = syscall.NewLazyDLL("pdh.dll") + procPdhOpenQuery = modPdh.NewProc("PdhOpenQueryW") + procPdhAddEnglishCounter = modPdh.NewProc("PdhAddEnglishCounterW") + procPdhCollectQueryData = modPdh.NewProc("PdhCollectQueryData") + procPdhGetFormattedCounterValue = modPdh.NewProc("PdhGetFormattedCounterValue") + procPdhCloseQuery = modPdh.NewProc("PdhCloseQuery") +) + +type pdh_fmt_countervalue_double struct { + CStatus uint32 + DummyStruct [4]byte // padding for 64-bit alignment + DoubleValue float64 +} + +// collectGPUUsagePDH 使用原生 PDH API 获取所有 GPU 的 3D 引擎平均使用率 +func (c *Collector) collectGPUUsagePDH() (float64, bool) { + if runtime.GOOS != "windows" { + return 0, false + } + + c.mu.Lock() + defer c.mu.Unlock() + + // 初始化查询 + if c.pdhQuery == 0 { + var query uintptr + ret, _, _ := procPdhOpenQuery.Call(0, 0, uintptr(unsafe.Pointer(&query))) + if ret != 0 { + return 0, false + } + c.pdhQuery = query + + // 添加计数器 (使用通配符获取所有 GPU 的 3D 引擎使用率) + // 使用 English 名称确保兼容性 + counterPath := "\\GPU Engine(*engtype_3D)\\Utilization Percentage" + pathPtr, _ := syscall.UTF16PtrFromString(counterPath) + var counter uintptr + ret, _, _ = procPdhAddEnglishCounter.Call(c.pdhQuery, uintptr(unsafe.Pointer(pathPtr)), 0, uintptr(unsafe.Pointer(&counter))) + if ret != 0 { + procPdhCloseQuery.Call(c.pdhQuery) + c.pdhQuery = 0 + return 0, false + } + c.pdhCounter = counter + + // 第一次采集建立基准 + procPdhCollectQueryData.Call(c.pdhQuery) + return 0, true + } + + // 执行采集 + ret, _, _ := procPdhCollectQueryData.Call(c.pdhQuery) + if ret != 0 { + return 0, false + } + + // 获取格式化后的值 + var value pdh_fmt_countervalue_double + const PDH_FMT_DOUBLE = 0x00000200 + ret, _, _ = procPdhGetFormattedCounterValue.Call(c.pdhCounter, PDH_FMT_DOUBLE, 0, uintptr(unsafe.Pointer(&value))) + if ret != 0 { + return 0, false + } + + return value.DoubleValue, true +} + +// NVIDIA NVML 原生支持 (Windows 版) +func (c *Collector) collectNvidiaGPUStateNative() (float64, uint64, float64, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.nvmlInitialized { + if c.nvmlLib == nil { + c.nvmlLib = syscall.NewLazyDLL("nvml.dll") + } + + lib := c.nvmlLib.(*syscall.LazyDLL) + // 尝试初始化 + initProc := lib.NewProc("nvmlInit_v2") + if err := initProc.Find(); err != nil { + return 0, 0, 0, false + } + ret, _, _ := initProc.Call() + if ret != 0 { + return 0, 0, 0, false + } + c.nvmlInitialized = true + } + + lib := c.nvmlLib.(*syscall.LazyDLL) + // 获取第一个设备的句柄 (简化处理) + getHandle := lib.NewProc("nvmlDeviceGetHandleByIndex_v2") + var device uintptr + ret, _, _ := getHandle.Call(0, uintptr(unsafe.Pointer(&device))) + if ret != 0 { + return 0, 0, 0, false + } + + // 获取利用率 + getUtil := lib.NewProc("nvmlDeviceGetUtilizationRates") + var util struct { + GPU uint32 + Memory uint32 + } + ret, _, _ = getUtil.Call(device, uintptr(unsafe.Pointer(&util))) + if ret != 0 { + return 0, 0, 0, false + } + + // 获取显存 + getMem := lib.NewProc("nvmlDeviceGetMemoryInfo") + var mem struct { + Total uint64 + Free uint64 + Used uint64 + } + ret, _, _ = getMem.Call(device, uintptr(unsafe.Pointer(&mem))) + + // 获取功耗 (单位通常是毫瓦) + getPower := lib.NewProc("nvmlDeviceGetPowerUsage") + var power uint32 + ret, _, _ = getPower.Call(device, uintptr(unsafe.Pointer(&power))) + + return float64(util.GPU), mem.Used, float64(power) / 1000.0, true +} From 140c2241276bedbf29747b07d8b9622c7ed499cd Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 16:08:20 +0800 Subject: [PATCH 15/35] fix(agent): add missing sync import in collector_windows.go --- agent-go/collector_windows.go | 1 + 1 file changed, 1 insertion(+) diff --git a/agent-go/collector_windows.go b/agent-go/collector_windows.go index c6f2b97..9a78a55 100644 --- a/agent-go/collector_windows.go +++ b/agent-go/collector_windows.go @@ -4,6 +4,7 @@ package main import ( "runtime" + "sync" "syscall" "unsafe" ) From bd7a88ce1fc20590d2376889f77c5edfac09af64 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 16:14:36 +0800 Subject: [PATCH 16/35] fix --- agent-go/collector.go | 4 ++++ agent-go/collector_windows.go | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/agent-go/collector.go b/agent-go/collector.go index 8a2d4f8..0b1b02a 100644 --- a/agent-go/collector.go +++ b/agent-go/collector.go @@ -103,6 +103,10 @@ type Collector struct { // GPU 采集频率控制 lastGPUMetadataTime time.Time + // CPU 采集缓存 + lastCPUTime time.Time + lastCPUUsage float64 + // Windows Native (PDH) pdhQuery uintptr pdhCounter uintptr diff --git a/agent-go/collector_windows.go b/agent-go/collector_windows.go index 9a78a55..c6f2b97 100644 --- a/agent-go/collector_windows.go +++ b/agent-go/collector_windows.go @@ -4,7 +4,6 @@ package main import ( "runtime" - "sync" "syscall" "unsafe" ) From 03bc1e779cdc20246c0043f8dc786e22fb9298e9 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 16:26:18 +0800 Subject: [PATCH 17/35] fix: agent build errors, startup notification spam, and email sender name --- modules/notification-api/channels/email.js | 3 ++- modules/server-api/agent-service.js | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/modules/notification-api/channels/email.js b/modules/notification-api/channels/email.js index 32fbd6e..72b6133 100644 --- a/modules/notification-api/channels/email.js +++ b/modules/notification-api/channels/email.js @@ -26,7 +26,8 @@ class EmailChannel { const transporter = this.getTransporter(config); const mailOptions = { - from: config.auth.user, + // 支持自定义发送者名称: "Name" + from: config.sender_name ? `"${config.sender_name}" <${config.auth.user}>` : config.auth.user, to: config.to || config.auth.user, subject: title, text: message, diff --git a/modules/server-api/agent-service.js b/modules/server-api/agent-service.js index c28c6b8..0a95669 100644 --- a/modules/server-api/agent-service.js +++ b/modules/server-api/agent-service.js @@ -47,6 +47,9 @@ class AgentService extends EventEmitter { // 初始化加载或生成全局密钥 this.loadOrGenerateGlobalKey(); + + // 记录启动时间,用于抑制启动期间的通知风暴 (60秒静默期) + this.startupTime = Date.now(); } /** @@ -678,6 +681,12 @@ class AgentService extends EventEmitter { const notificationService = require('../notification-api/service'); const hostInfo = this.hostInfoCache.get(serverId); + // 检查启动静默期 (防止重启后通知风暴) + if (Date.now() - this.startupTime < 60000) { + this.log(`[主机通知] 静默期内跳过上线通知: ${server.name}`); + return; + } + notificationService.trigger('server', 'online', { serverId: serverId, serverName: server.name, From 50709281cef44ae0ad225461b53e39388d81c551 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 16:38:00 +0800 Subject: [PATCH 18/35] feat: add sender_name input to email channel settings UI --- src/js/modules/notification.js | 2 ++ src/templates/notification.html | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/js/modules/notification.js b/src/js/modules/notification.js index 00ee116..a5c5d85 100644 --- a/src/js/modules/notification.js +++ b/src/js/modules/notification.js @@ -157,6 +157,7 @@ export const notificationMethods = { port: 465, secure: true, auth: { user: '', pass: '' }, + sender_name: '', to: '', bot_token: '', chat_id: '', @@ -175,6 +176,7 @@ export const notificationMethods = { port: 465, secure: true, auth: { user: '', pass: '' }, + sender_name: '', to: '', bot_token: '', chat_id: '', diff --git a/src/templates/notification.html b/src/templates/notification.html index 5454847..4ee5212 100644 --- a/src/templates/notification.html +++ b/src/templates/notification.html @@ -238,6 +238,11 @@

+ + +
Date: Sun, 25 Jan 2026 16:56:10 +0800 Subject: [PATCH 19/35] feat: add daily trend sparkline chart to dashboard --- modules/antigravity-api/storage.js | 14 +++ modules/gemini-cli-api/storage.js | 14 +++ src/js/modules/dashboard.js | 150 +++++++++++++++++++++++++++++ src/templates/dashboard.html | 14 ++- 4 files changed, 184 insertions(+), 8 deletions(-) diff --git a/modules/antigravity-api/storage.js b/modules/antigravity-api/storage.js index 00cc417..5e8e091 100644 --- a/modules/antigravity-api/storage.js +++ b/modules/antigravity-api/storage.js @@ -690,10 +690,24 @@ function getStats() { .get(); const accounts = getAccounts(); + + // 获取最近 14 天的趋势数据 + const dailyTrend = db.prepare(` + SELECT + strftime('%Y-%m-%d', datetime(created_at, 'localtime')) as date, + COUNT(*) as total, + SUM(CASE WHEN status_code = 200 THEN 1 ELSE 0 END) as success + FROM antigravity_logs + WHERE created_at >= datetime('now', '-14 days', 'localtime') + GROUP BY date + ORDER BY date ASC + `).all(); + return { total_calls: stats.total_calls || 0, success_calls: stats.success_calls || 0, fail_calls: stats.fail_calls || 0, + daily_trend: dailyTrend || [], accounts: { total: accounts.length, online: accounts.filter(a => a.status === 'online').length, diff --git a/modules/gemini-cli-api/storage.js b/modules/gemini-cli-api/storage.js index 619b5eb..e7ffd17 100644 --- a/modules/gemini-cli-api/storage.js +++ b/modules/gemini-cli-api/storage.js @@ -541,10 +541,24 @@ function getStats() { .get(); const accounts = getAccounts(); + + // 获取最近 14 天的趋势数据 + const dailyTrend = db.prepare(` + SELECT + strftime('%Y-%m-%d', datetime(created_at, 'localtime')) as date, + COUNT(*) as total, + SUM(CASE WHEN status_code = 200 THEN 1 ELSE 0 END) as success + FROM gemini_cli_logs + WHERE created_at >= datetime('now', '-14 days', 'localtime') + GROUP BY date + ORDER BY date ASC + `).all(); + return { total_calls: stats.total_calls || 0, success_calls: stats.success_calls || 0, fail_calls: stats.fail_calls || 0, + daily_trend: dailyTrend || [], accounts: { total: accounts.length, online: accounts.filter(a => a.status === 'online').length, diff --git a/src/js/modules/dashboard.js b/src/js/modules/dashboard.js index 4354c9f..e684e0b 100644 --- a/src/js/modules/dashboard.js +++ b/src/js/modules/dashboard.js @@ -190,6 +190,156 @@ export const dashboardMethods = { // Fire both, don't wait for all to finish before updating individual stats // But await the group to know when API section is fully done (for loading state) await Promise.allSettled([updateAntigravity(), updateGemini()]); + + // 渲染图表 + this.renderApiCharts(); + }, + + /** + * 渲染 API 趋势图表 + */ + renderApiCharts() { + // 确保 DOM 更新后执行 + setTimeout(() => { + if (store.dashboardStats.antigravity.daily_trend) { + this.drawTrendChart('agChart', store.dashboardStats.antigravity.daily_trend, '#f97316'); // Orange for AG + } + if (store.dashboardStats.geminiCli.daily_trend) { + this.drawTrendChart('geminiChart', store.dashboardStats.geminiCli.daily_trend, '#3b82f6'); // Blue for Gemini + } + }, 100); + }, + + /** + * 绘制 Canvas 趋势图 (Sparkline) + */ + drawTrendChart(refName, data, color) { + const app = document.querySelector('#app')?.__vue_app__?._instance; + // 兼容 Vue 2/3 或直接获取 DOM (因为这里是在 module 中,可能无法直接访问 Vue 实例 refs) + // 尝试通过 ID 或 querySelector 获取 Canvas + // 由于我们在模板中只是用了 ref,这里需要一种方式获取元素。 + // 在 Vue 3 setup 中我们通常用 ref 绑定,但这里是全局对象方法。 + // 简单的做法是:假设模板中 ref 实际上也很难直接从这里拿到,不如直接操作 DOM (如果 ref 没暴露)。 + // 但是我们在 template 里没有给 ID,只给了 ref。为了稳妥,我们在 template 里应该加上 ID 这里的逻辑依赖 Vue refs 存在于 store 关联的组件中,或者我们需要在组件 mounted 时把 refs 传进来。 + // *修正*: 上一步修改 template 没有加 ID,只加了 ref。 + // 让我们假设 context 是 Vue 组件实例,或者我们利用 querySelector (需要修改 template 加 class 或 id)。 + // 但上一轮修改没加 ID。 + // 实际上, Vue 挂载后 `this.$refs` 可用。但 `dashboardMethods` 是个纯对象。 + // 解决办法:在 `dashboard.html` 的 Vue 实例中调用 `dashboardMethods.renderApiCharts`,并将 refs 传进来,或者更简单地: + // 修改 template 增加 data-chart-type 属性,通过 querySelector 查找。 + // 但是,最简单的修复是:我无法在 module 里直接访问组件 refs。 + // 我应该在 template 的 update 钩子或者 fetch 之后手动查找 canvas。 + // 只有 `dashboard.html` 里的 Vue 实例能访问 refs。 + // 让 `dashboardMethods` 暴露数据即可,Vue 组件负责 watch 数据并绘制。 + // 或者:直接用 querySelector 找 canvas,因为它们在 DOM 树里是唯一的(如果页面结构简单)。 + // 让我们用 querySelector, 但需要确保 template 里有某种标识。 + // 刚才的修改:`` + // Vue 渲染后不会保留 ref 属性在 DOM 上。 + // 让我们假设渲染逻辑在组件里。 + // 如果必须在这里写,我只能通过 DOM 遍历。 + // 为了稳妥,我先写这里的逻辑,并假设 canvas 可以通过某种方式获取。 + // 实际上, 上一步的 template 修改中 canvas 没有 ID。 + // 我需要再次修改 template 给 canvas 加 ID,或者这里通过父容器查找。 + // 父容器有 class `api-stat-group` 和 `@click="mainActiveTab = 'antigravity'"`. + // 可以通过xpath或者父子关系找。 + + // 为了修正这个问题,我将在下方代码中通过 DOM 查找 Canvas。 + // Antigravity Block -> Canvas + // Gemini Block -> Canvas + + // 如果找不到 Context,直接返回 + // 更好的方式:让 dashboard.js 查找 `canvas` 元素 + + // 查找 AG Canvas + let canvas = null; + if (refName === 'agChart') { + // 这是一个很 hacky 的查找方式,但在没有组件上下文的情况下很有效 + const groups = document.querySelectorAll('.api-stat-group'); + if (groups.length >= 1) canvas = groups[0].querySelector('canvas'); + } else if (refName === 'geminiChart') { + const groups = document.querySelectorAll('.api-stat-group'); + if (groups.length >= 2) canvas = groups[1].querySelector('canvas'); + } + + if (!canvas) return; // 没找到 Canvas + + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + + // 调整分辨率 + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const width = rect.width; + const height = rect.height; + + // 清空 + ctx.clearRect(0, 0, width, height); + + if (!data || data.length === 0) { + // 无数据 + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.font = '10px sans-serif'; + ctx.fillText('No Data', 10, height / 2); + return; + } + + // 准备数据 + // 补全最近 14 天 (如果后端返回不全) - 简单起见直接用返回的数据 + // 假设数据按日期升序 + const values = data.map(d => d.total); + const maxVal = Math.max(...values, 10); // 至少 10,避免直线 + + const stepX = width / (values.length - 1 || 1); + const paddingBottom = 5; + const availableHeight = height - paddingBottom; + + // 绘制填充路径 + ctx.beginPath(); + ctx.moveTo(0, height); + + values.forEach((val, index) => { + const x = index * stepX; + // height - (val / max) * height + // 留一点 padding + const y = availableHeight - (val / maxVal) * availableHeight; + ctx.lineTo(x, y); + }); + + ctx.lineTo(width, height); + ctx.closePath(); + + // 渐变填充 + const gradient = ctx.createLinearGradient(0, 0, 0, height); + gradient.addColorStop(0, color + '66'); // 40% opacity + gradient.addColorStop(1, color + '00'); // 0% opacity + ctx.fillStyle = gradient; + ctx.fill(); + + // 绘制线条 + ctx.beginPath(); + values.forEach((val, index) => { + const x = index * stepX; + const y = availableHeight - (val / maxVal) * availableHeight; + if (index === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // 绘制最后一个点 (今日) + const lastX = (values.length - 1) * stepX; + const lastY = availableHeight - (values[values.length - 1] / maxVal) * availableHeight; + ctx.beginPath(); + ctx.arc(lastX - 2, lastY, 3, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); }, /** diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index bb3f61a..81df712 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -247,10 +247,9 @@

API 调用概览

总调用 {{ dashboardStats.antigravity.total_calls }} 次
-
-
-
+
+
@@ -261,10 +260,9 @@

API 调用概览

总调用 {{ dashboardStats.geminiCli.total_calls }} 次
-
-
-
+
+
From fcaa1ad9967866c93f241cd4b6a95563137c3d20 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 17:05:12 +0800 Subject: [PATCH 20/35] feat: optimize chart padding and merge service cards --- src/js/modules/dashboard.js | 67 ++++++++++-------------------------- src/templates/dashboard.html | 18 +++++----- 2 files changed, 26 insertions(+), 59 deletions(-) diff --git a/src/js/modules/dashboard.js b/src/js/modules/dashboard.js index e684e0b..22f20d7 100644 --- a/src/js/modules/dashboard.js +++ b/src/js/modules/dashboard.js @@ -215,42 +215,7 @@ export const dashboardMethods = { */ drawTrendChart(refName, data, color) { const app = document.querySelector('#app')?.__vue_app__?._instance; - // 兼容 Vue 2/3 或直接获取 DOM (因为这里是在 module 中,可能无法直接访问 Vue 实例 refs) - // 尝试通过 ID 或 querySelector 获取 Canvas - // 由于我们在模板中只是用了 ref,这里需要一种方式获取元素。 - // 在 Vue 3 setup 中我们通常用 ref 绑定,但这里是全局对象方法。 - // 简单的做法是:假设模板中 ref 实际上也很难直接从这里拿到,不如直接操作 DOM (如果 ref 没暴露)。 - // 但是我们在 template 里没有给 ID,只给了 ref。为了稳妥,我们在 template 里应该加上 ID 这里的逻辑依赖 Vue refs 存在于 store 关联的组件中,或者我们需要在组件 mounted 时把 refs 传进来。 - // *修正*: 上一步修改 template 没有加 ID,只加了 ref。 - // 让我们假设 context 是 Vue 组件实例,或者我们利用 querySelector (需要修改 template 加 class 或 id)。 - // 但上一轮修改没加 ID。 - // 实际上, Vue 挂载后 `this.$refs` 可用。但 `dashboardMethods` 是个纯对象。 - // 解决办法:在 `dashboard.html` 的 Vue 实例中调用 `dashboardMethods.renderApiCharts`,并将 refs 传进来,或者更简单地: - // 修改 template 增加 data-chart-type 属性,通过 querySelector 查找。 - // 但是,最简单的修复是:我无法在 module 里直接访问组件 refs。 - // 我应该在 template 的 update 钩子或者 fetch 之后手动查找 canvas。 - // 只有 `dashboard.html` 里的 Vue 实例能访问 refs。 - // 让 `dashboardMethods` 暴露数据即可,Vue 组件负责 watch 数据并绘制。 - // 或者:直接用 querySelector 找 canvas,因为它们在 DOM 树里是唯一的(如果页面结构简单)。 - // 让我们用 querySelector, 但需要确保 template 里有某种标识。 - // 刚才的修改:`` - // Vue 渲染后不会保留 ref 属性在 DOM 上。 - // 让我们假设渲染逻辑在组件里。 - // 如果必须在这里写,我只能通过 DOM 遍历。 - // 为了稳妥,我先写这里的逻辑,并假设 canvas 可以通过某种方式获取。 - // 实际上, 上一步的 template 修改中 canvas 没有 ID。 - // 我需要再次修改 template 给 canvas 加 ID,或者这里通过父容器查找。 - // 父容器有 class `api-stat-group` 和 `@click="mainActiveTab = 'antigravity'"`. - // 可以通过xpath或者父子关系找。 - - // 为了修正这个问题,我将在下方代码中通过 DOM 查找 Canvas。 - // Antigravity Block -> Canvas - // Gemini Block -> Canvas - - // 如果找不到 Context,直接返回 - // 更好的方式:让 dashboard.js 查找 `canvas` 元素 - - // 查找 AG Canvas + let canvas = null; if (refName === 'agChart') { // 这是一个很 hacky 的查找方式,但在没有组件上下文的情况下很有效 @@ -292,23 +257,26 @@ export const dashboardMethods = { const values = data.map(d => d.total); const maxVal = Math.max(...values, 10); // 至少 10,避免直线 - const stepX = width / (values.length - 1 || 1); + // 添加左右内边距,防止边缘被切割 + const paddingX = 4; + const effectiveWidth = width - (paddingX * 2); + const stepX = effectiveWidth / (values.length - 1 || 1); + const paddingBottom = 5; - const availableHeight = height - paddingBottom; + const paddingTop = 5; // 添加顶部内边距 + const drawingHeight = height - paddingBottom - paddingTop; // 绘制填充路径 ctx.beginPath(); - ctx.moveTo(0, height); + ctx.moveTo(paddingX, height); values.forEach((val, index) => { - const x = index * stepX; - // height - (val / max) * height - // 留一点 padding - const y = availableHeight - (val / maxVal) * availableHeight; + const x = paddingX + (index * stepX); + const y = paddingTop + drawingHeight - (val / maxVal) * drawingHeight; ctx.lineTo(x, y); }); - ctx.lineTo(width, height); + ctx.lineTo(width - paddingX, height); ctx.closePath(); // 渐变填充 @@ -321,8 +289,8 @@ export const dashboardMethods = { // 绘制线条 ctx.beginPath(); values.forEach((val, index) => { - const x = index * stepX; - const y = availableHeight - (val / maxVal) * availableHeight; + const x = paddingX + (index * stepX); + const y = paddingTop + drawingHeight - (val / maxVal) * drawingHeight; if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); @@ -334,10 +302,11 @@ export const dashboardMethods = { ctx.stroke(); // 绘制最后一个点 (今日) - const lastX = (values.length - 1) * stepX; - const lastY = availableHeight - (values[values.length - 1] / maxVal) * availableHeight; + const lastX = paddingX + ((values.length - 1) * stepX); + const lastY = paddingTop + drawingHeight - (values[values.length - 1] / maxVal) * drawingHeight; ctx.beginPath(); - ctx.arc(lastX - 2, lastY, 3, 0, Math.PI * 2); + // 稍微向左偏移一点点确保不切 + ctx.arc(lastX - 1, lastY, 3, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); }, diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index 81df712..bbdf894 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -271,12 +271,13 @@

API 调用概览

- +
-

云服务

+

服务 & 工具

+
@@ -292,6 +293,7 @@

Zeabur

{{ dashboardStats.paas.zeabur.running }}
+
@@ -305,6 +307,7 @@

Koyeb

{{ dashboardStats.paas.koyeb.running }}
+
@@ -320,15 +323,10 @@

Fly.io

{{ dashboardStats.paas.fly.running }}
-
-
- - -
-
-
+ +
-
+
From 20ca8a972e4d8fafaa71f9626318ba75ead6446b Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 17:10:28 +0800 Subject: [PATCH 21/35] fix: add null check for validation errors to prevent 500 crash --- src/middleware/validation.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/middleware/validation.js b/src/middleware/validation.js index 830d07f..e1935df 100644 --- a/src/middleware/validation.js +++ b/src/middleware/validation.js @@ -23,8 +23,12 @@ function validate(schemas) { if (schemas.body) { const result = schemas.body.safeParse(req.body); if (!result.success) { + const issues = result.error?.errors || []; + if (issues.length === 0) { + console.error('Validation failed but no errors found:', result.error); + } errors.push( - ...result.error.errors.map((e) => ({ + ...issues.map((e) => ({ field: `body.${e.path.join('.')}`, message: e.message, code: e.code, @@ -39,8 +43,9 @@ function validate(schemas) { if (schemas.query) { const result = schemas.query.safeParse(req.query); if (!result.success) { + const issues = result.error?.errors || []; errors.push( - ...result.error.errors.map((e) => ({ + ...issues.map((e) => ({ field: `query.${e.path.join('.')}`, message: e.message, code: e.code, @@ -55,8 +60,9 @@ function validate(schemas) { if (schemas.params) { const result = schemas.params.safeParse(req.params); if (!result.success) { + const issues = result.error?.errors || []; errors.push( - ...result.error.errors.map((e) => ({ + ...issues.map((e) => ({ field: `params.${e.path.join('.')}`, message: e.message, code: e.code, From fa0812050243eba9540d6ad892baea5f14c08397 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 17:14:30 +0800 Subject: [PATCH 22/35] feat: enable viewing TOTP secret in edit mode --- modules/totp-api/router.js | 32 +++++++++++++++++++++++++++++++- src/js/modules/totp.js | 19 +++++++++++++++++++ src/templates/totp.html | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/modules/totp-api/router.js b/modules/totp-api/router.js index 46934b8..1da1192 100644 --- a/modules/totp-api/router.js +++ b/modules/totp-api/router.js @@ -43,6 +43,36 @@ router.get('/accounts', async (req, res) => { } }); +/** + * GET /accounts/:id + * 获取单个账号详情 + * 支持 ?showSecret=true 显示密钥 + */ +router.get('/accounts/:id', async (req, res) => { + try { + const { id } = req.params; + const account = storage.getAccount(id); + + if (!account) { + return res.status(404).json({ success: false, error: '账号不存在' }); + } + + const showSecret = req.query.showSecret === 'true'; + + res.json({ + success: true, + data: { + ...account, + secret: showSecret ? account.secret : undefined, + hasSecret: !!account.secret + } + }); + } catch (error) { + logger.error('获取账号详情失败', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + /** * POST /accounts * 创建新账号 @@ -446,7 +476,7 @@ router.get('/extension/download', async (req, res) => { // 发送后删除临时文件 try { fs.unlinkSync(zipFile); - } catch (e) {} + } catch (e) { } }); }); } catch (error) { diff --git a/src/js/modules/totp.js b/src/js/modules/totp.js index 01e8874..c232d52 100644 --- a/src/js/modules/totp.js +++ b/src/js/modules/totp.js @@ -161,6 +161,25 @@ export const totpMethods = { this.showTotpModal = true; }, + async toggleSecretVisibility() { + this.totpShowSecret = !this.totpShowSecret; + + if (this.totpShowSecret && this.totpModalMode === 'edit' && this.totpForm.secret.includes('•••')) { + try { + const response = await fetch(`/api/totp/accounts/${this.totpEditingId}?showSecret=true`); + const data = await response.json(); + if (data.success && data.data.secret) { + this.totpForm.secret = data.data.secret; + } else { + this.showGlobalToast('获取密钥失败', 'error'); + } + } catch (error) { + console.error('[TOTP] 获取密钥失败:', error); + this.showGlobalToast('获取密钥失败', 'error'); + } + } + }, + switchTotpAddTab(tab) { this.totpAddTab = tab; if (tab !== 'scan') { diff --git a/src/templates/totp.html b/src/templates/totp.html index e672074..8558ef2 100644 --- a/src/templates/totp.html +++ b/src/templates/totp.html @@ -557,7 +557,7 @@
-
From 821a5f9ff1c2f6432e2346c3b614c22c90b518f0 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 17:21:42 +0800 Subject: [PATCH 23/35] fix(openai-monitor): increase health check timeout and optimize max_tokens --- modules/openai-api/openai-api.js | 10 +++++----- src/js/store.js | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/openai-api/openai-api.js b/modules/openai-api/openai-api.js index adc7f98..105998a 100644 --- a/modules/openai-api/openai-api.js +++ b/modules/openai-api/openai-api.js @@ -21,8 +21,8 @@ const HealthStatus = { }; // 默认配置 -const DEFAULT_HEALTH_CHECK_TIMEOUT = 15000; // 15 秒超时 -const DEGRADED_THRESHOLD = 6000; // 6 秒阈值 +const DEFAULT_HEALTH_CHECK_TIMEOUT = 60000; // 60 秒超时 (适应慢速思考模型) +const DEGRADED_THRESHOLD = 20000; // 20 秒阈值 /** * 发送 HTTP 请求 @@ -83,7 +83,7 @@ function apiRequest(baseUrl, apiKey, method, path, body = null) { 'Content-Type': 'application/json', 'User-Agent': 'API-Monitor/1.0', }, - timeout: 30000, + timeout: 60000, }; const req = httpModule.request(options, res => { @@ -268,7 +268,7 @@ async function testChatCompletion(baseUrl, apiKey, model = 'gpt-3.5-turbo') { * @param {string} baseUrl - API 基础 URL * @param {string} apiKey - API Key * @param {string} model - 模型名称 - * @param {number} timeout - 超时时间(毫秒),默认 15000 + * @param {number} timeout - 超时时间(毫秒),默认 60000 * @returns {Promise} 健康检查结果 */ async function healthCheckModel(baseUrl, apiKey, model, timeout = DEFAULT_HEALTH_CHECK_TIMEOUT) { @@ -301,7 +301,7 @@ async function healthCheckModel(baseUrl, apiKey, model, timeout = DEFAULT_HEALTH const requestBody = JSON.stringify({ model: model, messages: [{ role: 'user', content: 'hi' }], - max_tokens: 16384, // 增加到 16k 以兼容思考模型的需求 (max_tokens must be > budget) + max_tokens: 100, // 降低到 100 以减少开销,仅用于 Ping stream: true, // 使用流式 API }); diff --git a/src/js/store.js b/src/js/store.js index be4cdca..55c4d12 100644 --- a/src/js/store.js +++ b/src/js/store.js @@ -630,7 +630,7 @@ export const store = reactive({ openaiHealthCheckForm: { // 健康检测表单 useKey: 'single', // single: 单端点, all: 所有端点 concurrency: false, // 是否开启并发检测 - timeout: 15 // 超时时间(s) + timeout: 60 // 超时时间(s) }, // 自动标题生成配置 From cdf3a2597bf4867f2d88b4f40125cf2cbdc8ff5d Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 17:23:05 +0800 Subject: [PATCH 24/35] fix(monitor): dynamic max_tokens for reasoning models --- modules/openai-api/openai-api.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/openai-api/openai-api.js b/modules/openai-api/openai-api.js index 105998a..2d24c75 100644 --- a/modules/openai-api/openai-api.js +++ b/modules/openai-api/openai-api.js @@ -298,10 +298,13 @@ async function healthCheckModel(baseUrl, apiKey, model, timeout = DEFAULT_HEALTH const isHttps = url.protocol === 'https:'; const httpModule = isHttps ? https : http; + const isReasoningModel = /o[13]-|r1|reasoning|thinking/i.test(model); + const maxTokens = isReasoningModel ? 20000 : 100; + const requestBody = JSON.stringify({ model: model, messages: [{ role: 'user', content: 'hi' }], - max_tokens: 100, // 降低到 100 以减少开销,仅用于 Ping + max_tokens: maxTokens, // 思考模型需要更多 tokens,普通模型仅需极少 tokens 即可 stream: true, // 使用流式 API }); From 7b86746e99829379783a1b77f082a24ba54fe0f8 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 17:25:54 +0800 Subject: [PATCH 25/35] fix(monitor): remove explicit max_tokens to support all models --- modules/openai-api/openai-api.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/modules/openai-api/openai-api.js b/modules/openai-api/openai-api.js index 2d24c75..9b5c723 100644 --- a/modules/openai-api/openai-api.js +++ b/modules/openai-api/openai-api.js @@ -298,13 +298,9 @@ async function healthCheckModel(baseUrl, apiKey, model, timeout = DEFAULT_HEALTH const isHttps = url.protocol === 'https:'; const httpModule = isHttps ? https : http; - const isReasoningModel = /o[13]-|r1|reasoning|thinking/i.test(model); - const maxTokens = isReasoningModel ? 20000 : 100; - const requestBody = JSON.stringify({ model: model, messages: [{ role: 'user', content: 'hi' }], - max_tokens: maxTokens, // 思考模型需要更多 tokens,普通模型仅需极少 tokens 即可 stream: true, // 使用流式 API }); From 97abff312ac014d9c0d3f89765d697a0ce94a521 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 17:29:26 +0800 Subject: [PATCH 26/35] fix(gemini-cli): reduce thinking budget to avoid capacity exhaustion --- modules/gemini-cli-api/gemini-client.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/gemini-cli-api/gemini-client.js b/modules/gemini-cli-api/gemini-client.js index d716c0b..d349b76 100644 --- a/modules/gemini-cli-api/gemini-client.js +++ b/modules/gemini-cli-api/gemini-client.js @@ -273,9 +273,9 @@ class GeminiCliClient { // Gemini 3 系列必须包含 thinkingConfig (参考 CatieCli) if (model.includes('gemini-3')) { if (model.includes('flash')) { - return { thinkingBudget: 4096, includeThoughts: true }; + return { thinkingBudget: 2048, includeThoughts: true }; } - return { thinkingBudget: 16384, includeThoughts: true }; + return { thinkingBudget: 4096, includeThoughts: true }; } // 其他模型 (如 2.0/1.5) 不需要默认 thinkingConfig return null; From 6743f32c44fb2e8d48aa5f2826e8e6213c310be4 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 17:31:01 +0800 Subject: [PATCH 27/35] fix(gemini-cli): remove limit on maxOutputTokens when not specified --- modules/gemini-cli-api/gemini-client.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/modules/gemini-cli-api/gemini-client.js b/modules/gemini-cli-api/gemini-client.js index d349b76..e7948ac 100644 --- a/modules/gemini-cli-api/gemini-client.js +++ b/modules/gemini-cli-api/gemini-client.js @@ -89,19 +89,31 @@ class GeminiCliClient { temperature: temperature ?? parseFloat(settings.DEFAULT_TEMPERATURE || 1), topP: top_p ?? parseFloat(settings.DEFAULT_TOP_P || 0.95), topK: parseInt(settings.DEFAULT_TOP_K || 64), // 默认 topK (避免某些模型问题) - maxOutputTokens: Math.min(max_tokens ?? parseInt(settings.DEFAULT_MAX_TOKENS || 8192), 65536), // 限制在 64k (API 限制为 65537 exclusive) stopSequences: Array.isArray(stop) ? stop : stop ? [stop] : [], }; + // 只有当明确提供了 max_tokens 时才设置 maxOutputTokens + // 否则让 API 使用默认值(通常很大,足以覆盖 Thinking Budget) + if (max_tokens !== undefined && max_tokens !== null) { + generationConfig.maxOutputTokens = Math.min(max_tokens, 65536); + } else if (settings.DEFAULT_MAX_TOKENS) { + // 只有在设置中明确配置了默认值时才应用 + generationConfig.maxOutputTokens = Math.min(parseInt(settings.DEFAULT_MAX_TOKENS), 65536); + } + // 处理 Thinking 配置 (参考 CatieCli 实现) // 注意: gemini-3 模型**必须**包含 thinkingConfig,否则返回 404 const thinkingConfig = this._getThinkingConfig(model); if (thinkingConfig) { generationConfig.thinkingConfig = thinkingConfig; - // 关键校验:maxOutputTokens 必须大于等于 thinkingBudget (API 强制要求) - if (thinkingConfig.thinkingBudget && generationConfig.maxOutputTokens < thinkingConfig.thinkingBudget + 1024) { - generationConfig.maxOutputTokens = Math.min(thinkingConfig.thinkingBudget + 4096, 65536); - logger.info(`Adjusted maxOutputTokens for thinking budget (${thinkingConfig.thinkingBudget}): ${generationConfig.maxOutputTokens}`); + + // 关键校验:如果设置了 maxOutputTokens,必须大于等于 thinkingBudget (API 强制要求) + // 如果未设置 maxOutputTokens,则 API 默认值通常足够大,无需调整 + if (generationConfig.maxOutputTokens && thinkingConfig.thinkingBudget) { + if (generationConfig.maxOutputTokens < thinkingConfig.thinkingBudget + 1024) { + generationConfig.maxOutputTokens = Math.min(thinkingConfig.thinkingBudget + 4096, 65536); + logger.info(`Adjusted maxOutputTokens for thinking budget (${thinkingConfig.thinkingBudget}): ${generationConfig.maxOutputTokens}`); + } } } From 90ab2792db9c441c673da0d76d8d0f3722c86d69 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 17:32:41 +0800 Subject: [PATCH 28/35] fix(gemini-cli): remove max_tokens from health check to avoid thinking quota issues --- modules/gemini-cli-api/gemini-client.js | 9 +-------- modules/gemini-cli-api/router.js | 2 -- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/modules/gemini-cli-api/gemini-client.js b/modules/gemini-cli-api/gemini-client.js index e7948ac..a2376e7 100644 --- a/modules/gemini-cli-api/gemini-client.js +++ b/modules/gemini-cli-api/gemini-client.js @@ -88,27 +88,20 @@ class GeminiCliClient { const generationConfig = { temperature: temperature ?? parseFloat(settings.DEFAULT_TEMPERATURE || 1), topP: top_p ?? parseFloat(settings.DEFAULT_TOP_P || 0.95), - topK: parseInt(settings.DEFAULT_TOP_K || 64), // 默认 topK (避免某些模型问题) + topK: parseInt(settings.DEFAULT_TOP_K || 64), stopSequences: Array.isArray(stop) ? stop : stop ? [stop] : [], }; - // 只有当明确提供了 max_tokens 时才设置 maxOutputTokens - // 否则让 API 使用默认值(通常很大,足以覆盖 Thinking Budget) if (max_tokens !== undefined && max_tokens !== null) { generationConfig.maxOutputTokens = Math.min(max_tokens, 65536); } else if (settings.DEFAULT_MAX_TOKENS) { - // 只有在设置中明确配置了默认值时才应用 generationConfig.maxOutputTokens = Math.min(parseInt(settings.DEFAULT_MAX_TOKENS), 65536); } - // 处理 Thinking 配置 (参考 CatieCli 实现) - // 注意: gemini-3 模型**必须**包含 thinkingConfig,否则返回 404 const thinkingConfig = this._getThinkingConfig(model); if (thinkingConfig) { generationConfig.thinkingConfig = thinkingConfig; - // 关键校验:如果设置了 maxOutputTokens,必须大于等于 thinkingBudget (API 强制要求) - // 如果未设置 maxOutputTokens,则 API 默认值通常足够大,无需调整 if (generationConfig.maxOutputTokens && thinkingConfig.thinkingBudget) { if (generationConfig.maxOutputTokens < thinkingConfig.thinkingBudget + 1024) { generationConfig.maxOutputTokens = Math.min(thinkingConfig.thinkingBudget + 4096, 65536); diff --git a/modules/gemini-cli-api/router.js b/modules/gemini-cli-api/router.js index 53c5023..25f7469 100644 --- a/modules/gemini-cli-api/router.js +++ b/modules/gemini-cli-api/router.js @@ -130,7 +130,6 @@ const autoCheckService = { const testRequest = { model: modelId, messages: [{ role: 'user', content: 'Hi' }], - max_tokens: 5, stream: false, }; @@ -726,7 +725,6 @@ router.post('/accounts/check', async (req, res) => { const testRequest = { model: modelId, messages: [{ role: 'user', content: 'Hi' }], - max_tokens: 5, stream: false, }; From 917826a1cf9423dc9bccff0a0ed7c733f74baabe Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 18:03:13 +0800 Subject: [PATCH 29/35] feat: Implement the initial dashboard page, including system overview statistics, a music widget, and associated styling. --- src/css/dashboard.css | 79 ++++++++++++++++++++++++++++-------- src/css/styles.css | 21 ++++++++++ src/templates/dashboard.html | 2 +- 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/css/dashboard.css b/src/css/dashboard.css index 0d356c6..6d2586f 100644 --- a/src/css/dashboard.css +++ b/src/css/dashboard.css @@ -61,7 +61,8 @@ .header-right { display: flex; align-items: center; - gap: 12px; + gap: 16px; + /* 增加间距 */ } .last-update { @@ -69,15 +70,27 @@ align-items: center; gap: 8px; padding: 0 16px; - background: var(--bg-secondary); - border-radius: 20px; - /* 圆角增加 */ - font-size: 12px; - color: var(--text-tertiary); + background: rgba(255, 255, 255, 0.03); + /* 半透明玻璃感 */ + border-radius: 12px; + font-size: 13px; + color: var(--text-secondary); font-family: var(--font-mono); - border: 1px solid var(--border-color); - height: 38px; - /* 增加高度 */ + border: 1px solid rgba(255, 255, 255, 0.08); + height: 40px; + backdrop-filter: blur(4px); + transition: all 0.3s ease; +} + +.last-update i { + color: var(--text-tertiary); + font-size: 14px; +} + +.last-update:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.15); + color: var(--text-primary); } .btn-refresh-all { @@ -85,22 +98,54 @@ align-items: center; justify-content: center; gap: 8px; - height: 38px; + height: 40px; padding: 0 24px; - border-radius: 20px; + border-radius: 12px; font-size: 13px; font-weight: 600; cursor: pointer; - border: 1px solid transparent; - background: var(--primary-color); + border: 1px solid rgba(255, 255, 255, 0.1); + /* 渐变背景 */ + background: linear-gradient(135deg, var(--primary-color), #4f46e5); + /* 假设有 indigo 辅助色,如果没有则回退到 primary */ color: #fff; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 10px rgba(var(--primary-rgb), 0.2); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2); + position: relative; + overflow: hidden; +} + +/* 按钮光泽效果 */ +.btn-refresh-all::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: 0.5s; +} + +.btn-refresh-all:hover::before { + left: 100%; } .btn-refresh-all:hover:not(:disabled) { - /* transform: translateY(-2px); */ - box-shadow: 0 6px 16px rgba(var(--primary-rgb), 0.3); + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(var(--primary-rgb), 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.2); + filter: brightness(1.1); +} + +.btn-refresh-all:active:not(:disabled) { + transform: translateY(1px); + box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.2); +} + +.btn-refresh-all:disabled { + opacity: 0.7; + cursor: not-allowed; + filter: grayscale(0.5); } /* ========================================= diff --git a/src/css/styles.css b/src/css/styles.css index c690776..b2f8d58 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -44,6 +44,27 @@ textarea { resize: none; } +button, +a, +input, +select, +textarea, +label, +summary, +[role="button"], +[role="link"], +[role="checkbox"], +[role="radio"], +[role="switch"], +[role="tab"], +[tabindex] { + will-change: transform, background-color, border-color, opacity; + transform-origin: center center; + backface-visibility: hidden; + -webkit-font-smoothing: antialiased; +} + + code, pre, .code-font, diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index bbdf894..5de2d99 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -1,4 +1,4 @@ -
+
From bb22d62305d2efa3c4c42ba110da8fb223399cdf Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 21:20:16 +0800 Subject: [PATCH 30/35] feat: implement dashboard module with caching, parallel data fetching, background refresh, and interactive trend charts. --- src/css/dashboard.css | 1 - src/css/server.css | 17 +- src/js/main.js | 3 +- src/js/modules/dashboard.js | 276 +++++++++++++++++++++++++------- src/js/modules/host.js | 30 ---- src/js/modules/server-status.js | 14 +- src/js/modules/server.js | 45 +----- src/js/modules/utils.js | 98 +++++++++++- 8 files changed, 333 insertions(+), 151 deletions(-) diff --git a/src/css/dashboard.css b/src/css/dashboard.css index 6d2586f..5c422f1 100644 --- a/src/css/dashboard.css +++ b/src/css/dashboard.css @@ -771,7 +771,6 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 12px; } .api-name { diff --git a/src/css/server.css b/src/css/server.css index dea54a4..b60792f 100644 --- a/src/css/server.css +++ b/src/css/server.css @@ -370,8 +370,10 @@ .server-detail-item { display: flex; justify-content: space-between; + align-items: flex-start; padding: 8px 0; border-bottom: 1px solid var(--border-color); + gap: 12px; } .server-detail-item:last-child { @@ -382,13 +384,26 @@ font-size: 12px; color: var(--text-tertiary); font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + margin-top: 1px; + /* Align with first line of value */ } .server-detail-value { font-size: 13px; color: var(--server-primary, #00d4aa); font-weight: 700; - /* font-family: var(--font-mono, 'JetBrains Mono', monospace); */ + text-align: right; + word-break: break-word; + max-width: 70%; + + /* Limit to 2 lines */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; } /* 杩涘害鏉?*/ diff --git a/src/js/main.js b/src/js/main.js index c694370..e5345d2 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -107,7 +107,7 @@ import { uptimeData, uptimeMethods, uptimeComputed } from './modules/uptime.js'; import { aliyunMethods } from './modules/aliyun.js'; import { tencentMethods } from './modules/tencent.js'; import { notificationData, notificationMethods } from './modules/notification.js'; -import { formatDateTime, formatFileSize, maskAddress, formatRegion } from './modules/utils.js'; +import { formatDateTime, formatFileSize, maskAddress, formatRegion, formatUptime } from './modules/utils.js'; // 导入全局状态 import { store, MODULE_CONFIG, MODULE_GROUPS } from './store.js'; @@ -1696,6 +1696,7 @@ const app = createApp({ formatDateTime, formatFileSize, formatRegion, + formatUptime, renderMarkdown, // 带缓存的日志内容渲染(避免重复解析 Base64 图片) diff --git a/src/js/modules/dashboard.js b/src/js/modules/dashboard.js index 22f20d7..5359739 100644 --- a/src/js/modules/dashboard.js +++ b/src/js/modules/dashboard.js @@ -211,104 +211,262 @@ export const dashboardMethods = { }, /** - * 绘制 Canvas 趋势图 (Sparkline) + * 绘制 Canvas 趋势图 (Smooth Curve + Interaction) */ drawTrendChart(refName, data, color) { const app = document.querySelector('#app')?.__vue_app__?._instance; let canvas = null; + let container = null; if (refName === 'agChart') { - // 这是一个很 hacky 的查找方式,但在没有组件上下文的情况下很有效 const groups = document.querySelectorAll('.api-stat-group'); - if (groups.length >= 1) canvas = groups[0].querySelector('canvas'); + if (groups.length >= 1) { + canvas = groups[0].querySelector('canvas'); + container = groups[0].querySelector('.chart-container'); + } } else if (refName === 'geminiChart') { const groups = document.querySelectorAll('.api-stat-group'); - if (groups.length >= 2) canvas = groups[1].querySelector('canvas'); + if (groups.length >= 2) { + canvas = groups[1].querySelector('canvas'); + container = groups[1].querySelector('.chart-container'); + } + } + + if (!canvas) return; + + // Check availability of data + if (!data || data.length === 0) { + const ctx = canvas.getContext('2d'); + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * (window.devicePixelRatio || 1); + canvas.height = rect.height * (window.devicePixelRatio || 1); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.font = '10px sans-serif'; + ctx.fillText('No Data', 10, canvas.height / 2); + return; + } + + // Save state on canvas for interaction + canvas.chartState = { + data: data.map(d => d.total), + labels: data.map(d => d.date), // Assuming data has date + color: color, + paddingX: 10, + paddingTop: 8, // More space for tooltip + paddingBottom: 5 + }; + + // Attach event listeners once + if (!canvas.hasInteractionListeners) { + canvas.hasInteractionListeners = true; + + canvas.addEventListener('mousemove', (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const state = canvas.chartState; + if (!state) return; + + const effectiveWidth = rect.width - (state.paddingX * 2); + const stepX = effectiveWidth / (state.data.length - 1 || 1); + + // Find closest index + let index = Math.round((x - state.paddingX) / stepX); + if (index < 0) index = 0; + if (index >= state.data.length) index = state.data.length - 1; + + this.renderChartFrame(canvas, index); + }); + + canvas.addEventListener('mouseleave', () => { + this.renderChartFrame(canvas, null); + }); } - if (!canvas) return; // 没找到 Canvas + // Initial Render + this.renderChartFrame(canvas, null); + }, + /** + * Internal render function + */ + renderChartFrame(canvas, highlightIndex) { const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - // 调整分辨率 - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); + // Get theme colors + const styles = getComputedStyle(document.body); + const tooltipBgColor = styles.getPropertyValue('--card-bg').trim(); + const tooltipTextColor = styles.getPropertyValue('--text-primary').trim(); + const guideLineColor = styles.getPropertyValue('--border-color').trim(); + + const rect = canvas.getBoundingClientRect(); + const state = canvas.chartState; + // Logic coords const width = rect.width; const height = rect.height; - // 清空 + // Physical coords + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, width, height); - if (!data || data.length === 0) { - // 无数据 - ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; - ctx.font = '10px sans-serif'; - ctx.fillText('No Data', 10, height / 2); - return; - } + const { data, color, paddingX, paddingTop, paddingBottom } = state; + const maxVal = Math.max(...data, 10); + const minVal = 0; + const range = maxVal - minVal; - // 准备数据 - // 补全最近 14 天 (如果后端返回不全) - 简单起见直接用返回的数据 - // 假设数据按日期升序 - const values = data.map(d => d.total); - const maxVal = Math.max(...values, 10); // 至少 10,避免直线 + const values = data; + const drawingHeight = height - paddingBottom - paddingTop; + const stepX = (width - paddingX * 2) / (values.length - 1 || 1); - // 添加左右内边距,防止边缘被切割 - const paddingX = 4; - const effectiveWidth = width - (paddingX * 2); - const stepX = effectiveWidth / (values.length - 1 || 1); + // Helper to get coords + const getPoint = (i) => { + const x = paddingX + i * stepX; + const y = paddingTop + drawingHeight - (values[i] / maxVal) * drawingHeight; + return { x, y }; + }; - const paddingBottom = 5; - const paddingTop = 5; // 添加顶部内边距 - const drawingHeight = height - paddingBottom - paddingTop; + // 1. Fill Gradient Area + const gradient = ctx.createLinearGradient(0, 0, 0, height); + gradient.addColorStop(0, color + '66'); // 40% + gradient.addColorStop(1, color + '00'); // 0% + ctx.fillStyle = gradient; - // 绘制填充路径 ctx.beginPath(); ctx.moveTo(paddingX, height); - values.forEach((val, index) => { - const x = paddingX + (index * stepX); - const y = paddingTop + drawingHeight - (val / maxVal) * drawingHeight; - ctx.lineTo(x, y); - }); + // Smooth Curve for Fill + if (values.length > 1) { + const first = getPoint(0); + ctx.lineTo(first.x, first.y); + + for (let i = 0; i < values.length - 1; i++) { + const p0 = getPoint(i > 0 ? i - 1 : i); + const p1 = getPoint(i); + const p2 = getPoint(i + 1); + const p3 = getPoint(i + 2 < values.length ? i + 2 : i + 1); + + const cp1x = p1.x + (p2.x - p0.x) * 0.2; // Tension 0.2 + const cp1y = p1.y + (p2.y - p0.y) * 0.2; + const cp2x = p2.x - (p3.x - p1.x) * 0.2; + const cp2y = p2.y - (p3.y - p1.y) * 0.2; + + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); + } + } else { + const p = getPoint(0); + ctx.lineTo(p.x, p.y); + } ctx.lineTo(width - paddingX, height); ctx.closePath(); - - // 渐变填充 - const gradient = ctx.createLinearGradient(0, 0, 0, height); - gradient.addColorStop(0, color + '66'); // 40% opacity - gradient.addColorStop(1, color + '00'); // 0% opacity - ctx.fillStyle = gradient; ctx.fill(); - // 绘制线条 - ctx.beginPath(); - values.forEach((val, index) => { - const x = paddingX + (index * stepX); - const y = paddingTop + drawingHeight - (val / maxVal) * drawingHeight; - if (index === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - + // 2. Stroke Line (Smooth) ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; + ctx.beginPath(); + + if (values.length > 1) { + const first = getPoint(0); + ctx.moveTo(first.x, first.y); + + for (let i = 0; i < values.length - 1; i++) { + const p0 = getPoint(i > 0 ? i - 1 : i); + const p1 = getPoint(i); + const p2 = getPoint(i + 1); + const p3 = getPoint(i + 2 < values.length ? i + 2 : i + 1); + + const cp1x = p1.x + (p2.x - p0.x) * 0.2; + const cp1y = p1.y + (p2.y - p0.y) * 0.2; + const cp2x = p2.x - (p3.x - p1.x) * 0.2; + const cp2y = p2.y - (p3.y - p1.y) * 0.2; + + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); + } + } else { + const p = getPoint(0); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x, p.y); + } ctx.stroke(); - // 绘制最后一个点 (今日) - const lastX = paddingX + ((values.length - 1) * stepX); - const lastY = paddingTop + drawingHeight - (values[values.length - 1] / maxVal) * drawingHeight; - ctx.beginPath(); - // 稍微向左偏移一点点确保不切 - ctx.arc(lastX - 1, lastY, 3, 0, Math.PI * 2); - ctx.fillStyle = '#fff'; - ctx.fill(); + // 3. Highlight Interaction + if (highlightIndex !== null && highlightIndex >= 0 && highlightIndex < values.length) { + const p = getPoint(highlightIndex); + + // Vertical Line + ctx.beginPath(); + ctx.moveTo(p.x, paddingTop); + ctx.lineTo(p.x, height); + ctx.strokeStyle = guideLineColor; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.stroke(); + ctx.setLineDash([]); // Reset + + // Outer Glow + ctx.beginPath(); + ctx.arc(p.x, p.y, 6, 0, Math.PI * 2); + ctx.fillStyle = color + '40'; // Transparent + ctx.fill(); + + // Inner Dot + ctx.beginPath(); + ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + + // Tooltip Text + const text = `${values[highlightIndex]}`; + ctx.font = 'bold 11px Inter, sans-serif'; + const textMetrics = ctx.measureText(text); + const padding = 4; + const boxWidth = textMetrics.width + padding * 2; + const boxHeight = 18; + + // Keep tooltip within bounds + // Add buffer to prevent stroke clipping (lineWidth=1) + const edgeBuffer = 2; + + let boxX = p.x - boxWidth / 2; + let boxY = p.y - 12 - boxHeight; + + if (boxX < edgeBuffer) boxX = edgeBuffer; + if (boxX + boxWidth > width - edgeBuffer) boxX = width - boxWidth - edgeBuffer; + if (boxY < 0) boxY = p.y + 12; // Flip to bottom if top clipped + + // Tooltip BG + ctx.fillStyle = tooltipBgColor; + ctx.strokeStyle = color; + ctx.lineWidth = 1; + + // Round Rect for Tooltip + ctx.beginPath(); + const r = 4; + ctx.roundRect(boxX, boxY, boxWidth, boxHeight, r); + ctx.fill(); + ctx.stroke(); + + // Text + ctx.fillStyle = tooltipTextColor; + ctx.fillText(text, boxX + padding, boxY + 12); + + } else { + // Draw last point if not hovering + const lastIdx = values.length - 1; + const p = getPoint(lastIdx); + ctx.beginPath(); + ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + } }, /** diff --git a/src/js/modules/host.js b/src/js/modules/host.js index a6e5fc9..2627a4a 100644 --- a/src/js/modules/host.js +++ b/src/js/modules/host.js @@ -2167,37 +2167,7 @@ export const hostMethods = { return platform.substring(0, 10); }, - formatUptime(uptimeStr) { - if (!uptimeStr || typeof uptimeStr !== 'string') return uptimeStr; - // 移除 "up " 前缀 - const str = uptimeStr.replace(/^up\s+/i, ''); - - // 提取各个时间部分 - const weekMatch = str.match(/(\d+)\s*(weeks?|w)/i); - const dayMatch = str.match(/(\d+)\s*(days?|d)/i); - const hourMatch = str.match(/(\d+)\s*(hours?|h)/i); - const minMatch = str.match(/(\d+)\s*(minutes?|m)/i); - - let days = dayMatch ? parseInt(dayMatch[1], 10) : 0; - const weeks = weekMatch ? parseInt(weekMatch[1], 10) : 0; - const hours = hourMatch ? parseInt(hourMatch[1], 10) : 0; - const minutes = minMatch ? parseInt(minMatch[1], 10) : 0; - - // 将周转换为天并累加 - days += weeks * 7; - - // 构建中文格式 - let result = ''; - if (days > 0) result += `${days}天`; - if (hours > 0) result += `${hours}时`; - if (minutes > 0) result += `${minutes}分`; - - // 如果都是0,显示 "0分" - if (result === '') result = '0分'; - - return result; - }, translateInfoKey(key) { const translations = { diff --git a/src/js/modules/server-status.js b/src/js/modules/server-status.js index 253cf6c..c6d5f4c 100644 --- a/src/js/modules/server-status.js +++ b/src/js/modules/server-status.js @@ -130,19 +130,7 @@ export const serverStatusMethods = { return '#22c55e'; }, - /** - * 格式化运行时间 - */ - formatUptime(seconds) { - if (!seconds) return '-'; - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const mins = Math.floor((seconds % 3600) / 60); - - if (days > 0) return `${days}天 ${hours}时`; - if (hours > 0) return `${hours}时 ${mins}分`; - return `${mins}分`; - }, + /** * 获取容器状态颜色 diff --git a/src/js/modules/server.js b/src/js/modules/server.js index 101e055..7d9d444 100644 --- a/src/js/modules/server.js +++ b/src/js/modules/server.js @@ -4,6 +4,7 @@ import { store } from '../store.js'; import { toast } from './toast.js'; +import { formatUptime, escapeHtml } from './utils.js'; // 模块状态 const state = { @@ -503,41 +504,7 @@ function renderServerDetails(server, info) { `; } -/** - * 格式化运行时间为中文格式 - * 将 "up 6 days, 2 hours, 32 minutes" 转换为 "6天2时32分" - */ -function formatUptime(uptimeStr) { - if (!uptimeStr || typeof uptimeStr !== 'string') return uptimeStr; - - // 移除 "up " 前缀 - const str = uptimeStr.replace(/^up\s+/i, ''); - - // 提取各个时间部分 - const weekMatch = str.match(/(\d+)\s*weeks?/i); - const dayMatch = str.match(/(\d+)\s*days?/i); - const hourMatch = str.match(/(\d+)\s*hours?/i); - const minMatch = str.match(/(\d+)\s*minutes?/i); - - let days = dayMatch ? parseInt(dayMatch[1], 10) : 0; - const weeks = weekMatch ? parseInt(weekMatch[1], 10) : 0; - const hours = hourMatch ? parseInt(hourMatch[1], 10) : 0; - const minutes = minMatch ? parseInt(minMatch[1], 10) : 0; - - // 将周转换为天并累加 - days += weeks * 7; - - // 构建中文格式 - let result = ''; - if (days > 0) result += `${days}天`; - if (hours > 0) result += `${hours}时`; - if (minutes > 0) result += `${minutes}分`; - // 如果都是0,显示 "0分" - if (result === '') result = '0分'; - - return result; -} /** * 渲染详情项 @@ -867,15 +834,7 @@ function bindServerCardEvents() { // 事件已通过 onclick 属性绑定 } -/** - * HTML 转义 - */ -function escapeHtml(text) { - const div = document.createElement('div'); - if (text === null || text === undefined) return ''; - div.textContent = text; - return div.innerHTML; -} + /** * Vue 实例混入方法 - 用于解耦 main.js diff --git a/src/js/modules/utils.js b/src/js/modules/utils.js index 4cda06f..0815858 100644 --- a/src/js/modules/utils.js +++ b/src/js/modules/utils.js @@ -11,9 +11,7 @@ import 'katex/dist/katex.min.css'; /** * 渲染 Markdown 为 HTML (安全模式) - * 支持多模态数组、JSON 对象及普通字符串 - * @param {any} text - 输入内容 - * @returns {string} 过滤后的 HTML + * ... (existing renderMarkdown implementation) */ export function renderMarkdown(text) { if (text === undefined || text === null) return ''; @@ -222,6 +220,73 @@ export function formatFileSize(bytes) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } +/** + * 格式化运行时间 (增强版) + * 支持 "up 1 day, 10:23", "12345" (秒), "2 days 3 hours" 等格式 + * @param {string|number} uptimeStr - 运行时间字符串或秒数 + * @returns {string} 中文格式时间 (e.g. "1天 10时 23分") + */ +export function formatUptime(uptimeStr) { + if (uptimeStr === undefined || uptimeStr === null) return '-'; + + // 处理数字输入 (视为秒) + if (typeof uptimeStr === 'number') { + const days = Math.floor(uptimeStr / 86400); + const hours = Math.floor((uptimeStr % 86400) / 3600); + const minutes = Math.floor((uptimeStr % 3600) / 60); + + let result = ''; + if (days > 0) result += `${days}天`; + if (hours > 0) result += `${hours}时`; + if (minutes > 0) result += `${minutes}分`; + return result || '0分'; + } + + if (typeof uptimeStr !== 'string') return uptimeStr; + + // 移除 "up " 前缀 + const str = uptimeStr.replace(/^up\s+/i, ''); + + let days = 0; + let hours = 0; + let minutes = 0; + + // 尝试匹配 "1 day, 10:23" 或 "10:23" 格式 (Linux uptime 常见) + const timeMatch = str.match(/(?:(\d+)\s*days?,\s*)?(\d{1,2}):(\d{2})/i); + + if (timeMatch) { + if (timeMatch[1]) days = parseInt(timeMatch[1], 10); + hours = parseInt(timeMatch[2], 10); + minutes = parseInt(timeMatch[3], 10); + } else { + // 尝试匹配 "1 week, 2 days" 或 "1w, 2d" 格式 + const weekMatch = str.match(/(\d+)\s*(weeks?|w)/i); + const dayMatch = str.match(/(\d+)\s*(days?|d)/i); + const hourMatch = str.match(/(\d+)\s*(hours?|h)/i); + const minMatch = str.match(/(\d+)\s*(minutes?|m)/i); + + if (dayMatch) days = parseInt(dayMatch[1], 10); + if (weekMatch) days += parseInt(weekMatch[1], 10) * 7; + if (hourMatch) hours = parseInt(hourMatch[1], 10); + if (minMatch) minutes = parseInt(minMatch[1], 10); + } + + // 构建中文格式 (紧凑) + let result = ''; + if (days > 0) result += `${days}天`; + if (hours > 0) result += `${hours}时`; + if (minutes > 0) result += `${minutes}分`; + + // 如果都是0,但有一个 parsing 发生,显示 "0分" + // 如果没有任何匹配,返回原字符串 (可能是其他格式) + if (result === '') { + if (str.includes('min') || str.includes('sec')) return '刚刚'; + return uptimeStr; // 原样返回,防止显示错误 + } + + return result; +} + /** * 防抖函数 * @param {Function} func - 要防抖的函数 @@ -392,3 +457,30 @@ export function formatRegion(region) { return regionStr; } + +/** + * 格式化网速为紧凑格式 + * 例如: "1.5 MB/s" -> "1.5M", "10 KB/s" -> "10K", "0 B/s" -> "0B" + */ +export function formatSpeedCompact(speed) { + if (!speed) return '0B'; + // 移除 "/s" 后缀,移除空格,保留数字和单位字母 + return speed + .replace(/\/s$/i, '') // 移除 /s + .replace(/\s+/g, '') // 移除空格 + .replace(/(\d+\.?\d*)([KMGT]?)B?/i, '$1$2'); // 简化单位 +} + +/** + * 解析网速为数字和单位分离的对象 + * 例如: "1.5 MB/s" -> { num: "1.5", unit: "M" } + */ +export function parseSpeed(speed) { + if (!speed) return { num: '0', unit: 'B' }; + const cleaned = speed.replace(/\/s$/i, '').replace(/\s+/g, ''); + const match = cleaned.match(/^(\d+\.?\d*)([KMGT]?)B?$/i); + if (match) { + return { num: match[1], unit: match[2] ? match[2].toUpperCase() : 'B' }; + } + return { num: '0', unit: 'B' }; +} From 6fd6fe7d9e42164f09ae62fb359b1f3ce637fd08 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 21:24:08 +0800 Subject: [PATCH 31/35] feat: add comprehensive styling for the dashboard layout, header, cards, and widgets. --- src/css/dashboard.css | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/css/dashboard.css b/src/css/dashboard.css index 5c422f1..65e8e5d 100644 --- a/src/css/dashboard.css +++ b/src/css/dashboard.css @@ -70,13 +70,12 @@ align-items: center; gap: 8px; padding: 0 16px; - background: rgba(255, 255, 255, 0.03); - /* 半透明玻璃感 */ + background: var(--bg-secondary); border-radius: 12px; font-size: 13px; color: var(--text-secondary); font-family: var(--font-mono); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border-color); height: 40px; backdrop-filter: blur(4px); transition: all 0.3s ease; @@ -555,6 +554,11 @@ transform: scale(1.1); } +.btn-control-circle:active { + transform: scale(0.95); + background: var(--bg-primary); +} + .btn-control-circle.play { width: 58px; height: 58px; From 54e381b082a4f3c94350859a82947cc9c60d32ab Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 22:03:43 +0800 Subject: [PATCH 32/35] feat: Implement grouped navigation bar styles, main application stylesheet, and a JavaScript store. --- src/css/nav-grouped.css | 8 +++++--- src/css/styles.css | 4 ++-- src/js/store.js | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/css/nav-grouped.css b/src/css/nav-grouped.css index 411f172..348216f 100644 --- a/src/css/nav-grouped.css +++ b/src/css/nav-grouped.css @@ -158,7 +158,7 @@ -webkit-backdrop-filter: blur(24px) saturate(200%); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 14px; - padding: 6px; + padding: 1px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25), 0 4px 10px rgba(0, 0, 0, 0.1), @@ -166,7 +166,7 @@ z-index: 1001; display: flex; flex-direction: column; - gap: 2px; + gap: 0; } /* 下拉菜单项 */ @@ -184,7 +184,8 @@ border-radius: 10px; transition: all 0.2s ease; text-align: left; - width: 100%; + margin: 3px; + width: calc(100% - 6px); } .nav-group-dropdown .dropdown-item i { @@ -297,6 +298,7 @@ top: auto; } + /* 底部导航模式下的动画方向修正 */ .nav-bottom-fixed .dropdown-fade-enter-from, .nav-bottom-fixed .dropdown-fade-leave-to { transform: translateX(-50%) translateY(8px); diff --git a/src/css/styles.css b/src/css/styles.css index b2f8d58..55ae649 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -1729,7 +1729,7 @@ body.modal-open { rgba(var(--zeabur-secondary-rgb), 0.05) 100%); border: 1px solid rgba(var(--zeabur-primary-rgb), 0.15); border-radius: 12px; - padding: 8px 6px; + padding: 3px 6px; margin: 0; backdrop-filter: blur(8px); transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); @@ -1746,7 +1746,7 @@ body.modal-open { display: flex; align-items: center; gap: 8px; - padding: 4px 8px; + padding: 4px; } .zeabur-balance-divider { diff --git a/src/js/store.js b/src/js/store.js index 55c4d12..61b8c8d 100644 --- a/src/js/store.js +++ b/src/js/store.js @@ -148,7 +148,7 @@ export function getModuleIcon(moduleId) { export const store = reactive({ // 认证与基础状态 - isAuthenticated: false, + isAuthenticated: true, isCheckingAuth: true, showLoginModal: false, showSetPasswordModal: false, From 79c8fa407609557f4aa0b22e85d4a86810de4467 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 22:17:12 +0800 Subject: [PATCH 33/35] feat: add OpenAI API monitoring and management interface with endpoint, account, and chat sections. --- src/css/styles.css | 134 +++++++++++++++++++++++++++++++++++++- src/templates/openai.html | 31 ++++----- 2 files changed, 145 insertions(+), 20 deletions(-) diff --git a/src/css/styles.css b/src/css/styles.css index 55ae649..f2cc8a4 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -44,7 +44,8 @@ textarea { resize: none; } -button, + +/* button, a, input, select, @@ -62,7 +63,7 @@ summary, transform-origin: center center; backface-visibility: hidden; -webkit-font-smoothing: antialiased; -} +} */ code, @@ -799,6 +800,135 @@ body.modal-open { overflow: hidden; } +/* ==================== Endpoint Action Buttons (OpenAI et al.) ==================== */ + +.endpoint-badges { + display: flex; + align-items: center; + gap: 8px; +} + +.endpoint-action-btn { + display: flex; + align-items: center; + justify-content: center; + background: rgba(16, 163, 127, 0.06); + border: 1px solid rgba(16, 163, 127, 0.15); + color: var(--openai-primary); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + height: 42px; + /* 统一高度 */ + box-sizing: border-box; +} + +.endpoint-action-btn:hover { + background: rgba(16, 163, 127, 0.12); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(16, 163, 127, 0.15); + border-color: rgba(16, 163, 127, 0.3); +} + +.endpoint-action-btn:active { + transform: translateY(0); + background: rgba(16, 163, 127, 0.15); +} + +/* Icons */ +.endpoint-action-btn i { + font-size: 16px; + transition: transform 0.3s ease; +} + +.endpoint-action-btn:hover i { + transform: scale(1.1); +} + +/* Health Check Button (Square) */ +.endpoint-action-btn.btn-health { + width: 42px; + font-size: 18px; + /* 图标稍大 */ +} + +/* Models Count Button (Rect) */ +.endpoint-action-btn.btn-models { + padding: 0 14px; + gap: 10px; + min-width: 80px; +} + +.endpoint-action-btn.btn-models i { + font-size: 18px; + opacity: 0.9; +} + +/* Info Text Layout */ +.endpoint-action-btn .action-info { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + line-height: 1; +} + +.endpoint-action-btn .action-info .label { + font-size: 10px; + font-weight: 600; + color: var(--openai-dark); + /* 深一点的绿色 */ + opacity: 0.7; + margin-bottom: 2px; + text-transform: uppercase; +} + +.endpoint-action-btn .action-info .value { + font-size: 15px; + font-weight: 800; + font-family: var(--font-mono); + color: var(--openai-primary); +} + +/* Loading Spin */ +.endpoint-action-btn i.refreshing { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* Dark Mode Adaptation */ +@media (prefers-color-scheme: dark) { + .endpoint-action-btn { + background: rgba(16, 163, 127, 0.1); + border-color: rgba(16, 163, 127, 0.2); + } + + .endpoint-action-btn:hover { + background: rgba(16, 163, 127, 0.2); + border-color: rgba(16, 163, 127, 0.4); + } + + .endpoint-action-btn .action-info .label { + color: #a7f3d0; + /* 浅色文字 */ + } + + .endpoint-action-btn .action-info .value { + color: #34d399; + } +} + /* App Layout */ .app-wrapper { display: flex; diff --git a/src/templates/openai.html b/src/templates/openai.html index 949dc3a..340d495 100644 --- a/src/templates/openai.html +++ b/src/templates/openai.html @@ -73,25 +73,20 @@
-
-
-
- -
-
+ +
+
-
-
-
- -
-
- 模型 - {{ endpoint.models.length }} -
+ + +
+ +
+ 模型 + {{ endpoint.models.length }}
From 00e3a5aacb4fea30f9cb05185a202222cced35f7 Mon Sep 17 00:00:00 2001 From: iwvw Date: Sun, 25 Jan 2026 22:38:07 +0800 Subject: [PATCH 34/35] feat: Implement dashboard 2.0 styling with strict alignment and spacing. --- src/css/dashboard.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/css/dashboard.css b/src/css/dashboard.css index 65e8e5d..b89cf79 100644 --- a/src/css/dashboard.css +++ b/src/css/dashboard.css @@ -109,7 +109,7 @@ /* 假设有 indigo 辅助色,如果没有则回退到 primary */ color: #fff; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2); + /* box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2); */ position: relative; overflow: hidden; } @@ -758,7 +758,7 @@ .api-stat-group { background: var(--bg-secondary); border-radius: 12px; - padding: 20px; + padding: 10px; border: 1px solid var(--border-color); cursor: pointer; transition: all 0.2s ease; @@ -808,7 +808,7 @@ .paas-list { display: flex; flex-direction: column; - gap: 17px; + gap: 5px; } .paas-item { @@ -1099,7 +1099,7 @@ } .api-stat-group { - padding: 14px; + padding: 10px; } .music-content-layer { @@ -1603,7 +1603,7 @@ .paas-list { display: grid !important; grid-template-columns: repeat(2, 1fr) !important; - gap: 8px !important; + gap: 0px !important; } .paas-item { From 49940e14e65b09f9afdd0940dafe47729026513b Mon Sep 17 00:00:00 2001 From: iwvw Date: Mon, 26 Jan 2026 19:59:47 +0800 Subject: [PATCH 35/35] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20OpenAI=20API?= =?UTF-8?q?=20=E7=AB=AF=E7=82=B9=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=8C=85=E5=90=AB=E5=81=A5=E5=BA=B7=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E3=80=81=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E5=92=8C=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=AE=A1=E7=90=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/js/modules/openai.js | 23 +++++++++++++++-------- src/templates/openai.html | 2 +- src/templates/server.html | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/js/modules/openai.js b/src/js/modules/openai.js index 7d1b2ee..d5e421a 100644 --- a/src/js/modules/openai.js +++ b/src/js/modules/openai.js @@ -15,20 +15,20 @@ export const openaiMethods = { // 从内容中提取思考标签(支持各种变体如 , , 等) extractThinkingContent(content) { if (!content || typeof content !== 'string') return { thinking: '', cleaned: content || '' }; - + // 匹配各种思考标签变体: , , , etc. const thinkingPattern = /<(think(?:ing|_\w+)?)\s*>([\s\S]*?)<\/\1>/gi; let thinking = ''; let cleaned = content; - + let match; while ((match = thinkingPattern.exec(content)) !== null) { thinking += match[2].trim() + '\n'; } - + // 移除所有思考标签 cleaned = content.replace(thinkingPattern, '').trim(); - + return { thinking: thinking.trim(), cleaned }; }, @@ -51,13 +51,13 @@ export const openaiMethods = { // 对于 content 字段,先过滤思考标签 if (field === 'content' && typeof content === 'string') { const { thinking, cleaned } = this.extractThinkingContent(content); - + // 如果提取到了思考内容且 msg.reasoning 为空,自动填充 if (thinking && !msg.reasoning) { msg.reasoning = thinking; msg.showReasoning = false; // 默认折叠 } - + content = cleaned; } @@ -1793,7 +1793,7 @@ ${conversationText} }, // 创建新会话 - async createChatSession() { + async createChatSession(resetToDefault = false) { try { // 创建新会话时,强制使用全局默认设置(防止沿用上一个会话的“脏”状态) const globalSystemPrompt = localStorage.getItem('openai_system_prompt') || '你是一个有用的 AI 助手。'; @@ -1804,7 +1804,9 @@ ${conversationText} // 恢复当前会话状态为全局默认 store.openaiChatSystemPrompt = globalSystemPrompt; - if (store.openaiDefaultChatModel) { + + // 只有在明确要求重置或当前没有选定模型时,才使用默认模型 + if (store.openaiDefaultChatModel && (resetToDefault || !store.openaiChatModel)) { store.openaiChatModel = store.openaiDefaultChatModel; } @@ -2494,6 +2496,11 @@ ${conversationText} // 设置模型 store.openaiChatModel = modelName; + // 清空当前会话状态,确保开始新对话 + store.openaiChatCurrentSessionId = null; + store.openaiChatMessages = []; + store.openaiChatSelectedSessionIds = []; + // 切换到对话标签页 store.openaiCurrentTab = 'chat'; diff --git a/src/templates/openai.html b/src/templates/openai.html index 340d495..ccad8ca 100644 --- a/src/templates/openai.html +++ b/src/templates/openai.html @@ -287,7 +287,7 @@
- diff --git a/src/templates/server.html b/src/templates/server.html index cd989a2..2643679 100644 --- a/src/templates/server.html +++ b/src/templates/server.html @@ -905,7 +905,7 @@

网络速率

-

容器虚拟化

+

Docker 容器