Files
flvx/.entire/metadata/ses_32f7e3b05ffeK8IxNvZAZISGWO/full.jsonl
T
sagit 5cb935e0e5 fix(frontend): refine node renewal UI and filters (#299)
* style: restore Prettier formatting in renewal.ts

* fix(frontend): refine node renewal UI and filters

Entire-Checkpoint: 88c8ae47fb7d

* refactor(nodes): remove unused tags field from node model

* refactor(node): improve card layout and reorder elements

Entire-Checkpoint: fb89f3ebef5c
2026-03-09 08:37:57 +00:00

5294 lines
625 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"info": {
"id": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"slug": "eager-cabin",
"projectID": "03c3dd2e5791a80ef100ef53744e013d779d671c",
"directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"title": "Docker镜像编译与推送:vite-frontend beta linux/amd64",
"version": "1.2.21",
"summary": {
"additions": 5229,
"deletions": 37,
"files": 5
},
"time": {
"created": 1773024691450,
"updated": 1773026824109
}
},
"messages": [
{
"info": {
"role": "user",
"time": {
"created": 1773024691703
},
"summary": {
"diffs": []
},
"agent": "build",
"model": {
"providerID": "zai-coding-plan",
"modelID": "glm-5"
},
"id": "msg_cd081c51a001vLqW21Ze705GtR",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "text",
"text": "编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送",
"id": "prt_cd081c51a002T3z3plWUWv0TQv",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081c51a001vLqW21Ze705GtR"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773024691711,
"completed": 1773024696822
},
"parentID": "msg_cd081c51a001vLqW21Ze705GtR",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 20667,
"input": 31,
"output": 92,
"reasoning": 0,
"cache": {
"read": 20544,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd081c5ff0012GzRl5Kpu0O5UQ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "10676d3395f1f949d9fb368ad57070ef5a1a7f4b",
"id": "prt_cd081cfa7001NZtwVfIY5a0eTg",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081c5ff0012GzRl5Kpu0O5UQ"
},
{
"type": "reasoning",
"text": "用户想要编译并推送 Docker 镜像,目标是:\n- 镜像名:ghcr.io/sagit-chu/vite-frontend:beta\n- 架构:linux/amd64\n\n让我先看看项目的 Dockerfile 和相关配置。",
"time": {
"start": 1773024694183,
"end": 1773024696580
},
"id": "prt_cd081cfa70020nF13Ce34ao0d5",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081c5ff0012GzRl5Kpu0O5UQ"
},
{
"type": "tool",
"callID": "call_2ed530c9e6224decbd741aa9",
"tool": "glob",
"state": {
"status": "completed",
"input": {
"pattern": "**/Dockerfile*"
},
"output": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/Dockerfile\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/Dockerfile",
"title": "",
"metadata": {
"count": 2,
"truncated": false
},
"time": {
"start": 1773024696077,
"end": 1773024696108
}
},
"id": "prt_cd081d7070012MSEvZ3ZJ0Ogxz",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081c5ff0012GzRl5Kpu0O5UQ"
},
{
"type": "tool",
"callID": "call_798854c4b4f646eda7c584e6",
"tool": "grep",
"state": {
"status": "completed",
"input": {
"include": "docker-compose*.yml",
"pattern": "vite-frontend"
},
"output": "Found 4 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/docker-compose-v4.yml:\n Line 58: image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}\n Line 59: container_name: vite-frontend\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/docker-compose-v6.yml:\n Line 58: image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}\n Line 59: container_name: vite-frontend",
"title": "vite-frontend",
"metadata": {
"matches": 4,
"truncated": false
},
"time": {
"start": 1773024696505,
"end": 1773024696532
}
},
"id": "prt_cd081d8b0001p10YcCsZSHTpRr",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081c5ff0012GzRl5Kpu0O5UQ"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "10676d3395f1f949d9fb368ad57070ef5a1a7f4b",
"cost": 0,
"tokens": {
"total": 20667,
"input": 31,
"output": 92,
"reasoning": 0,
"cache": {
"read": 20544,
"write": 0
}
},
"id": "prt_cd081d906001IalWHk54P6HBiv",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081c5ff0012GzRl5Kpu0O5UQ"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773024696823,
"completed": 1773024701931
},
"parentID": "msg_cd081c51a001vLqW21Ze705GtR",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 21013,
"input": 396,
"output": 73,
"reasoning": 0,
"cache": {
"read": 20544,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd081d9f600176iBr2i6Wx7cgQ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "10676d3395f1f949d9fb368ad57070ef5a1a7f4b",
"id": "prt_cd081e351001PSfu4BylxuGnxc",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081d9f600176iBr2i6Wx7cgQ"
},
{
"type": "reasoning",
"text": "找到了 vite-frontend/Dockerfile。让我读取它来了解构建配置。",
"time": {
"start": 1773024699218,
"end": 1773024701677
},
"id": "prt_cd081e352001p8HCJvfIh1Fg0X",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081d9f600176iBr2i6Wx7cgQ"
},
{
"type": "tool",
"callID": "call_1ad20ce617e74519bc55856e",
"tool": "read",
"state": {
"status": "completed",
"input": {
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/Dockerfile"
},
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/Dockerfile</path>\n<type>file</type>\n<content>1: # 多阶段构建 - 构建阶段\n2: FROM node:20.19.0 AS builder\n3: \n4: WORKDIR /app\n5: \n6: COPY package*.json ./\n7: RUN npm install --legacy-peer-deps\n8: \n9: COPY . .\n10: RUN npm run build\n11: \n12: # 生产阶段\n13: FROM nginx:stable-alpine AS production-stage\n14: \n15: COPY nginx.conf /etc/nginx/nginx.conf\n16: \n17: COPY --from=builder /app/dist /usr/share/nginx/html\n18: \n19: EXPOSE 80\n20: \n21: CMD [\"nginx\", \"-g\", \"daemon off;\"] \n\n(End of file - total 21 lines)\n</content>\n\n<system-reminder>\nInstructions from: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/AGENTS.md\n# VITE FRONTEND KNOWLEDGE BASE\n\n**Generated:** Thu Feb 26 2026\n**Commit:** 21008cc\n**Branch:** main\n**Tag:** 2.1.5-rc15\n\n## OVERVIEW\nWeb management console for FLVX.\n**Stack:** React 18, rolldown-vite, TypeScript, Tailwind CSS v4, shadcn/radix primitives with HeroUI-compatible bridge.\n\n## STRUCTURE\n```\nvite-frontend/\n├── src/\n│ ├── api/ # Axios wrapper + typed endpoint helpers\n│ ├── components/ui/ # shadcn/radix primitive components\n│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade (23 components)\n│ ├── pages/ # Route views + page modules (forward/node/tunnel)\n│ ├── hooks/ # H5/WebView/mobile hooks\n│ ├── styles/\n│ │ ├── globals.css # Base styles + imports tailwind-theme.pcss\n│ │ └── tailwind-theme.pcss # Tailwind v4 @theme inline semantic token mapping\n│ ├── App.tsx # Routes + ProtectedRoute + H5 layout selection\n│ ├── main.tsx # ReactDOM + BrowserRouter + Provider\n│ └── provider.tsx # Toast/theme/provider composition\n├── components.json # shadcn/ui config\n├── tailwind.config.js # Compatibility config for migration scaffolding\n├── vite.config.ts # base '/', host 0.0.0.0:3000; minify/treeshake disabled\n└── package.json\n```\n\n## WHERE TO LOOK\n| Task | Location | Notes |\n|------|----------|-------|\n| **Route definitions** | `src/App.tsx` | React Router v6 + ProtectedRoute |\n| **API Client/Auth header** | `src/api/network.ts` | Sends raw JWT in `Authorization` header |\n| **Login Flow** | `src/pages/index.tsx` | Calls `login()`, stores `localStorage.token` |\n| **Auth helpers** | `src/utils/auth.ts`, `src/utils/jwt.ts` | Role checks + token expiration parsing |\n| **UI bridge usage** | `src/shadcn-bridge/heroui/` | Import from bridge, not `@heroui/*` |\n| **Button parity mapping** | `src/shadcn-bridge/heroui/button.tsx` | Legacy `color`/`variant` mapped to shadcn classes |\n| **Semantic theme tokens** | `src/styles/tailwind-theme.pcss` | Restores classes like `bg-primary`, `border-input` |\n| **Theme wiring** | `src/styles/globals.css` | Must import `./tailwind-theme.pcss` |\n\n## CONVENTIONS\n- **Auth Header**: Use raw JWT token (no `Bearer` prefix).\n- **API Envelope**: Responses follow `{code, msg, data, ts}`.\n- **UI Imports**: Use `src/shadcn-bridge/heroui/*` in app pages/layouts for compatibility.\n- **Semantic Colors**: Keep `globals.css -> tailwind-theme.pcss` import intact or semantic classes break.\n- **Build profile**: `minify: false`, `treeshake: false` for debugging.\n- **Layout mode**: H5/mobile mode controlled by existing route/query and hook logic.\n\n## ANTI-PATTERNS\n- **DO NOT ADD** `Bearer` to auth header in frontend requests.\n- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies.\n- **DO NOT REMOVE** `src/styles/tailwind-theme.pcss` import from `src/styles/globals.css`.\n- **DO NOT ADD** frontend tests; no Vitest/Jest setup exists.\n\n## NOTES\n- Uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.\n- Build outputs are non-minified (debugging mode).\n- No test infrastructure exists (Vitest/Jest not configured).\n\n## COMMANDS\n```bash\ncd vite-frontend\nnpm run dev\nnpm run build\nnpm run lint\n```\n\n</system-reminder>",
"title": "vite-frontend/Dockerfile",
"metadata": {
"preview": "# 多阶段构建 - 构建阶段\nFROM node:20.19.0 AS builder\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm install --legacy-peer-deps\n\nCOPY . .\nRUN npm run build\n\n# 生产阶段\nFROM nginx:stable-alpine AS production-stage\n\nCOPY nginx.conf /etc/nginx/nginx.conf\n\nCOPY --from=builder /app/dist /usr/share/nginx/html\n\nEXPOSE 80\n",
"truncated": false,
"loaded": [
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/AGENTS.md"
]
},
"time": {
"start": 1773024701602,
"end": 1773024701608
}
},
"id": "prt_cd081ec9d001LbwS55yT2qulTB",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081d9f600176iBr2i6Wx7cgQ"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "10676d3395f1f949d9fb368ad57070ef5a1a7f4b",
"cost": 0,
"tokens": {
"total": 21013,
"input": 396,
"output": 73,
"reasoning": 0,
"cache": {
"read": 20544,
"write": 0
}
},
"id": "prt_cd081ecf0001NtBu3LYGGaWyaa",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081d9f600176iBr2i6Wx7cgQ"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773024701932,
"completed": 1773024761229
},
"parentID": "msg_cd081c51a001vLqW21Ze705GtR",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 22361,
"input": 1268,
"output": 165,
"reasoning": 0,
"cache": {
"read": 20928,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd081edec001jfq37hJv3aWjj4",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "10676d3395f1f949d9fb368ad57070ef5a1a7f4b",
"id": "prt_cd081f670001t7KRfghdv10vqr",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081edec001jfq37hJv3aWjj4"
},
{
"type": "reasoning",
"text": "我需要执行以下步骤来构建并推送 Docker 镜像:\n\n1. 使用 `docker buildx build` 来构建 linux/amd64 架构的镜像\n2. 推送到 ghcr.io/sagit-chu/vite-frontend:beta\n\n让我执行这个命令。",
"time": {
"start": 1773024704113,
"end": 1773024708857
},
"id": "prt_cd081f671001hVTdKBnKOj7gmh",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081edec001jfq37hJv3aWjj4"
},
{
"type": "tool",
"callID": "call_4f9274fd73f44cfb880d06d8",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .",
"description": "Build and push vite-frontend Docker image",
"timeout": 600000
},
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.8s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 2.1s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [internal] load build context\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.9s done\n#6 DONE 0.9s\n\n#7 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#7 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#7 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.9s done\n#7 DONE 0.9s\n\n#5 [internal] load build context\n#5 transferring context: 173.95MB 4.0s\n#5 transferring context: 297.12MB 5.3s done\n#5 DONE 5.5s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 DONE 0.5s\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 2.879 npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead\n#10 2.928 npm warn deprecated source-map@0.8.0-beta.0: The work that was done in this beta branch won't be included in future versions\n#10 3.615 npm warn deprecated react-beautiful-dnd@13.1.1: react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672\n#10 4.331 npm warn deprecated glob@11.1.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me\n#10 12.79 \n#10 12.79 added 843 packages, and audited 844 packages in 12s\n#10 12.79 \n#10 12.79 251 packages are looking for funding\n#10 12.79 run `npm fund` for details\n#10 12.79 \n#10 12.79 found 0 vulnerabilities\n#10 12.79 npm notice\n#10 12.79 npm notice New major version of npm available! 10.8.2 -> 11.11.0\n#10 12.79 npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.11.0\n#10 12.79 npm notice To update run: npm install -g npm@11.11.0\n#10 12.79 npm notice\n#10 DONE 13.9s\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.8s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.389 \n#12 0.389 > flvx@0.0.0 build\n#12 0.389 > tsc && vite build\n#12 0.389 \n#12 13.95 rolldown-vite v7.3.1 building client environment for production...\n#12 14.15 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.76 rendering chunks...\n#12 15.27 computing gzip size...\n#12 15.32 dist/manifest.webmanifest 0.43 kB\n#12 15.32 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.32 dist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\n#12 15.32 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.32 dist/assets/index-0sKKex2Q.js 4,010.25 kB │ gzip: 827.76 kB\n#12 15.32 ✓ built in 1.37s\n#12 16.55 \n#12 16.55 PWA v1.2.0\n#12 16.55 mode generateSW\n#12 16.55 precache 9 entries (4044.55 KiB)\n#12 16.55 files generated\n#12 16.55 dist/sw.js\n#12 16.55 dist/workbox-8c29f6e4.js\n#12 DONE 16.7s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers\n#15 exporting layers 0.2s done\n#15 exporting manifest sha256:f88879189809930231db5f5435be40fa06e325b2b37c05af6ccb5277b0440f15 done\n#15 exporting config sha256:6c5512f6b83bd640a7f65a42edd2f41b1cab747c51b27ba59fd2cba046ff9f9e done\n#15 exporting attestation manifest sha256:9fe0c166aee5d81f9213731485c597574ffb96f19ab3d74540a19e4d984b9b02 0.0s done\n#15 exporting manifest list sha256:a2b9193ec6b1bc5600a42bb593cb1caf3fccfd768fdff8d667162eac803b2af3 done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 6.2s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:a2b9193ec6b1bc5600a42bb593cb1caf3fccfd768fdff8d667162eac803b2af3\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:a2b9193ec6b1bc5600a42bb593cb1caf3fccfd768fdff8d667162eac803b2af3 2.3s done\n#15 DONE 8.6s\n",
"title": "Build and push vite-frontend Docker image",
"metadata": {
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.8s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 2.1s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [internal] load build context\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.9s done\n#6 DONE 0.9s\n\n#7 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#7 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#7 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.9s done\n#7 DONE 0.9s\n\n#5 [internal] load build context\n#5 transferring context: 173.95MB 4.0s\n#5 transferring context: 297.12MB 5.3s done\n#5 DONE 5.5s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 DONE 0.5s\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 2.879 npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead\n#10 2.928 npm warn deprecated source-map@0.8.0-beta.0: The work that was done in this beta branch won't be included in future versions\n#10 3.615 npm warn deprecated react-beautiful-dnd@13.1.1: react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672\n#10 4.331 npm warn deprecated glob@11.1.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me\n#10 12.79 \n#10 12.79 added 843 packages, and audited 844 packages in 12s\n#10 12.79 \n#10 12.79 251 packages are looking for funding\n#10 12.79 run `npm fund` for details\n#10 12.79 \n#10 12.79 found 0 vulnerabilities\n#10 12.79 npm notice\n#10 12.79 npm notice New major version of npm available! 10.8.2 -> 11.11.0\n#10 12.79 npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.11.0\n#10 12.79 npm notice To update run: npm install -g npm@11.11.0\n#10 12.79 npm notice\n#10 DONE 13.9s\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.8s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.389 \n#12 0.389 > flvx@0.0.0 build\n#12 0.389 > tsc && vite build\n#12 0.389 \n#12 13.95 rolldown-vite v7.3.1 building client environment for production...\n#12 14.15 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.76 rendering chunks...\n#12 15.27 computing gzip size...\n#12 15.32 dist/manifest.webmanifest 0.43 kB\n#12 15.32 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.32 dist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\n#12 15.32 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.32 dist/assets/index-0sKKex2Q.js 4,010.25 kB │ gzip: 827.76 kB\n#12 15.32 ✓ built in 1.37s\n#12 16.55 \n#12 16.55 PWA v1.2.0\n#12 16.55 mode generateSW\n#12 16.55 precache 9 entries (4044.55 KiB)\n#12 16.55 files generated\n#12 16.55 dist/sw.js\n#12 16.55 dist/workbox-8c29f6e4.js\n#12 DONE 16.7s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers\n#15 exporting layers 0.2s done\n#15 exporting manifest sha256:f88879189809930231db5f5435be40fa06e325b2b37c05af6ccb5277b0440f15 done\n#15 exporting config sha256:6c5512f6b83bd640a7f65a42edd2f41b1cab747c51b27ba59fd2cba046ff9f9e done\n#15 exporting attestation manifest sha256:9fe0c166aee5d81f9213731485c597574ffb96f19ab3d74540a19e4d984b9b02 0.0s done\n#15 exporting manifest list sha256:a2b9193ec6b1bc5600a42bb593cb1caf3fccfd768fdff8d667162eac803b2af3 done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 6.2s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:a2b9193ec6b1bc5600a42bb593cb1caf3fccfd768fdff8d667162eac803b2af3\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:a2b9193ec6b1bc5600a42bb593cb1caf3fccfd768fdff8d667162eac803b2af3 2.3s done\n#15 DONE 8.6s\n",
"exit": 0,
"description": "Build and push vite-frontend Docker image",
"truncated": false
},
"time": {
"start": 1773024708843,
"end": 1773024760994
}
},
"id": "prt_cd08208e60011Z5FFJwC4J6KWw",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081edec001jfq37hJv3aWjj4"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "10676d3395f1f949d9fb368ad57070ef5a1a7f4b",
"cost": 0,
"tokens": {
"total": 22361,
"input": 1268,
"output": 165,
"reasoning": 0,
"cache": {
"read": 20928,
"write": 0
}
},
"id": "prt_cd082d4a3001vKK54ZIxZnYfJL",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd081edec001jfq37hJv3aWjj4"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773024761230,
"completed": 1773024765207
},
"parentID": "msg_cd081c51a001vLqW21Ze705GtR",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 24380,
"input": 2194,
"output": 42,
"reasoning": 0,
"cache": {
"read": 22144,
"write": 0
}
},
"finish": "stop",
"id": "msg_cd082d58e001zwT37orS3bMHXV",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "10676d3395f1f949d9fb368ad57070ef5a1a7f4b",
"id": "prt_cd082dffa001IabWBF2DprZGbi",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd082d58e001zwT37orS3bMHXV"
},
{
"type": "reasoning",
"text": "镜像构建并推送成功了。让我给用户一个简洁的确认。",
"time": {
"start": 1773024763899,
"end": 1773024764951
},
"id": "prt_cd082dffb0013QIRhZ3B1mLLE7",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd082d58e001zwT37orS3bMHXV"
},
{
"type": "text",
"text": "已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)",
"time": {
"start": 1773024764953,
"end": 1773024764953
},
"id": "prt_cd082e0b8001MztPXQo1xNLicQ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd082d58e001zwT37orS3bMHXV"
},
{
"type": "step-finish",
"reason": "stop",
"snapshot": "10676d3395f1f949d9fb368ad57070ef5a1a7f4b",
"cost": 0,
"tokens": {
"total": 24380,
"input": 2194,
"output": 42,
"reasoning": 0,
"cache": {
"read": 22144,
"write": 0
}
},
"id": "prt_cd082e41b0014C9r2pBWJCFJNn",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd082d58e001zwT37orS3bMHXV"
}
]
},
{
"info": {
"role": "user",
"time": {
"created": 1773025127123
},
"summary": {
"diffs": []
},
"agent": "build",
"model": {
"providerID": "zai-coding-plan",
"modelID": "glm-5"
},
"id": "msg_cd0886a71001TAXZ8c21Nw9tV4",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "text",
"text": "编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送",
"id": "prt_cd0886a710024eOzMgGtHM0Cgb",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0886a71001TAXZ8c21Nw9tV4"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773025127135,
"completed": 1773025164811
},
"parentID": "msg_cd0886a71001TAXZ8c21Nw9tV4",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 24383,
"input": 3717,
"output": 122,
"reasoning": 0,
"cache": {
"read": 20544,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd0886adf001bANSFgMKAW5L02",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "e8e2b8fa124c3b48dbb776aefa8a81ca4f727434",
"id": "prt_cd0887533001lPGNaWFlq4G9FI",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0886adf001bANSFgMKAW5L02"
},
{
"type": "reasoning",
"text": "用户要求重新编译并推送相同的镜像。我将再次执行构建和推送命令。",
"time": {
"start": 1773025129779,
"end": 1773025133822
},
"id": "prt_cd0887533002myWe3qaxDda873",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0886adf001bANSFgMKAW5L02"
},
{
"type": "tool",
"callID": "call_0cead5cb36d049ef9b4c6262",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .",
"description": "Build and push vite-frontend Docker image",
"timeout": 600000
},
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.1s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.0s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 3.4s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.412 \n#12 0.412 > flvx@0.0.0 build\n#12 0.412 > tsc && vite build\n#12 0.412 \n#12 14.12 rolldown-vite v7.3.1 building client environment for production...\n#12 14.31 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.93 rendering chunks...\n#12 15.42 computing gzip size...\n#12 15.47 dist/manifest.webmanifest 0.43 kB\n#12 15.47 dist/index.html 7.51 kB │ gzip: 2.51 kB\n#12 15.47 dist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\n#12 15.47 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.47 dist/assets/index-DH8973Pf.js 4,009.80 kB │ gzip: 827.68 kB\n#12 15.47 ✓ built in 1.35s\n#12 16.68 \n#12 16.68 PWA v1.2.0\n#12 16.68 mode generateSW\n#12 16.68 precache 9 entries (4044.11 KiB)\n#12 16.68 files generated\n#12 16.68 dist/sw.js\n#12 16.68 dist/workbox-8c29f6e4.js\n#12 DONE 16.8s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:25fdee23b7726944e3ed9dd857ce55137c2dfa02a063885b58e61cd158ea4e4b done\n#15 exporting config sha256:a145caaf1a61b7811dee1ed1b14b5e02784eadeee5d56a30727ddc2f5951a8bf done\n#15 exporting attestation manifest sha256:687f273b2aab29c696545e409cd6ec872eab2de1f96b55f00cc6fe2394afa2b7 0.0s done\n#15 exporting manifest list sha256:f84d330abc93582a1b86dcd23560f33886ea96ece064058ec12a85686ad10fc7 done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.6s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:f84d330abc93582a1b86dcd23560f33886ea96ece064058ec12a85686ad10fc7\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:f84d330abc93582a1b86dcd23560f33886ea96ece064058ec12a85686ad10fc7 2.3s done\n#15 DONE 7.1s\n",
"title": "Build and push vite-frontend Docker image",
"metadata": {
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.1s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.0s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 3.4s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.412 \n#12 0.412 > flvx@0.0.0 build\n#12 0.412 > tsc && vite build\n#12 0.412 \n#12 14.12 rolldown-vite v7.3.1 building client environment for production...\n#12 14.31 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.93 rendering chunks...\n#12 15.42 computing gzip size...\n#12 15.47 dist/manifest.webmanifest 0.43 kB\n#12 15.47 dist/index.html 7.51 kB │ gzip: 2.51 kB\n#12 15.47 dist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\n#12 15.47 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.47 dist/assets/index-DH8973Pf.js 4,009.80 kB │ gzip: 827.68 kB\n#12 15.47 ✓ built in 1.35s\n#12 16.68 \n#12 16.68 PWA v1.2.0\n#12 16.68 mode generateSW\n#12 16.68 precache 9 entries (4044.11 KiB)\n#12 16.68 files generated\n#12 16.68 dist/sw.js\n#12 16.68 dist/workbox-8c29f6e4.js\n#12 DONE 16.8s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:25fdee23b7726944e3ed9dd857ce55137c2dfa02a063885b58e61cd158ea4e4b done\n#15 exporting config sha256:a145caaf1a61b7811dee1ed1b14b5e02784eadeee5d56a30727ddc2f5951a8bf done\n#15 exporting attestation manifest sha256:687f273b2aab29c696545e409cd6ec872eab2de1f96b55f00cc6fe2394afa2b7 0.0s done\n#15 exporting manifest list sha256:f84d330abc93582a1b86dcd23560f33886ea96ece064058ec12a85686ad10fc7 done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.6s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:f84d330abc93582a1b86dcd23560f33886ea96ece064058ec12a85686ad10fc7\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:f84d330abc93582a1b86dcd23560f33886ea96ece064058ec12a85686ad10fc7 2.3s done\n#15 DONE 7.1s\n",
"exit": 0,
"description": "Build and push vite-frontend Docker image",
"truncated": false
},
"time": {
"start": 1773025133778,
"end": 1773025164556
}
},
"id": "prt_cd08884ca001Rv79ES6kMbavEd",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0886adf001bANSFgMKAW5L02"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "e8e2b8fa124c3b48dbb776aefa8a81ca4f727434",
"cost": 0,
"tokens": {
"total": 24383,
"input": 3717,
"output": 122,
"reasoning": 0,
"cache": {
"read": 20544,
"write": 0
}
},
"id": "prt_cd088fd10001qrNHT1r0I4g0tZ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0886adf001bANSFgMKAW5L02"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773025164813,
"completed": 1773025169737
},
"parentID": "msg_cd0886a71001TAXZ8c21Nw9tV4",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 25835,
"input": 1545,
"output": 34,
"reasoning": 0,
"cache": {
"read": 24256,
"write": 0
}
},
"finish": "stop",
"id": "msg_cd088fe0d001rUh5fR6D8rMQp2",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "e8e2b8fa124c3b48dbb776aefa8a81ca4f727434",
"id": "prt_cd0890dee0016JbYjHUwloDOyO",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd088fe0d001rUh5fR6D8rMQp2"
},
{
"type": "reasoning",
"text": "构建和推送成功完成。",
"time": {
"start": 1773025168878,
"end": 1773025169476
},
"id": "prt_cd0890dee002gfRDTMYj1PYCWu",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd088fe0d001rUh5fR6D8rMQp2"
},
{
"type": "text",
"text": "已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)",
"time": {
"start": 1773025169477,
"end": 1773025169477
},
"id": "prt_cd0890e38001GTigzgi94Rg8VQ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd088fe0d001rUh5fR6D8rMQp2"
},
{
"type": "step-finish",
"reason": "stop",
"snapshot": "e8e2b8fa124c3b48dbb776aefa8a81ca4f727434",
"cost": 0,
"tokens": {
"total": 25835,
"input": 1545,
"output": 34,
"reasoning": 0,
"cache": {
"read": 24256,
"write": 0
}
},
"id": "prt_cd0891047001xlqfxxre7qlrKt",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd088fe0d001rUh5fR6D8rMQp2"
}
]
},
{
"info": {
"role": "user",
"time": {
"created": 1773025424196
},
"summary": {
"diffs": [
{
"file": "vite-frontend/src/pages/node.tsx",
"before": "import { useState, useEffect, useMemo, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n type DragEndEvent,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport {\n SortableContext,\n arrayMove,\n rectSortingStrategy,\n sortableKeyboardCoordinates,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nimport { SearchBar } from \"@/components/search-bar\";\nimport { AnimatedPage } from \"@/components/animated-page\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport {\n Modal,\n ModalContent,\n ModalHeader,\n ModalBody,\n ModalFooter,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport { Chip } from \"@/shadcn-bridge/heroui/chip\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Alert } from \"@/shadcn-bridge/heroui/alert\";\nimport { Progress } from \"@/shadcn-bridge/heroui/progress\";\nimport { Accordion, AccordionItem } from \"@/shadcn-bridge/heroui/accordion\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n createNode,\n getNodeList,\n updateNode,\n deleteNode,\n getNodeInstallCommand,\n updateNodeOrder,\n batchDeleteNodes,\n upgradeNode,\n batchUpgradeNodes,\n getNodeReleases,\n rollbackNode,\n type ReleaseChannel,\n} from \"@/api\";\nimport { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\nimport {\n getConnectionStatusMeta,\n getRemoteSyncErrorMessage,\n} from \"@/pages/node/display\";\nimport { tryCopyInstallCommand } from \"@/pages/node/install-command\";\nimport {\n getNodeRenewalSnapshot,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";\nimport { buildNodeSystemInfo } from \"@/pages/node/system-info\";\nimport { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\nimport { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\nimport { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n\nconst NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n\ninterface Node {\n id: number;\n inx?: number;\n name: string;\n remark?: string;\n tags?: string;\n expiryTime?: number;\n renewalCycle?: NodeRenewalCycle;\n ip: string;\n serverIp: string;\n serverIpV4?: string;\n serverIpV6?: string;\n port: string;\n tcpListenAddr?: string;\n udpListenAddr?: string;\n extraIPs?: string;\n version?: string;\n http?: number; // 0 关 1 开\n tls?: number; // 0 关 1 开\n socks?: number; // 0 关 1 开\n status: number;\n isRemote?: number;\n remoteUrl?: string;\n syncError?: string;\n connectionStatus: \"online\" | \"offline\";\n systemInfo?: {\n cpuUsage: number;\n memoryUsage: number;\n uploadTraffic: number;\n downloadTraffic: number;\n uploadSpeed: number;\n downloadSpeed: number;\n uptime: number;\n } | null;\n copyLoading?: boolean;\n upgradeLoading?: boolean;\n rollbackLoading?: boolean;\n}\n\ninterface NodeForm {\n id: number | null;\n name: string;\n remark: string;\n tags: string;\n expiryTime: number;\n renewalCycle: NodeRenewalCycle;\n serverHost: string;\n serverIpV4: string;\n serverIpV6: string;\n port: string;\n tcpListenAddr: string;\n udpListenAddr: string;\n interfaceName: string;\n extraIPs: string;\n http: number; // 0 关 1 开\n tls: number; // 0 关 1 开\n socks: number; // 0 关 1 开\n}\n\nconst EXPIRING_SOON_DAYS = 7;\n\ntype NodeExpiryState = \"permanent\" | \"healthy\" | \"expiringSoon\" | \"expired\";\n\ntype NodeFilterMode = \"all\" | \"expiringSoon\" | \"expired\" | \"withExpiry\";\n\nconst getNodeReminderEnabled = (node: Node): boolean => {\n return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;\n};\n\nconst getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {\n const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);\n\n if (renewal.state === \"unset\") {\n return {\n state: \"permanent\" as NodeExpiryState,\n label: \"未设置续费周期\",\n tone: \"default\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 3,\n nextDueTime: undefined,\n };\n }\n\n if (renewal.state === \"expired\") {\n return {\n state: \"expired\" as NodeExpiryState,\n label: \"已过期\",\n tone: \"danger\" as const,\n accentClassName:\n \"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20\",\n bannerClassName:\n \"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300\",\n isHighlighted: true,\n sortWeight: 0,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n if (renewal.state === \"dueSoon\") {\n return {\n state: \"expiringSoon\" as NodeExpiryState,\n label: renewal.label,\n tone: \"warning\" as const,\n accentClassName:\n \"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20\",\n bannerClassName:\n \"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300\",\n isHighlighted: true,\n sortWeight: 1,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n return {\n state: \"healthy\" as NodeExpiryState,\n label: renewal.label,\n tone: \"success\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 2,\n nextDueTime: renewal.nextDueTime,\n };\n};\n\nconst normalizeNodeTags = (tags?: string): string[] => {\n if (!tags) return [];\n\n return tags\n .split(\",\")\n .map((tag) => tag.trim())\n .filter(Boolean);\n};\n\nconst mergeNodeRealtimeState = (\n incomingNode: Node,\n existingNode?: Node,\n): Node => {\n return {\n ...incomingNode,\n systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,\n copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,\n upgradeLoading:\n existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,\n rollbackLoading:\n existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,\n };\n};\n\nconst SortableItem = ({\n id,\n children,\n}: {\n id: number;\n children: (listeners: any) => any;\n}) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n };\n\n return (\n <div\n ref={setNodeRef}\n style={style}\n {...attributes}\n className=\"overflow-hidden h-full\"\n {...listeners}\n >\n {children(listeners)}\n </div>\n );\n};\n\nexport default function NodePage() {\n const [nodeList, setNodeList] = useState<Node[]>([]);\n const [nodeOrder, setNodeOrder] = useState<number[]>([]);\n const [loading, setLoading] = useState(false);\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"node-search-keyword\",\n \"\",\n );\n const [nodeFilterMode, setNodeFilterMode] =\n useLocalStorageState<NodeFilterMode>(\"node-expiry-filter-mode\", \"all\");\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [dialogVisible, setDialogVisible] = useState(false);\n const [dialogTitle, setDialogTitle] = useState(\"\");\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);\n const [protocolDisabled, setProtocolDisabled] = useState(false);\n const [protocolDisabledReason, setProtocolDisabledReason] = useState(\"\");\n const [form, setForm] = useState<NodeForm>({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n const [errors, setErrors] = useState<Record<string, string>>({});\n\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchLoading, setBatchLoading] = useState(false);\n\n // 安装命令相关状态\n const [installCommandModal, setInstallCommandModal] = useState(false);\n const [installCommand, setInstallCommand] = useState(\"\");\n const [currentNodeName, setCurrentNodeName] = useState(\"\");\n const [installSelectorOpen, setInstallSelectorOpen] = useState(false);\n const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);\n const [installChannel, setInstallChannel] =\n useState<ReleaseChannel>(\"stable\");\n\n // 升级相关状态\n const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);\n const [upgradeTarget, setUpgradeTarget] = useState<\"single\" | \"batch\">(\n \"single\",\n );\n const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(\n null,\n );\n const [releases, setReleases] = useState<\n Array<{\n version: string;\n name: string;\n publishedAt: string;\n prerelease: boolean;\n channel: ReleaseChannel;\n }>\n >([]);\n const [releasesLoading, setReleasesLoading] = useState(false);\n const [releaseChannel, setReleaseChannel] =\n useState<ReleaseChannel>(\"stable\");\n const [selectedVersion, setSelectedVersion] = useState(\"\");\n const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);\n const [upgradeProgress, setUpgradeProgress] = useState<\n Record<number, { stage: string; percent: number; message: string }>\n >({});\n\n const handleNodeOffline = useCallback((nodeId: number) => {\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"offline\" && node.systemInfo === null) {\n return node;\n }\n\n return { ...node, connectionStatus: \"offline\", systemInfo: null };\n }),\n );\n }, []);\n\n const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({\n delayMs: 3000,\n onNodeOffline: handleNodeOffline,\n });\n\n // 加载节点列表\n const loadNodes = useCallback(async (options?: { silent?: boolean }) => {\n const silent = options?.silent ?? false;\n\n if (!silent) {\n setLoading(true);\n }\n\n try {\n const res = await getNodeList();\n\n if (res.code === 0) {\n const nodesData: Node[] = (res.data || []).map((node: any) => ({\n ...node,\n inx: node.inx ?? 0,\n connectionStatus: node.syncError\n ? \"offline\"\n : node.status === 1\n ? \"online\"\n : \"offline\",\n syncError: node.syncError || undefined,\n systemInfo: null,\n copyLoading: false,\n }));\n\n setNodeList((prev) => {\n const previousById = new Map(prev.map((node) => [node.id, node]));\n\n return nodesData.map((node) =>\n mergeNodeRealtimeState(node, previousById.get(node.id)),\n );\n });\n\n // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序\n const hasDbOrdering = nodesData.some(\n (n) => n.inx !== undefined && n.inx !== 0,\n );\n\n if (hasDbOrdering) {\n const dbOrder = [...nodesData]\n .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n .map((n) => n.id);\n\n setNodeOrder(dbOrder);\n } else {\n setNodeOrder(\n loadStoredOrder(\n \"node-order\",\n nodesData.map((n) => n.id),\n ),\n );\n }\n } else {\n if (!silent) {\n toast.error(res.msg || \"加载节点列表失败\");\n }\n }\n } catch {\n if (!silent) {\n toast.error(\"网络错误,请重试\");\n }\n } finally {\n if (!silent) {\n setLoading(false);\n }\n }\n }, []);\n\n // 处理WebSocket消息\n const handleWebSocketMessage = (data: any) => {\n const { id, type, data: messageData } = data;\n const nodeId = Number(id);\n\n if (Number.isNaN(nodeId)) return;\n\n if (type === \"status\") {\n if (messageData === 1) {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"online\") return node;\n\n return { ...node, connectionStatus: \"online\" };\n }),\n );\n } else {\n // 离线事件做延迟处理,避免短抖动导致频繁闪烁\n scheduleNodeOffline(nodeId);\n }\n } else if (type === \"info\") {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id === nodeId) {\n const systemInfo = buildNodeSystemInfo(\n messageData,\n node.systemInfo,\n );\n\n if (!systemInfo) {\n return node;\n }\n\n return {\n ...node,\n connectionStatus: \"online\",\n systemInfo,\n };\n }\n\n return node;\n }),\n );\n } else if (type === \"upgrade_progress\") {\n try {\n const progressData =\n typeof messageData === \"string\"\n ? JSON.parse(messageData)\n : messageData;\n\n if (progressData?.data) {\n setUpgradeProgress((prev) => ({\n ...prev,\n [nodeId]: {\n stage: progressData.data.stage || \"\",\n percent: progressData.data.percent || 0,\n message: progressData.message || \"\",\n },\n }));\n }\n } catch {\n // ignore parse errors\n }\n }\n };\n\n const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n\n useEffect(() => {\n loadNodes();\n }, [loadNodes]);\n\n useEffect(() => {\n if (!usingPollingFallback) {\n return;\n }\n\n void loadNodes({ silent: true });\n\n const interval = window.setInterval(() => {\n void loadNodes({ silent: true });\n }, NODE_FALLBACK_REFRESH_INTERVAL_MS);\n\n return () => {\n window.clearInterval(interval);\n };\n }, [loadNodes, usingPollingFallback]);\n\n // 格式化速度\n const formatSpeed = (bytesPerSecond: number): string => {\n if (bytesPerSecond === 0) return \"0 B/s\";\n\n const k = 1024;\n const sizes = [\"B/s\", \"KB/s\", \"MB/s\", \"GB/s\", \"TB/s\"];\n const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));\n\n return (\n parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i]\n );\n };\n\n // 格式化开机时间\n const formatUptime = (seconds: number): string => {\n if (seconds === 0) return \"-\";\n\n const days = Math.floor(seconds / 86400);\n const hours = Math.floor((seconds % 86400) / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n\n if (days > 0) {\n return `${days}天${hours}小时`;\n } else if (hours > 0) {\n return `${hours}小时${minutes}分钟`;\n } else {\n return `${minutes}分钟`;\n }\n };\n\n // 格式化流量\n const formatTraffic = (bytes: number): string => {\n if (bytes === 0) return \"0 B\";\n\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n };\n\n // 获取进度条颜色\n const getProgressColor = (\n value: number,\n offline = false,\n ): \"default\" | \"primary\" | \"secondary\" | \"success\" | \"warning\" | \"danger\" => {\n if (offline) return \"default\";\n if (value <= 50) return \"success\";\n if (value <= 80) return \"warning\";\n\n return \"danger\";\n };\n\n // IPv4/IPv6 格式验证(仅用于判定地址族)\n const ipv4Regex =\n /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;\n const ipv6Regex =\n /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;\n\n const validateIpv4Literal = (ip: string): boolean =>\n ipv4Regex.test(ip.trim());\n const validateIpv6Literal = (ip: string): boolean =>\n ipv6Regex.test(ip.trim());\n\n // Hostname/domain validation (no scheme/port)\n const hostnameRegex =\n /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;\n const validateHostname = (host: string): boolean => {\n const v = host.trim();\n\n if (!v) return false;\n if (v === \"localhost\") return true;\n\n return hostnameRegex.test(v);\n };\n\n // 验证端口格式:支持 80,443,100-600\n const validatePort = (\n portStr: string,\n ): { valid: boolean; error?: string } => {\n if (!portStr || !portStr.trim()) {\n return { valid: false, error: \"请输入端口\" };\n }\n\n const trimmed = portStr.trim();\n const parts = trimmed\n .split(\",\")\n .map((p) => p.trim())\n .filter((p) => p);\n\n if (parts.length === 0) {\n return { valid: false, error: \"请输入有效的端口\" };\n }\n\n for (const part of parts) {\n // 检查是否是端口范围 (如 100-600)\n if (part.includes(\"-\")) {\n const range = part.split(\"-\").map((p) => p.trim());\n\n if (range.length !== 2) {\n return { valid: false, error: `端口范围格式错误: ${part}` };\n }\n\n const start = parseInt(range[0]);\n const end = parseInt(range[1]);\n\n if (isNaN(start) || isNaN(end)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (start < 1 || start > 65535 || end < 1 || end > 65535) {\n return {\n valid: false,\n error: `端口范围必须在 1-65535 之间: ${part}`,\n };\n }\n\n if (start >= end) {\n return { valid: false, error: `起始端口必须小于结束端口: ${part}` };\n }\n } else {\n // 单个端口\n const port = parseInt(part);\n\n if (isNaN(port)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (port < 1 || port > 65535) {\n return { valid: false, error: `端口必须在 1-65535 之间: ${part}` };\n }\n }\n }\n\n return { valid: true };\n };\n\n // 表单验证\n const validateForm = (): boolean => {\n const newErrors: Record<string, string> = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入节点名称\";\n } else if (form.name.trim().length < 2) {\n newErrors.name = \"节点名称长度至少2位\";\n } else if (form.name.trim().length > 50) {\n newErrors.name = \"节点名称长度不能超过50位\";\n }\n\n if (\n (form.renewalCycle && !form.expiryTime) ||\n (!form.renewalCycle && form.expiryTime)\n ) {\n newErrors.expiryTime = \"请同时设置续费周期和续费基准时间\";\n }\n\n const v4 = form.serverIpV4.trim();\n const v6 = form.serverIpV6.trim();\n const host = form.serverHost.trim();\n\n if (!v4 && !v6 && !host) {\n const msg = \"请至少填写一个 IPv4/IPv6 地址或域名\";\n\n newErrors.serverIpV4 = msg;\n newErrors.serverIpV6 = msg;\n newErrors.serverHost = msg;\n } else {\n if (v4 && !validateIpv4Literal(v4)) {\n newErrors.serverIpV4 = \"请输入有效的IPv4地址\";\n }\n if (v6 && !validateIpv6Literal(v6)) {\n newErrors.serverIpV6 = \"请输入有效的IPv6地址\";\n }\n if (host && !validateHostname(host)) {\n newErrors.serverHost = \"请输入有效的域名/主机名\";\n }\n }\n\n const portValidation = validatePort(form.port);\n\n if (!portValidation.valid) {\n newErrors.port = portValidation.error || \"端口格式错误\";\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增节点\n const handleAdd = () => {\n setDialogTitle(\"新增节点\");\n setIsEdit(false);\n setDialogVisible(true);\n resetForm();\n setProtocolDisabled(true);\n setProtocolDisabledReason(\"节点未在线,等待节点上线后再设置\");\n };\n\n // 编辑节点\n const handleEdit = (node: Node) => {\n setDialogTitle(\"编辑节点\");\n setIsEdit(true);\n\n const legacy = (node.serverIp || \"\").trim();\n const normalizedV4 =\n node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : \"\");\n const normalizedV6 =\n node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : \"\");\n const normalizedHost =\n !normalizedV4 && !normalizedV6 && legacy ? legacy : \"\";\n\n setForm({\n id: node.id,\n name: node.name,\n remark: node.remark || \"\",\n tags: node.tags || \"\",\n expiryTime: node.expiryTime || 0,\n renewalCycle: node.renewalCycle || \"\",\n serverHost: normalizedHost,\n serverIpV4: normalizedV4,\n serverIpV6: normalizedV6,\n port: node.port || \"1000-65535\",\n tcpListenAddr: node.tcpListenAddr || \"[::]\",\n udpListenAddr: node.udpListenAddr || \"[::]\",\n interfaceName: (node as any).interfaceName || \"\",\n extraIPs: node.extraIPs || \"\",\n http: typeof node.http === \"number\" ? node.http : 1,\n tls: typeof node.tls === \"number\" ? node.tls : 1,\n socks: typeof node.socks === \"number\" ? node.socks : 1,\n });\n const offline = node.connectionStatus !== \"online\";\n\n setProtocolDisabled(offline);\n setProtocolDisabledReason(\n offline ? \"节点未在线,等待节点上线后再设置\" : \"\",\n );\n setDialogVisible(true);\n };\n\n // 删除节点\n const handleDelete = (node: Node) => {\n setNodeToDelete(node);\n setDeleteModalOpen(true);\n };\n\n const confirmDelete = async () => {\n if (!nodeToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteNode(nodeToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id));\n setDeleteModalOpen(false);\n setNodeToDelete(null);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n const openInstallSelector = (node: Node) => {\n setInstallTargetNode(node);\n setInstallChannel(\"stable\");\n setInstallSelectorOpen(true);\n };\n\n // 复制安装命令\n const handleCopyInstallCommand = async (\n node: Node,\n channel: ReleaseChannel,\n ) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),\n );\n\n try {\n const res = await getNodeInstallCommand(node.id, channel);\n\n if (res.code === 0 && res.data) {\n const copied = await tryCopyInstallCommand(res.data);\n\n if (copied) {\n toast.success(\n `${channel === \"stable\" ? \"正式版\" : \"测试版\"}安装命令已复制到剪贴板`,\n );\n } else {\n setInstallCommand(res.data);\n setCurrentNodeName(node.name);\n setInstallCommandModal(true);\n }\n } else {\n toast.error(res.msg || \"获取安装命令失败\");\n }\n } catch {\n toast.error(\"获取安装命令失败\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)),\n );\n }\n };\n\n const handleConfirmInstallCommand = async () => {\n if (!installTargetNode) return;\n\n setInstallSelectorOpen(false);\n await handleCopyInstallCommand(installTargetNode, installChannel);\n };\n\n // 手动复制安装命令\n const handleManualCopy = async () => {\n try {\n await navigator.clipboard.writeText(installCommand);\n toast.success(\"安装命令已复制到剪贴板\");\n setInstallCommandModal(false);\n } catch {\n toast.error(\"复制失败,请手动选择文本复制\");\n }\n };\n\n const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {\n setReleasesLoading(true);\n try {\n const res = await getNodeReleases(channel);\n\n if (res.code === 0 && Array.isArray(res.data)) {\n setReleases(res.data);\n } else {\n toast.error(res.msg || \"获取版本列表失败\");\n }\n } catch {\n toast.error(\"获取版本列表失败\");\n } finally {\n setReleasesLoading(false);\n }\n }, []);\n\n // 打开版本选择弹窗\n const openUpgradeModal = async (\n target: \"single\" | \"batch\",\n nodeId?: number,\n ) => {\n const defaultChannel: ReleaseChannel = \"stable\";\n\n setUpgradeTarget(target);\n setUpgradeTargetNodeId(nodeId || null);\n setReleaseChannel(defaultChannel);\n setSelectedVersion(\"\");\n setUpgradeModalOpen(true);\n await loadReleasesByChannel(defaultChannel);\n };\n\n // 确认升级(从版本弹窗)\n const handleConfirmUpgrade = async () => {\n const version = selectedVersion || undefined;\n\n if (upgradeTarget === \"single\" && upgradeTargetNodeId) {\n setUpgradeModalOpen(false);\n // Find the node\n const node = nodeList.find((n) => n.id === upgradeTargetNodeId);\n\n if (!node) return;\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,\n ),\n );\n try {\n const res = await upgradeNode(\n upgradeTargetNodeId,\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`节点升级命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,\n ),\n );\n }\n } else if (upgradeTarget === \"batch\") {\n setBatchUpgradeLoading(true);\n setUpgradeModalOpen(false);\n try {\n const res = await batchUpgradeNodes(\n Array.from(selectedIds),\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);\n } else {\n toast.error(res.msg || \"批量升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchUpgradeLoading(false);\n }\n }\n };\n\n // 回退节点\n const handleRollbackNode = async (node: Node) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),\n );\n try {\n const res = await rollbackNode(node.id);\n\n if (res.code === 0) {\n toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"回退失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === node.id ? { ...n, rollbackLoading: false } : n,\n ),\n );\n }\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n\n try {\n const apiCall = isEdit ? updateNode : createNode;\n const { serverHost, ...rest } = form;\n const data = {\n ...rest,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n extraIPs: form.extraIPs,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n serverHost?.trim() ||\n \"\",\n };\n\n const res = await apiCall(data);\n\n if (res.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n setDialogVisible(false);\n\n if (isEdit) {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === form.id\n ? {\n ...n,\n name: form.name,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n form.serverHost?.trim() ||\n \"\",\n serverIpV4: form.serverIpV4,\n serverIpV6: form.serverIpV6,\n port: form.port,\n tcpListenAddr: form.tcpListenAddr,\n udpListenAddr: form.udpListenAddr,\n interfaceName: form.interfaceName,\n http: form.http,\n tls: form.tls,\n socks: form.socks,\n }\n : n,\n ),\n );\n } else {\n loadNodes();\n }\n } else {\n toast.error(res.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 重置表单\n const resetForm = () => {\n setForm({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n setErrors({});\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n if (!nodeOrder || nodeOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const oldIndex = nodeOrder.indexOf(activeId);\n const newIndex = nodeOrder.indexOf(overId);\n\n if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;\n\n const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);\n\n setNodeOrder(newOrder);\n\n saveOrder(\"node-order\", newOrder);\n\n // 持久化到数据库\n try {\n const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));\n const response = await updateNodeOrder({ nodes: nodesToUpdate });\n\n if (response.code === 0) {\n setNodeList((prev) =>\n prev.map((node) => {\n const updated = nodesToUpdate.find((n) => n.id === node.id);\n\n return updated ? { ...node, inx: updated.inx } : node;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n };\n\n // 批量操作处理函数\n const toggleSelectMode = () => {\n setSelectMode((prev) => {\n if (prev) {\n setSelectedIds(new Set());\n }\n\n return !prev;\n });\n };\n\n const toggleSelect = (id: number) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n };\n\n const selectAll = () => {\n setSelectedIds(new Set(sortedNodes.map((n) => n.id)));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n try {\n const res = await batchDeleteNodes(Array.from(selectedIds));\n\n if (res.code === 0) {\n toast.success(`成功删除 ${selectedIds.size} 个节点`);\n setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id)));\n setSelectedIds(new Set());\n setBatchDeleteModalOpen(false);\n setSelectMode(false);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchLoading(false);\n }\n };\n\n // 传感器配置\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n const nodeExpiryStats = useMemo(() => {\n return nodeList.reduce(\n (acc, node) => {\n const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);\n\n if (meta.state === \"expired\") acc.expired += 1;\n if (meta.state === \"expiringSoon\") acc.expiringSoon += 1;\n if (getNodeReminderEnabled(node)) {\n acc.withExpiry += 1;\n }\n\n return acc;\n },\n { expired: 0, expiringSoon: 0, withExpiry: 0 },\n );\n }, [nodeList]);\n\n // 根据排序顺序获取节点列表\n const sortedNodes = useMemo((): Node[] => {\n if (!nodeList || nodeList.length === 0) return [];\n\n let filteredNodes = nodeList;\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredNodes = filteredNodes.filter(\n (n) =>\n (n.name && n.name.toLowerCase().includes(lowerKeyword)) ||\n (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||\n (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n if (nodeFilterMode !== \"all\") {\n filteredNodes = filteredNodes.filter((node) => {\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n switch (nodeFilterMode) {\n case \"expiringSoon\":\n return expiryMeta.state === \"expiringSoon\";\n case \"expired\":\n return expiryMeta.state === \"expired\";\n case \"withExpiry\":\n return getNodeReminderEnabled(node);\n default:\n return true;\n }\n });\n }\n\n const sortedByDb = [...filteredNodes].sort((a, b) => {\n const expiryDiff =\n getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -\n getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;\n\n if (expiryDiff !== 0) {\n return expiryDiff;\n }\n\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n return aInx - bInx;\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n nodeOrder &&\n nodeOrder.length > 0 &&\n sortedByDb.every((n) => n.inx === undefined || n.inx === 0)\n ) {\n const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));\n const localSorted: Node[] = [];\n\n nodeOrder.forEach((id) => {\n const node = nodeMap.get(id);\n\n if (node) localSorted.push(node);\n });\n\n filteredNodes.forEach((node) => {\n if (!nodeOrder.includes(node.id)) {\n localSorted.push(node);\n }\n });\n\n return localSorted;\n }\n\n return sortedByDb;\n }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);\n\n const sortableNodeIds = useMemo(\n () => sortedNodes.map((n) => n.id),\n [sortedNodes],\n );\n\n return (\n <AnimatedPage className=\"px-3 lg:px-6 py-8\">\n <div className=\"flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3\">\n <div className=\"flex flex-1 flex-col gap-3 lg:flex-row lg:items-center\">\n <SearchBar\n isVisible={isSearchVisible}\n placeholder=\"搜索名称、IP、备注或标签\"\n value={searchKeyword}\n onChange={setSearchKeyword}\n onClose={() => setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n\n <Select\n className=\"w-full max-w-sm lg:w-56\"\n label=\"到期筛选\"\n selectedKeys={[nodeFilterMode]}\n size=\"sm\"\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as\n | NodeFilterMode\n | undefined;\n\n setNodeFilterMode(selected || \"all\");\n }}\n >\n <SelectItem key=\"all\" textValue=\"全部节点\">\n 全部节点\n </SelectItem>\n <SelectItem key=\"expiringSoon\" textValue=\"7天内到期\">\n 7天内续费 ({nodeExpiryStats.expiringSoon})\n </SelectItem>\n <SelectItem key=\"expired\" textValue=\"已过期\">\n 已逾期 ({nodeExpiryStats.expired})\n </SelectItem>\n <SelectItem key=\"withExpiry\" textValue=\"已设置到期时间\">\n 已启用续费提醒 ({nodeExpiryStats.withExpiry})\n </SelectItem>\n </Select>\n </div>\n\n <div className=\"min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x\">\n <div className=\"flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0\">\n {selectMode ? (\n <>\n <span className=\"text-sm text-default-600 shrink-0\">\n 已选 {selectedIds.size} 项\n </span>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={selectAll}\n >\n 全选\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={deselectAll}\n >\n 清空\n </Button>\n <Button\n color=\"warning\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchUpgradeLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => openUpgradeModal(\"batch\")}\n >\n 升级\n </Button>\n <Button\n color=\"danger\"\n isDisabled={selectedIds.size === 0}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setBatchDeleteModalOpen(true)}\n >\n 删除\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"solid\"\n onPress={toggleSelectMode}\n >\n 退出\n </Button>\n </>\n ) : (\n <>\n <Button\n className=\"bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45\"\n color=\"default\"\n size=\"sm\"\n variant=\"flat\"\n onPress={toggleSelectMode}\n >\n 批量\n </Button>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleAdd}\n >\n 新增\n </Button>\n </>\n )}\n </div>\n </div>\n </div>\n\n {!wsConnected && (\n <Alert\n className=\"mb-4\"\n color=\"warning\"\n description={\n wsConnecting\n ? \"监控连接中...\"\n : usingPollingFallback\n ? \"监控连接已断开,已切换为列表自动刷新兜底模式。\"\n : \"监控连接已断开,正在重连...\"\n }\n variant=\"flat\"\n />\n )}\n\n {/* 节点列表 */}\n {loading ? (\n <PageLoadingState message=\"正在加载...\" />\n ) : nodeList.length === 0 ? (\n <PageEmptyState\n className=\"h-64\"\n message=\"暂无节点配置,点击上方按钮开始创建\"\n />\n ) : (\n <DndContext sensors={sensors} onDragEnd={handleDragEnd}>\n <SortableContext\n items={sortableNodeIds}\n strategy={rectSortingStrategy}\n >\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4\">\n {sortedNodes.map((node) => {\n const isRemoteNode = node.isRemote === 1;\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n return (\n <SortableItem key={node.id} id={node.id}>\n {(listeners) => (\n <Card\n key={node.id}\n className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full flex flex-col ${expiryMeta.accentClassName}`}\n >\n <CardHeader className=\"pb-2 md:pb-2\">\n <div className=\"flex justify-between items-start w-full\">\n <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n {selectMode && (\n <Checkbox\n isSelected={selectedIds.has(node.id)}\n onValueChange={() => toggleSelect(node.id)}\n />\n )}\n <h3 className=\"font-semibold text-foreground truncate text-sm\">\n {node.name}\n </h3>\n </div>\n <div className=\"flex items-center gap-1.5 ml-2\">\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n <Chip\n className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n color={expiryMeta.tone}\n size=\"sm\"\n title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}\n variant=\"flat\"\n >\n {expiryMeta.label}\n </Chip>\n )}\n <div\n className=\"cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100\"\n {...listeners}\n style={{ touchAction: \"none\" }}\n title=\"拖拽排序\"\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n {isRemoteNode && (\n <Chip\n className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n 远程\n </Chip>\n )}\n {(() => {\n const connectionStatusMeta =\n getConnectionStatusMeta(\n node.connectionStatus,\n );\n\n return (\n <Chip\n className=\"text-[10px] h-5 px-1\"\n color={connectionStatusMeta.color}\n size=\"sm\"\n variant=\"flat\"\n >\n {connectionStatusMeta.text}\n </Chip>\n );\n })()}\n </div>\n </div>\n </CardHeader>\n\n <CardBody className=\"pt-0 pb-3 md:pt-0 md:pb-3 flex-1 flex flex-col\">\n {isRemoteNode && node.syncError && (\n <div className=\"mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs\">\n {getRemoteSyncErrorMessage(node.syncError)}\n </div>\n )}\n {expiryMeta.isHighlighted && (\n <div\n className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}\n >\n <span>节点到期提醒</span>\n <span>{expiryMeta.label}</span>\n </div>\n )}\n {/* 基础信息 */}\n <div className=\"space-y-2 mb-4\">\n {(node.remark?.trim() || node.tags?.trim()) && (\n <div className=\"space-y-1.5\">\n {node.remark?.trim() && (\n <div className=\"rounded-md border border-divider/80 bg-default-50/80 px-2.5 py-1.5\">\n <div\n className=\"text-xs leading-5 text-default-600 line-clamp-1 break-all\"\n title={node.remark.trim()}\n >\n {node.remark.trim()}\n </div>\n </div>\n )}\n {normalizeNodeTags(node.tags).length > 0 && (\n <div className=\"flex flex-wrap gap-1.5\">\n {normalizeNodeTags(node.tags).map((tag) => (\n <Chip\n key={`${node.id}-${tag}`}\n className=\"text-[10px] h-5 px-1\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n #{tag}\n </Chip>\n ))}\n </div>\n )}\n </div>\n )}\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && <div className=\"hidden\" />}\n <div className=\"flex justify-between items-center text-sm min-w-0\">\n <span className=\"text-default-600 flex-shrink-0\">\n IP\n </span>\n <div className=\"text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]\">\n {node.serverIpV4?.trim() ||\n node.serverIpV6?.trim() ? (\n <div className=\"space-y-0.5\">\n {node.serverIpV4?.trim() && (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIpV4.trim()}\n >\n {node.serverIpV4.trim()}\n </span>\n )}\n {node.serverIpV6?.trim() && (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIpV6.trim()}\n >\n {node.serverIpV6.trim()}\n </span>\n )}\n </div>\n ) : (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIp.trim()}\n >\n {node.serverIp.trim()}\n </span>\n )}\n </div>\n </div>\n {!isRemoteNode && (\n <>\n <div className=\"flex justify-between text-sm\">\n <span className=\"text-default-600\">版本</span>\n <span className=\"text-xs\">\n {node.version || \"未知\"}\n </span>\n </div>\n {upgradeProgress[node.id] &&\n upgradeProgress[node.id].percent < 100 && (\n <div className=\"mt-1\">\n <Progress\n showValueLabel\n aria-label=\"升级进度\"\n color=\"warning\"\n label={upgradeProgress[node.id].message}\n size=\"sm\"\n value={upgradeProgress[node.id].percent}\n />\n </div>\n )}\n <div className=\"flex justify-between text-sm\">\n <span className=\"text-default-600\">\n 开机时间\n </span>\n <span className=\"text-xs\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatUptime(node.systemInfo.uptime)\n : \"-\"}\n </span>\n </div>\n </>\n )}\n </div>\n\n {!isRemoteNode && (\n <>\n {/* 系统监控 */}\n <div className=\"space-y-3 mb-4\">\n <div className=\"grid grid-cols-2 gap-3\">\n <div>\n <div className=\"flex justify-between text-xs mb-1\">\n <span>CPU</span>\n <span className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.cpuUsage.toFixed(1)}%`\n : \"-\"}\n </span>\n </div>\n <Progress\n aria-label=\"CPU使用率\"\n color={getProgressColor(\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.cpuUsage\n : 0,\n node.connectionStatus !== \"online\",\n )}\n size=\"sm\"\n value={\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.cpuUsage\n : 0\n }\n />\n </div>\n <div>\n <div className=\"flex justify-between text-xs mb-1\">\n <span>内存</span>\n <span className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.memoryUsage.toFixed(1)}%`\n : \"-\"}\n </span>\n </div>\n <Progress\n aria-label=\"内存使用率\"\n color={getProgressColor(\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.memoryUsage\n : 0,\n node.connectionStatus !== \"online\",\n )}\n size=\"sm\"\n value={\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.memoryUsage\n : 0\n }\n />\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-2 text-xs\">\n <div className=\"text-center p-2 bg-default-50 dark:bg-default-100 rounded\">\n <div className=\"text-default-600 mb-0.5\">\n 上传\n </div>\n <div className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.uploadSpeed,\n )\n : \"-\"}\n </div>\n </div>\n <div className=\"text-center p-2 bg-default-50 dark:bg-default-100 rounded\">\n <div className=\"text-default-600 mb-0.5\">\n 下载\n </div>\n <div className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.downloadSpeed,\n )\n : \"-\"}\n </div>\n </div>\n </div>\n\n {/* 流量统计 */}\n <div className=\"grid grid-cols-2 gap-2 text-xs\">\n <div className=\"text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20\">\n <div className=\"text-primary-600 dark:text-primary-400 mb-0.5\">\n ↑ 上行流量\n </div>\n <div className=\"font-mono text-primary-700 dark:text-primary-300\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.uploadTraffic,\n )\n : \"-\"}\n </div>\n </div>\n <div className=\"text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20\">\n <div className=\"text-success-600 dark:text-success-400 mb-0.5\">\n ↓ 下行流量\n </div>\n <div className=\"font-mono text-success-700 dark:text-success-300\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.downloadTraffic,\n )\n : \"-\"}\n </div>\n </div>\n </div>\n </div>\n </>\n )}\n\n {/* 操作按钮 */}\n <div className=\"space-y-1.5 mt-auto\">\n {!isRemoteNode && (\n <div className=\"grid grid-cols-3 gap-1.5\">\n <Button\n className=\"min-h-8\"\n color=\"success\"\n isLoading={node.copyLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => openInstallSelector(node)}\n >\n 安装\n </Button>\n <Button\n className=\"min-h-8\"\n color=\"warning\"\n isDisabled={\n node.connectionStatus !== \"online\"\n }\n isLoading={node.upgradeLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() =>\n openUpgradeModal(\"single\", node.id)\n }\n >\n 升级\n </Button>\n <Button\n className=\"min-h-8\"\n color=\"secondary\"\n isDisabled={\n node.connectionStatus !== \"online\"\n }\n isLoading={node.rollbackLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleRollbackNode(node)}\n >\n 回退\n </Button>\n </div>\n )}\n <div\n className={`grid gap-1.5 ${isRemoteNode ? \"grid-cols-1\" : \"grid-cols-2\"}`}\n >\n {!isRemoteNode && (\n <Button\n className=\"min-h-8\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleEdit(node)}\n >\n 编辑\n </Button>\n )}\n <Button\n className=\"min-h-8\"\n color=\"danger\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleDelete(node)}\n >\n 删除\n </Button>\n </div>\n </div>\n </CardBody>\n </Card>\n )}\n </SortableItem>\n );\n })}\n </div>\n </SortableContext>\n </DndContext>\n )}\n\n {/* 新增/编辑节点对话框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={dialogVisible}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setDialogVisible(false)}\n >\n <ModalContent>\n <ModalHeader>{dialogTitle}</ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <Input\n errorMessage={errors.name}\n isInvalid={!!errors.name}\n label=\"节点名称\"\n placeholder=\"请输入节点名称\"\n value={form.name}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n <Textarea\n description=\"可记录供应商、用途、续费说明等补充信息\"\n label=\"备注\"\n maxRows={4}\n minRows={3}\n placeholder=\"例如: 搬瓦工年付,2026-12 续费,日本中转\"\n value={form.remark}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, remark: e.target.value }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n description=\"多个标签用逗号分隔,可用于搜索过滤\"\n label=\"标签\"\n placeholder=\"例如: 年付,日本中转,测试机\"\n value={form.tags}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, tags: e.target.value }))\n }\n />\n\n <Select\n label=\"续费周期\"\n placeholder=\"选择续费周期\"\n selectedKeys={form.renewalCycle ? [form.renewalCycle] : []}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as\n | NodeRenewalCycle\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n renewalCycle: selected || \"\",\n }));\n }}\n >\n <SelectItem key=\"month\" textValue=\"month\">\n 月付\n </SelectItem>\n <SelectItem key=\"quarter\" textValue=\"quarter\">\n 季付\n </SelectItem>\n <SelectItem key=\"year\" textValue=\"year\">\n 年付\n </SelectItem>\n </Select>\n </div>\n\n <Input\n description=\"填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费\"\n errorMessage={errors.expiryTime}\n isInvalid={!!errors.expiryTime}\n label=\"续费基准时间\"\n type=\"datetime-local\"\n value={\n form.expiryTime > 0\n ? new Date(form.expiryTime).toISOString().slice(0, 16)\n : \"\"\n }\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n expiryTime: e.target.value\n ? new Date(e.target.value).getTime()\n : 0,\n }))\n }\n />\n\n <Alert\n color=\"primary\"\n description=\"例如选择月付并填写 2026-03-01,系统会自动按每月同日推算下次续费时间。\"\n variant=\"flat\"\n />\n\n <Input\n description=\"可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名\"\n errorMessage={errors.serverHost}\n isInvalid={!!errors.serverHost}\n label=\"服务器域名/主机名\"\n placeholder=\"例如: node.example.com\"\n value={form.serverHost}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverHost: e.target.value }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n description=\"双栈节点组隧道时优先使用 IPv4\"\n errorMessage={errors.serverIpV4}\n isInvalid={!!errors.serverIpV4}\n label=\"服务器IPv4\"\n placeholder=\"例如: 203.0.113.10\"\n value={form.serverIpV4}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverIpV4: e.target.value }))\n }\n />\n\n <Input\n description=\"至少填写一个 IPv4/IPv6/域名\"\n errorMessage={errors.serverIpV6}\n isInvalid={!!errors.serverIpV6}\n label=\"服务器IPv6\"\n placeholder=\"例如: 2001:db8::10\"\n value={form.serverIpV6}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverIpV6: e.target.value }))\n }\n />\n </div>\n\n <Input\n classNames={{\n input: \"font-mono\",\n }}\n description=\"支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔\"\n errorMessage={errors.port}\n isInvalid={!!errors.port}\n label=\"可用端口\"\n placeholder=\"例如: 80,443,1000-65535\"\n value={form.port}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, port: e.target.value }))\n }\n />\n\n {/* 高级配置 */}\n <Accordion variant=\"bordered\">\n <AccordionItem\n key=\"advanced\"\n aria-label=\"高级配置\"\n title=\"高级配置\"\n >\n <div className=\"space-y-4 pb-2\">\n <Input\n description=\"用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行\"\n errorMessage={errors.interfaceName}\n isInvalid={!!errors.interfaceName}\n label=\"出口网卡名或IP\"\n placeholder=\"请输入出口网卡名或IP\"\n value={form.interfaceName}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n interfaceName: e.target.value,\n }))\n }\n />\n\n <Input\n description=\"多IP服务器可填写额外IP地址,逗号分隔\"\n label=\"额外IP地址\"\n placeholder=\"例如: 192.168.1.100, 10.0.0.5\"\n value={form.extraIPs}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n extraIPs: e.target.value,\n }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n errorMessage={errors.tcpListenAddr}\n isInvalid={!!errors.tcpListenAddr}\n label=\"TCP监听地址\"\n placeholder=\"请输入TCP监听地址\"\n startContent={\n <div className=\"pointer-events-none flex items-center\">\n <span className=\"text-default-400 text-small\">\n TCP\n </span>\n </div>\n }\n value={form.tcpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n tcpListenAddr: e.target.value,\n }))\n }\n />\n\n <Input\n errorMessage={errors.udpListenAddr}\n isInvalid={!!errors.udpListenAddr}\n label=\"UDP监听地址\"\n placeholder=\"请输入UDP监听地址\"\n startContent={\n <div className=\"pointer-events-none flex items-center\">\n <span className=\"text-default-400 text-small\">\n UDP\n </span>\n </div>\n }\n value={form.udpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n udpListenAddr: e.target.value,\n }))\n }\n />\n </div>\n {/* 屏蔽协议 */}\n <div>\n <div className=\"text-sm font-medium text-default-700 mb-2\">\n 屏蔽协议\n </div>\n <div className=\"text-xs text-default-500 mb-2\">\n 开启开关以屏蔽对应协议\n </div>\n {protocolDisabled && (\n <Alert\n className=\"mb-2\"\n color=\"warning\"\n description={\n protocolDisabledReason || \"等待节点上线后再设置\"\n }\n variant=\"flat\"\n />\n )}\n <div\n className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-default-50 dark:bg-default-100 p-3 rounded-md border border-default-200 dark:border-default-100/30 ${protocolDisabled ? \"opacity-70\" : \"\"}`}\n >\n {/* HTTP tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <rect height=\"16\" rx=\"2\" width=\"20\" x=\"2\" y=\"4\" />\n <path d=\"M2 10h20\" />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n HTTP\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.http === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({\n ...prev,\n http: v ? 1 : 0,\n }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.http === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n\n {/* TLS tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M6 10V7a6 6 0 1 1 12 0v3\" />\n <rect\n height=\"10\"\n rx=\"2\"\n width=\"16\"\n x=\"4\"\n y=\"10\"\n />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n TLS\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.tls === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({ ...prev, tls: v ? 1 : 0 }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.tls === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n\n {/* SOCKS tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n <polyline points=\"7 10 12 15 17 10\" />\n <line x1=\"12\" x2=\"12\" y1=\"15\" y2=\"3\" />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n SOCKS\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.socks === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({\n ...prev,\n socks: v ? 1 : 0,\n }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.socks === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n </div>\n </div>\n\n <Alert\n color=\"danger\"\n description=\"请不要在出口节点执行屏蔽协议,否则可能影响转发;屏蔽协议仅需在入口节点执行。\"\n variant=\"flat\"\n />\n </div>\n </AccordionItem>\n </Accordion>\n\n <Alert\n className=\"mt-4\"\n color=\"primary\"\n description=\"服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。\"\n variant=\"flat\"\n />\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"flat\" onPress={() => setDialogVisible(false)}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isLoading={submitLoading}\n onPress={handleSubmit}\n >\n {submitLoading ? \"提交中...\" : \"确定\"}\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={deleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onOpenChange={setDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p>\n 确定要删除节点{\" \"}\n <strong>&quot;{nodeToDelete?.name}&quot;</strong> 吗?\n </p>\n <p className=\"text-small text-default-500\">\n 此操作不可恢复,请谨慎操作。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={deleteLoading}\n onPress={confirmDelete}\n >\n {deleteLoading ? \"删除中...\" : \"确认删除\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n <Modal\n backdrop=\"blur\"\n isOpen={installSelectorOpen}\n placement=\"center\"\n size=\"md\"\n onOpenChange={setInstallSelectorOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n 选择安装通道\n {installTargetNode ? ` - ${installTargetNode.name}` : \"\"}\n </h2>\n </ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <Select\n label=\"版本通道\"\n selectedKeys={[installChannel]}\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as ReleaseChannel;\n\n setInstallChannel(selected || \"stable\");\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 正式版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 测试版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button color=\"primary\" onPress={handleConfirmInstallCommand}>\n 生成命令\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 安装命令模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={installCommandModal}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setInstallCommandModal(false)}\n >\n <ModalContent>\n <ModalHeader>安装命令 - {currentNodeName}</ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <p className=\"text-sm text-default-600\">\n 请复制以下安装命令到服务器上执行:\n </p>\n <div className=\"relative\">\n <Textarea\n readOnly\n className=\"font-mono text-sm\"\n classNames={{\n input: \"font-mono text-sm\",\n }}\n maxRows={10}\n minRows={6}\n value={installCommand}\n variant=\"bordered\"\n />\n <Button\n className=\"absolute top-2 right-2\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleManualCopy}\n >\n 复制\n </Button>\n </div>\n <div className=\"text-xs text-default-500\">\n 💡 提示:如果复制按钮失效,请手动选择上方文本进行复制\n </div>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button\n variant=\"flat\"\n onPress={() => setInstallCommandModal(false)}\n >\n 关闭\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 版本选择升级模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={upgradeModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"md\"\n onOpenChange={setUpgradeModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n {upgradeTarget === \"batch\"\n ? `批量升级 (${selectedIds.size} 个节点)`\n : \"升级节点\"}\n </h2>\n </ModalHeader>\n <ModalBody>\n {releasesLoading ? (\n <div className=\"flex justify-center py-8\">\n <Spinner size=\"lg\" />\n </div>\n ) : (\n <div className=\"space-y-4\">\n <Select\n label=\"版本通道\"\n selectedKeys={[releaseChannel]}\n onSelectionChange={(keys) => {\n const selected =\n (Array.from(keys)[0] as ReleaseChannel) || \"stable\";\n\n setReleaseChannel(selected);\n setSelectedVersion(\"\");\n void loadReleasesByChannel(selected);\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 正式版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 测试版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n <Select\n label=\"选择版本\"\n placeholder=\"留空则使用当前通道最新版本\"\n selectedKeys={selectedVersion ? [selectedVersion] : []}\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as string;\n\n setSelectedVersion(selected || \"\");\n }}\n >\n {releases.map((r) => (\n <SelectItem key={r.version} textValue={r.version}>\n <div className=\"flex justify-between items-center\">\n <span>{r.version}</span>\n <span className=\"text-xs text-default-400\">\n {r.publishedAt\n ? new Date(r.publishedAt).toLocaleDateString()\n : \"\"}\n {r.channel === \"dev\" && (\n <Chip\n className=\"ml-1\"\n color=\"warning\"\n size=\"sm\"\n variant=\"flat\"\n >\n 测试\n </Chip>\n )}\n </span>\n </div>\n </SelectItem>\n ))}\n </Select>\n <p className=\"text-sm text-default-500\">\n {selectedVersion\n ? `将升级到版本 ${selectedVersion}`\n : `未选择版本,将自动使用最新${releaseChannel === \"stable\" ? \"正式\" : \"测试\"}版`}\n </p>\n </div>\n )}\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"warning\"\n isDisabled={releasesLoading}\n onPress={handleConfirmUpgrade}\n >\n 确认升级\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 批量删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={batchDeleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"md\"\n onOpenChange={setBatchDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p>\n 确定要删除选中的 <strong>{selectedIds.size}</strong>{\" \"}\n 个节点吗?\n </p>\n <p className=\"text-small text-default-500\">\n 此操作不可恢复,请谨慎操作。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={batchLoading}\n onPress={handleBatchDelete}\n >\n {batchLoading ? \"删除中...\" : \"确认删除\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n </AnimatedPage>\n );\n}\n",
"after": "import { useState, useEffect, useMemo, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n type DragEndEvent,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport {\n SortableContext,\n arrayMove,\n rectSortingStrategy,\n sortableKeyboardCoordinates,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nimport { SearchBar } from \"@/components/search-bar\";\nimport { AnimatedPage } from \"@/components/animated-page\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport {\n Modal,\n ModalContent,\n ModalHeader,\n ModalBody,\n ModalFooter,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport { Chip } from \"@/shadcn-bridge/heroui/chip\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Alert } from \"@/shadcn-bridge/heroui/alert\";\nimport { Progress } from \"@/shadcn-bridge/heroui/progress\";\nimport { Accordion, AccordionItem } from \"@/shadcn-bridge/heroui/accordion\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n createNode,\n getNodeList,\n updateNode,\n deleteNode,\n getNodeInstallCommand,\n updateNodeOrder,\n batchDeleteNodes,\n upgradeNode,\n batchUpgradeNodes,\n getNodeReleases,\n rollbackNode,\n type ReleaseChannel,\n} from \"@/api\";\nimport { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\nimport {\n getConnectionStatusMeta,\n getRemoteSyncErrorMessage,\n} from \"@/pages/node/display\";\nimport { tryCopyInstallCommand } from \"@/pages/node/install-command\";\nimport {\n getNodeRenewalSnapshot,\n formatNodeRenewalTime,\n getNodeRenewalCycleLabel,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";\nimport { buildNodeSystemInfo } from \"@/pages/node/system-info\";\nimport { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\nimport { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\nimport { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n\nconst NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n\ninterface Node {\n id: number;\n inx?: number;\n name: string;\n remark?: string;\n tags?: string;\n expiryTime?: number;\n renewalCycle?: NodeRenewalCycle;\n ip: string;\n serverIp: string;\n serverIpV4?: string;\n serverIpV6?: string;\n port: string;\n tcpListenAddr?: string;\n udpListenAddr?: string;\n extraIPs?: string;\n version?: string;\n http?: number; // 0 关 1 开\n tls?: number; // 0 关 1 开\n socks?: number; // 0 关 1 开\n status: number;\n isRemote?: number;\n remoteUrl?: string;\n syncError?: string;\n connectionStatus: \"online\" | \"offline\";\n systemInfo?: {\n cpuUsage: number;\n memoryUsage: number;\n uploadTraffic: number;\n downloadTraffic: number;\n uploadSpeed: number;\n downloadSpeed: number;\n uptime: number;\n } | null;\n copyLoading?: boolean;\n upgradeLoading?: boolean;\n rollbackLoading?: boolean;\n}\n\ninterface NodeForm {\n id: number | null;\n name: string;\n remark: string;\n tags: string;\n expiryTime: number;\n renewalCycle: NodeRenewalCycle;\n serverHost: string;\n serverIpV4: string;\n serverIpV6: string;\n port: string;\n tcpListenAddr: string;\n udpListenAddr: string;\n interfaceName: string;\n extraIPs: string;\n http: number; // 0 关 1 开\n tls: number; // 0 关 1 开\n socks: number; // 0 关 1 开\n}\n\nconst EXPIRING_SOON_DAYS = 7;\n\ntype NodeExpiryState = \"permanent\" | \"healthy\" | \"expiringSoon\" | \"expired\";\n\ntype NodeFilterMode = \"all\" | \"expiringSoon\" | \"expired\" | \"withExpiry\";\n\nconst getNodeReminderEnabled = (node: Node): boolean => {\n return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;\n};\n\nconst getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {\n const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);\n\n if (renewal.state === \"unset\") {\n return {\n state: \"permanent\" as NodeExpiryState,\n label: \"未设置续费周期\",\n tone: \"default\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 3,\n nextDueTime: undefined,\n };\n }\n\n if (renewal.state === \"expired\") {\n return {\n state: \"expired\" as NodeExpiryState,\n label: \"已过期\",\n tone: \"danger\" as const,\n accentClassName:\n \"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20\",\n bannerClassName:\n \"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300\",\n isHighlighted: true,\n sortWeight: 0,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n if (renewal.state === \"dueSoon\") {\n return {\n state: \"expiringSoon\" as NodeExpiryState,\n label: renewal.label,\n tone: \"warning\" as const,\n accentClassName:\n \"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20\",\n bannerClassName:\n \"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300\",\n isHighlighted: true,\n sortWeight: 1,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n return {\n state: \"healthy\" as NodeExpiryState,\n label: renewal.label,\n tone: \"success\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 2,\n nextDueTime: renewal.nextDueTime,\n };\n};\n\nconst normalizeNodeTags = (tags?: string): string[] => {\n if (!tags) return [];\n\n return tags\n .split(\",\")\n .map((tag) => tag.trim())\n .filter(Boolean);\n};\n\nconst mergeNodeRealtimeState = (\n incomingNode: Node,\n existingNode?: Node,\n): Node => {\n return {\n ...incomingNode,\n systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,\n copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,\n upgradeLoading:\n existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,\n rollbackLoading:\n existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,\n };\n};\n\nconst SortableItem = ({\n id,\n children,\n}: {\n id: number;\n children: (listeners: any) => any;\n}) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n };\n\n return (\n <div\n ref={setNodeRef}\n style={style}\n {...attributes}\n className=\"overflow-hidden h-full\"\n {...listeners}\n >\n {children(listeners)}\n </div>\n );\n};\n\nexport default function NodePage() {\n const [nodeList, setNodeList] = useState<Node[]>([]);\n const [nodeOrder, setNodeOrder] = useState<number[]>([]);\n const [loading, setLoading] = useState(false);\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"node-search-keyword\",\n \"\",\n );\n const [nodeFilterMode, setNodeFilterMode] =\n useLocalStorageState<NodeFilterMode>(\"node-expiry-filter-mode\", \"all\");\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [dialogVisible, setDialogVisible] = useState(false);\n const [dialogTitle, setDialogTitle] = useState(\"\");\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);\n const [protocolDisabled, setProtocolDisabled] = useState(false);\n const [protocolDisabledReason, setProtocolDisabledReason] = useState(\"\");\n const [form, setForm] = useState<NodeForm>({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n const [errors, setErrors] = useState<Record<string, string>>({});\n\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchLoading, setBatchLoading] = useState(false);\n\n // 安装命令相关状态\n const [installCommandModal, setInstallCommandModal] = useState(false);\n const [installCommand, setInstallCommand] = useState(\"\");\n const [currentNodeName, setCurrentNodeName] = useState(\"\");\n const [installSelectorOpen, setInstallSelectorOpen] = useState(false);\n const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);\n const [installChannel, setInstallChannel] =\n useState<ReleaseChannel>(\"stable\");\n\n // 升级相关状态\n const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);\n const [upgradeTarget, setUpgradeTarget] = useState<\"single\" | \"batch\">(\n \"single\",\n );\n const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(\n null,\n );\n const [releases, setReleases] = useState<\n Array<{\n version: string;\n name: string;\n publishedAt: string;\n prerelease: boolean;\n channel: ReleaseChannel;\n }>\n >([]);\n const [releasesLoading, setReleasesLoading] = useState(false);\n const [releaseChannel, setReleaseChannel] =\n useState<ReleaseChannel>(\"stable\");\n const [selectedVersion, setSelectedVersion] = useState(\"\");\n const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);\n const [upgradeProgress, setUpgradeProgress] = useState<\n Record<number, { stage: string; percent: number; message: string }>\n >({});\n\n const handleNodeOffline = useCallback((nodeId: number) => {\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"offline\" && node.systemInfo === null) {\n return node;\n }\n\n return { ...node, connectionStatus: \"offline\", systemInfo: null };\n }),\n );\n }, []);\n\n const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({\n delayMs: 3000,\n onNodeOffline: handleNodeOffline,\n });\n\n // 加载节点列表\n const loadNodes = useCallback(async (options?: { silent?: boolean }) => {\n const silent = options?.silent ?? false;\n\n if (!silent) {\n setLoading(true);\n }\n\n try {\n const res = await getNodeList();\n\n if (res.code === 0) {\n const nodesData: Node[] = (res.data || []).map((node: any) => ({\n ...node,\n inx: node.inx ?? 0,\n connectionStatus: node.syncError\n ? \"offline\"\n : node.status === 1\n ? \"online\"\n : \"offline\",\n syncError: node.syncError || undefined,\n systemInfo: null,\n copyLoading: false,\n }));\n\n setNodeList((prev) => {\n const previousById = new Map(prev.map((node) => [node.id, node]));\n\n return nodesData.map((node) =>\n mergeNodeRealtimeState(node, previousById.get(node.id)),\n );\n });\n\n // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序\n const hasDbOrdering = nodesData.some(\n (n) => n.inx !== undefined && n.inx !== 0,\n );\n\n if (hasDbOrdering) {\n const dbOrder = [...nodesData]\n .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n .map((n) => n.id);\n\n setNodeOrder(dbOrder);\n } else {\n setNodeOrder(\n loadStoredOrder(\n \"node-order\",\n nodesData.map((n) => n.id),\n ),\n );\n }\n } else {\n if (!silent) {\n toast.error(res.msg || \"加载节点列表失败\");\n }\n }\n } catch {\n if (!silent) {\n toast.error(\"网络错误,请重试\");\n }\n } finally {\n if (!silent) {\n setLoading(false);\n }\n }\n }, []);\n\n // 处理WebSocket消息\n const handleWebSocketMessage = (data: any) => {\n const { id, type, data: messageData } = data;\n const nodeId = Number(id);\n\n if (Number.isNaN(nodeId)) return;\n\n if (type === \"status\") {\n if (messageData === 1) {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"online\") return node;\n\n return { ...node, connectionStatus: \"online\" };\n }),\n );\n } else {\n // 离线事件做延迟处理,避免短抖动导致频繁闪烁\n scheduleNodeOffline(nodeId);\n }\n } else if (type === \"info\") {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id === nodeId) {\n const systemInfo = buildNodeSystemInfo(\n messageData,\n node.systemInfo,\n );\n\n if (!systemInfo) {\n return node;\n }\n\n return {\n ...node,\n connectionStatus: \"online\",\n systemInfo,\n };\n }\n\n return node;\n }),\n );\n } else if (type === \"upgrade_progress\") {\n try {\n const progressData =\n typeof messageData === \"string\"\n ? JSON.parse(messageData)\n : messageData;\n\n if (progressData?.data) {\n setUpgradeProgress((prev) => ({\n ...prev,\n [nodeId]: {\n stage: progressData.data.stage || \"\",\n percent: progressData.data.percent || 0,\n message: progressData.message || \"\",\n },\n }));\n }\n } catch {\n // ignore parse errors\n }\n }\n };\n\n const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n\n useEffect(() => {\n loadNodes();\n }, [loadNodes]);\n\n useEffect(() => {\n if (!usingPollingFallback) {\n return;\n }\n\n void loadNodes({ silent: true });\n\n const interval = window.setInterval(() => {\n void loadNodes({ silent: true });\n }, NODE_FALLBACK_REFRESH_INTERVAL_MS);\n\n return () => {\n window.clearInterval(interval);\n };\n }, [loadNodes, usingPollingFallback]);\n\n // 格式化速度\n const formatSpeed = (bytesPerSecond: number): string => {\n if (bytesPerSecond === 0) return \"0 B/s\";\n\n const k = 1024;\n const sizes = [\"B/s\", \"KB/s\", \"MB/s\", \"GB/s\", \"TB/s\"];\n const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));\n\n return (\n parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i]\n );\n };\n\n // 格式化开机时间\n const formatUptime = (seconds: number): string => {\n if (seconds === 0) return \"-\";\n\n const days = Math.floor(seconds / 86400);\n const hours = Math.floor((seconds % 86400) / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n\n if (days > 0) {\n return `${days}天${hours}小时`;\n } else if (hours > 0) {\n return `${hours}小时${minutes}分钟`;\n } else {\n return `${minutes}分钟`;\n }\n };\n\n // 格式化流量\n const formatTraffic = (bytes: number): string => {\n if (bytes === 0) return \"0 B\";\n\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n };\n\n // 获取进度条颜色\n const getProgressColor = (\n value: number,\n offline = false,\n ): \"default\" | \"primary\" | \"secondary\" | \"success\" | \"warning\" | \"danger\" => {\n if (offline) return \"default\";\n if (value <= 50) return \"success\";\n if (value <= 80) return \"warning\";\n\n return \"danger\";\n };\n\n // IPv4/IPv6 格式验证(仅用于判定地址族)\n const ipv4Regex =\n /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;\n const ipv6Regex =\n /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;\n\n const validateIpv4Literal = (ip: string): boolean =>\n ipv4Regex.test(ip.trim());\n const validateIpv6Literal = (ip: string): boolean =>\n ipv6Regex.test(ip.trim());\n\n // Hostname/domain validation (no scheme/port)\n const hostnameRegex =\n /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;\n const validateHostname = (host: string): boolean => {\n const v = host.trim();\n\n if (!v) return false;\n if (v === \"localhost\") return true;\n\n return hostnameRegex.test(v);\n };\n\n // 验证端口格式:支持 80,443,100-600\n const validatePort = (\n portStr: string,\n ): { valid: boolean; error?: string } => {\n if (!portStr || !portStr.trim()) {\n return { valid: false, error: \"请输入端口\" };\n }\n\n const trimmed = portStr.trim();\n const parts = trimmed\n .split(\",\")\n .map((p) => p.trim())\n .filter((p) => p);\n\n if (parts.length === 0) {\n return { valid: false, error: \"请输入有效的端口\" };\n }\n\n for (const part of parts) {\n // 检查是否是端口范围 (如 100-600)\n if (part.includes(\"-\")) {\n const range = part.split(\"-\").map((p) => p.trim());\n\n if (range.length !== 2) {\n return { valid: false, error: `端口范围格式错误: ${part}` };\n }\n\n const start = parseInt(range[0]);\n const end = parseInt(range[1]);\n\n if (isNaN(start) || isNaN(end)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (start < 1 || start > 65535 || end < 1 || end > 65535) {\n return {\n valid: false,\n error: `端口范围必须在 1-65535 之间: ${part}`,\n };\n }\n\n if (start >= end) {\n return { valid: false, error: `起始端口必须小于结束端口: ${part}` };\n }\n } else {\n // 单个端口\n const port = parseInt(part);\n\n if (isNaN(port)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (port < 1 || port > 65535) {\n return { valid: false, error: `端口必须在 1-65535 之间: ${part}` };\n }\n }\n }\n\n return { valid: true };\n };\n\n // 表单验证\n const validateForm = (): boolean => {\n const newErrors: Record<string, string> = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入节点名称\";\n } else if (form.name.trim().length < 2) {\n newErrors.name = \"节点名称长度至少2位\";\n } else if (form.name.trim().length > 50) {\n newErrors.name = \"节点名称长度不能超过50位\";\n }\n\n if (\n (form.renewalCycle && !form.expiryTime) ||\n (!form.renewalCycle && form.expiryTime)\n ) {\n newErrors.expiryTime = \"请同时设置续费周期和续费基准时间\";\n }\n\n const v4 = form.serverIpV4.trim();\n const v6 = form.serverIpV6.trim();\n const host = form.serverHost.trim();\n\n if (!v4 && !v6 && !host) {\n const msg = \"请至少填写一个 IPv4/IPv6 地址或域名\";\n\n newErrors.serverIpV4 = msg;\n newErrors.serverIpV6 = msg;\n newErrors.serverHost = msg;\n } else {\n if (v4 && !validateIpv4Literal(v4)) {\n newErrors.serverIpV4 = \"请输入有效的IPv4地址\";\n }\n if (v6 && !validateIpv6Literal(v6)) {\n newErrors.serverIpV6 = \"请输入有效的IPv6地址\";\n }\n if (host && !validateHostname(host)) {\n newErrors.serverHost = \"请输入有效的域名/主机名\";\n }\n }\n\n const portValidation = validatePort(form.port);\n\n if (!portValidation.valid) {\n newErrors.port = portValidation.error || \"端口格式错误\";\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增节点\n const handleAdd = () => {\n setDialogTitle(\"新增节点\");\n setIsEdit(false);\n setDialogVisible(true);\n resetForm();\n setProtocolDisabled(true);\n setProtocolDisabledReason(\"节点未在线,等待节点上线后再设置\");\n };\n\n // 编辑节点\n const handleEdit = (node: Node) => {\n setDialogTitle(\"编辑节点\");\n setIsEdit(true);\n\n const legacy = (node.serverIp || \"\").trim();\n const normalizedV4 =\n node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : \"\");\n const normalizedV6 =\n node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : \"\");\n const normalizedHost =\n !normalizedV4 && !normalizedV6 && legacy ? legacy : \"\";\n\n setForm({\n id: node.id,\n name: node.name,\n remark: node.remark || \"\",\n tags: node.tags || \"\",\n expiryTime: node.expiryTime || 0,\n renewalCycle: node.renewalCycle || \"\",\n serverHost: normalizedHost,\n serverIpV4: normalizedV4,\n serverIpV6: normalizedV6,\n port: node.port || \"1000-65535\",\n tcpListenAddr: node.tcpListenAddr || \"[::]\",\n udpListenAddr: node.udpListenAddr || \"[::]\",\n interfaceName: (node as any).interfaceName || \"\",\n extraIPs: node.extraIPs || \"\",\n http: typeof node.http === \"number\" ? node.http : 1,\n tls: typeof node.tls === \"number\" ? node.tls : 1,\n socks: typeof node.socks === \"number\" ? node.socks : 1,\n });\n const offline = node.connectionStatus !== \"online\";\n\n setProtocolDisabled(offline);\n setProtocolDisabledReason(\n offline ? \"节点未在线,等待节点上线后再设置\" : \"\",\n );\n setDialogVisible(true);\n };\n\n // 删除节点\n const handleDelete = (node: Node) => {\n setNodeToDelete(node);\n setDeleteModalOpen(true);\n };\n\n const confirmDelete = async () => {\n if (!nodeToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteNode(nodeToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id));\n setDeleteModalOpen(false);\n setNodeToDelete(null);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n const openInstallSelector = (node: Node) => {\n setInstallTargetNode(node);\n setInstallChannel(\"stable\");\n setInstallSelectorOpen(true);\n };\n\n // 复制安装命令\n const handleCopyInstallCommand = async (\n node: Node,\n channel: ReleaseChannel,\n ) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),\n );\n\n try {\n const res = await getNodeInstallCommand(node.id, channel);\n\n if (res.code === 0 && res.data) {\n const copied = await tryCopyInstallCommand(res.data);\n\n if (copied) {\n toast.success(\n `${channel === \"stable\" ? \"正式版\" : \"测试版\"}安装命令已复制到剪贴板`,\n );\n } else {\n setInstallCommand(res.data);\n setCurrentNodeName(node.name);\n setInstallCommandModal(true);\n }\n } else {\n toast.error(res.msg || \"获取安装命令失败\");\n }\n } catch {\n toast.error(\"获取安装命令失败\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)),\n );\n }\n };\n\n const handleConfirmInstallCommand = async () => {\n if (!installTargetNode) return;\n\n setInstallSelectorOpen(false);\n await handleCopyInstallCommand(installTargetNode, installChannel);\n };\n\n // 手动复制安装命令\n const handleManualCopy = async () => {\n try {\n await navigator.clipboard.writeText(installCommand);\n toast.success(\"安装命令已复制到剪贴板\");\n setInstallCommandModal(false);\n } catch {\n toast.error(\"复制失败,请手动选择文本复制\");\n }\n };\n\n const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {\n setReleasesLoading(true);\n try {\n const res = await getNodeReleases(channel);\n\n if (res.code === 0 && Array.isArray(res.data)) {\n setReleases(res.data);\n } else {\n toast.error(res.msg || \"获取版本列表失败\");\n }\n } catch {\n toast.error(\"获取版本列表失败\");\n } finally {\n setReleasesLoading(false);\n }\n }, []);\n\n // 打开版本选择弹窗\n const openUpgradeModal = async (\n target: \"single\" | \"batch\",\n nodeId?: number,\n ) => {\n const defaultChannel: ReleaseChannel = \"stable\";\n\n setUpgradeTarget(target);\n setUpgradeTargetNodeId(nodeId || null);\n setReleaseChannel(defaultChannel);\n setSelectedVersion(\"\");\n setUpgradeModalOpen(true);\n await loadReleasesByChannel(defaultChannel);\n };\n\n // 确认升级(从版本弹窗)\n const handleConfirmUpgrade = async () => {\n const version = selectedVersion || undefined;\n\n if (upgradeTarget === \"single\" && upgradeTargetNodeId) {\n setUpgradeModalOpen(false);\n // Find the node\n const node = nodeList.find((n) => n.id === upgradeTargetNodeId);\n\n if (!node) return;\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,\n ),\n );\n try {\n const res = await upgradeNode(\n upgradeTargetNodeId,\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`节点升级命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,\n ),\n );\n }\n } else if (upgradeTarget === \"batch\") {\n setBatchUpgradeLoading(true);\n setUpgradeModalOpen(false);\n try {\n const res = await batchUpgradeNodes(\n Array.from(selectedIds),\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);\n } else {\n toast.error(res.msg || \"批量升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchUpgradeLoading(false);\n }\n }\n };\n\n // 回退节点\n const handleRollbackNode = async (node: Node) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),\n );\n try {\n const res = await rollbackNode(node.id);\n\n if (res.code === 0) {\n toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"回退失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === node.id ? { ...n, rollbackLoading: false } : n,\n ),\n );\n }\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n\n try {\n const apiCall = isEdit ? updateNode : createNode;\n const { serverHost, ...rest } = form;\n const data = {\n ...rest,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n extraIPs: form.extraIPs,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n serverHost?.trim() ||\n \"\",\n };\n\n const res = await apiCall(data);\n\n if (res.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n setDialogVisible(false);\n\n if (isEdit) {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === form.id\n ? {\n ...n,\n name: form.name,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n form.serverHost?.trim() ||\n \"\",\n serverIpV4: form.serverIpV4,\n serverIpV6: form.serverIpV6,\n port: form.port,\n tcpListenAddr: form.tcpListenAddr,\n udpListenAddr: form.udpListenAddr,\n interfaceName: form.interfaceName,\n http: form.http,\n tls: form.tls,\n socks: form.socks,\n }\n : n,\n ),\n );\n } else {\n loadNodes();\n }\n } else {\n toast.error(res.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 重置表单\n const resetForm = () => {\n setForm({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n setErrors({});\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n if (!nodeOrder || nodeOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const oldIndex = nodeOrder.indexOf(activeId);\n const newIndex = nodeOrder.indexOf(overId);\n\n if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;\n\n const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);\n\n setNodeOrder(newOrder);\n\n saveOrder(\"node-order\", newOrder);\n\n // 持久化到数据库\n try {\n const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));\n const response = await updateNodeOrder({ nodes: nodesToUpdate });\n\n if (response.code === 0) {\n setNodeList((prev) =>\n prev.map((node) => {\n const updated = nodesToUpdate.find((n) => n.id === node.id);\n\n return updated ? { ...node, inx: updated.inx } : node;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n };\n\n // 批量操作处理函数\n const toggleSelectMode = () => {\n setSelectMode((prev) => {\n if (prev) {\n setSelectedIds(new Set());\n }\n\n return !prev;\n });\n };\n\n const toggleSelect = (id: number) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n };\n\n const selectAll = () => {\n setSelectedIds(new Set(sortedNodes.map((n) => n.id)));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n try {\n const res = await batchDeleteNodes(Array.from(selectedIds));\n\n if (res.code === 0) {\n toast.success(`成功删除 ${selectedIds.size} 个节点`);\n setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id)));\n setSelectedIds(new Set());\n setBatchDeleteModalOpen(false);\n setSelectMode(false);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchLoading(false);\n }\n };\n\n // 传感器配置\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n const nodeExpiryStats = useMemo(() => {\n return nodeList.reduce(\n (acc, node) => {\n const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);\n\n if (meta.state === \"expired\") acc.expired += 1;\n if (meta.state === \"expiringSoon\") acc.expiringSoon += 1;\n if (getNodeReminderEnabled(node)) {\n acc.withExpiry += 1;\n }\n\n return acc;\n },\n { expired: 0, expiringSoon: 0, withExpiry: 0 },\n );\n }, [nodeList]);\n\n // 根据排序顺序获取节点列表\n const sortedNodes = useMemo((): Node[] => {\n if (!nodeList || nodeList.length === 0) return [];\n\n let filteredNodes = nodeList;\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredNodes = filteredNodes.filter(\n (n) =>\n (n.name && n.name.toLowerCase().includes(lowerKeyword)) ||\n (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||\n (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n if (nodeFilterMode !== \"all\") {\n filteredNodes = filteredNodes.filter((node) => {\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n switch (nodeFilterMode) {\n case \"expiringSoon\":\n return expiryMeta.state === \"expiringSoon\";\n case \"expired\":\n return expiryMeta.state === \"expired\";\n case \"withExpiry\":\n return getNodeReminderEnabled(node);\n default:\n return true;\n }\n });\n }\n\n const sortedByDb = [...filteredNodes].sort((a, b) => {\n const expiryDiff =\n getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -\n getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;\n\n if (expiryDiff !== 0) {\n return expiryDiff;\n }\n\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n return aInx - bInx;\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n nodeOrder &&\n nodeOrder.length > 0 &&\n sortedByDb.every((n) => n.inx === undefined || n.inx === 0)\n ) {\n const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));\n const localSorted: Node[] = [];\n\n nodeOrder.forEach((id) => {\n const node = nodeMap.get(id);\n\n if (node) localSorted.push(node);\n });\n\n filteredNodes.forEach((node) => {\n if (!nodeOrder.includes(node.id)) {\n localSorted.push(node);\n }\n });\n\n return localSorted;\n }\n\n return sortedByDb;\n }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);\n\n const sortableNodeIds = useMemo(\n () => sortedNodes.map((n) => n.id),\n [sortedNodes],\n );\n\n return (\n <AnimatedPage className=\"px-3 lg:px-6 py-8\">\n <div className=\"flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3\">\n <div className=\"flex flex-1 flex-col gap-3 lg:flex-row lg:items-center\">\n <SearchBar\n isVisible={isSearchVisible}\n placeholder=\"搜索名称、IP、备注或标签\"\n value={searchKeyword}\n onChange={setSearchKeyword}\n onClose={() => setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n\n <Select\n className=\"w-full max-w-sm lg:w-56\"\n label=\"到期筛选\"\n selectedKeys={[nodeFilterMode]}\n size=\"sm\"\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as\n | NodeFilterMode\n | undefined;\n\n setNodeFilterMode(selected || \"all\");\n }}\n >\n <SelectItem key=\"all\" textValue=\"全部节点\">\n 全部节点\n </SelectItem>\n <SelectItem key=\"expiringSoon\" textValue=\"7天内到期\">\n 7天内续费 ({nodeExpiryStats.expiringSoon})\n </SelectItem>\n <SelectItem key=\"expired\" textValue=\"已过期\">\n 已逾期 ({nodeExpiryStats.expired})\n </SelectItem>\n <SelectItem key=\"withExpiry\" textValue=\"已设置到期时间\">\n 已启用续费提醒 ({nodeExpiryStats.withExpiry})\n </SelectItem>\n </Select>\n </div>\n\n <div className=\"min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x\">\n <div className=\"flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0\">\n {selectMode ? (\n <>\n <span className=\"text-sm text-default-600 shrink-0\">\n 已选 {selectedIds.size} 项\n </span>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={selectAll}\n >\n 全选\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={deselectAll}\n >\n 清空\n </Button>\n <Button\n color=\"warning\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchUpgradeLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => openUpgradeModal(\"batch\")}\n >\n 升级\n </Button>\n <Button\n color=\"danger\"\n isDisabled={selectedIds.size === 0}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setBatchDeleteModalOpen(true)}\n >\n 删除\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"solid\"\n onPress={toggleSelectMode}\n >\n 退出\n </Button>\n </>\n ) : (\n <>\n <Button\n className=\"bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45\"\n color=\"default\"\n size=\"sm\"\n variant=\"flat\"\n onPress={toggleSelectMode}\n >\n 批量\n </Button>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleAdd}\n >\n 新增\n </Button>\n </>\n )}\n </div>\n </div>\n </div>\n\n {!wsConnected && (\n <Alert\n className=\"mb-4\"\n color=\"warning\"\n description={\n wsConnecting\n ? \"监控连接中...\"\n : usingPollingFallback\n ? \"监控连接已断开,已切换为列表自动刷新兜底模式。\"\n : \"监控连接已断开,正在重连...\"\n }\n variant=\"flat\"\n />\n )}\n\n {/* 节点列表 */}\n {loading ? (\n <PageLoadingState message=\"正在加载...\" />\n ) : nodeList.length === 0 ? (\n <PageEmptyState\n className=\"h-64\"\n message=\"暂无节点配置,点击上方按钮开始创建\"\n />\n ) : (\n <DndContext sensors={sensors} onDragEnd={handleDragEnd}>\n <SortableContext\n items={sortableNodeIds}\n strategy={rectSortingStrategy}\n >\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4\">\n {sortedNodes.map((node) => {\n const isRemoteNode = node.isRemote === 1;\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n return (\n <SortableItem key={node.id} id={node.id}>\n {(listeners) => (\n <Card\n key={node.id}\n className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full flex flex-col ${expiryMeta.accentClassName}`}\n >\n <CardHeader className=\"pb-2 md:pb-2\">\n <div className=\"flex justify-between items-start w-full\">\n <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n {selectMode && (\n <Checkbox\n isSelected={selectedIds.has(node.id)}\n onValueChange={() => toggleSelect(node.id)}\n />\n )}\n <h3 className=\"font-semibold text-foreground truncate text-sm\">\n {node.name}\n </h3>\n </div>\n <div className=\"flex items-center gap-1.5 ml-2\">\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n <Chip\n className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n color={expiryMeta.tone}\n size=\"sm\"\n title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}\n variant=\"flat\"\n >\n {expiryMeta.label}\n </Chip>\n )}\n <div\n className=\"cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100\"\n {...listeners}\n style={{ touchAction: \"none\" }}\n title=\"拖拽排序\"\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n {isRemoteNode && (\n <Chip\n className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n 远程\n </Chip>\n )}\n {(() => {\n const connectionStatusMeta =\n getConnectionStatusMeta(\n node.connectionStatus,\n );\n\n return (\n <Chip\n className=\"text-[10px] h-5 px-1\"\n color={connectionStatusMeta.color}\n size=\"sm\"\n variant=\"flat\"\n >\n {connectionStatusMeta.text}\n </Chip>\n );\n })()}\n </div>\n </div>\n </CardHeader>\n\n <CardBody className=\"pt-0 pb-3 md:pt-0 md:pb-3 flex-1 flex flex-col\">\n {isRemoteNode && node.syncError && (\n <div className=\"mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs\">\n {getRemoteSyncErrorMessage(node.syncError)}\n </div>\n )}\n {expiryMeta.isHighlighted && (\n <div\n className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}\n >\n <span>节点到期提醒</span>\n <span>{expiryMeta.label}</span>\n </div>\n )}\n {/* 基础信息 */}\n <div className=\"space-y-2 mb-4\">\n {(node.remark?.trim() || node.tags?.trim()) && (\n <div className=\"space-y-1.5\">\n {node.remark?.trim() && (\n <div className=\"rounded-md border border-divider/80 bg-default-50/80 px-2.5 py-1.5\">\n <div\n className=\"text-xs leading-5 text-default-600 line-clamp-1 break-all\"\n title={node.remark.trim()}\n >\n {node.remark.trim()}\n </div>\n </div>\n )}\n {normalizeNodeTags(node.tags).length > 0 && (\n <div className=\"flex flex-wrap gap-1.5\">\n {normalizeNodeTags(node.tags).map((tag) => (\n <Chip\n key={`${node.id}-${tag}`}\n className=\"text-[10px] h-5 px-1\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n #{tag}\n </Chip>\n ))}\n </div>\n )}\n </div>\n )}\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && <div className=\"hidden\" />}\n <div className=\"flex justify-between items-center text-sm min-w-0\">\n <span className=\"text-default-600 flex-shrink-0\">\n IP\n </span>\n <div className=\"text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]\">\n {node.serverIpV4?.trim() ||\n node.serverIpV6?.trim() ? (\n <div className=\"space-y-0.5\">\n {node.serverIpV4?.trim() && (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIpV4.trim()}\n >\n {node.serverIpV4.trim()}\n </span>\n )}\n {node.serverIpV6?.trim() && (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIpV6.trim()}\n >\n {node.serverIpV6.trim()}\n </span>\n )}\n </div>\n ) : (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIp.trim()}\n >\n {node.serverIp.trim()}\n </span>\n )}\n </div>\n </div>\n {!isRemoteNode && (\n <>\n <div className=\"flex justify-between text-sm\">\n <span className=\"text-default-600\">版本</span>\n <span className=\"text-xs\">\n {node.version || \"未知\"}\n </span>\n </div>\n {upgradeProgress[node.id] &&\n upgradeProgress[node.id].percent < 100 && (\n <div className=\"mt-1\">\n <Progress\n showValueLabel\n aria-label=\"升级进度\"\n color=\"warning\"\n label={upgradeProgress[node.id].message}\n size=\"sm\"\n value={upgradeProgress[node.id].percent}\n />\n </div>\n )}\n <div className=\"flex justify-between text-sm\">\n <span className=\"text-default-600\">\n 开机时间\n </span>\n <span className=\"text-xs\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatUptime(node.systemInfo.uptime)\n : \"-\"}\n </span>\n </div>\n </>\n )}\n </div>\n\n {!isRemoteNode && (\n <>\n {/* 系统监控 */}\n <div className=\"space-y-3 mb-4\">\n <div className=\"grid grid-cols-2 gap-3\">\n <div>\n <div className=\"flex justify-between text-xs mb-1\">\n <span>CPU</span>\n <span className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.cpuUsage.toFixed(1)}%`\n : \"-\"}\n </span>\n </div>\n <Progress\n aria-label=\"CPU使用率\"\n color={getProgressColor(\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.cpuUsage\n : 0,\n node.connectionStatus !== \"online\",\n )}\n size=\"sm\"\n value={\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.cpuUsage\n : 0\n }\n />\n </div>\n <div>\n <div className=\"flex justify-between text-xs mb-1\">\n <span>内存</span>\n <span className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.memoryUsage.toFixed(1)}%`\n : \"-\"}\n </span>\n </div>\n <Progress\n aria-label=\"内存使用率\"\n color={getProgressColor(\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.memoryUsage\n : 0,\n node.connectionStatus !== \"online\",\n )}\n size=\"sm\"\n value={\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.memoryUsage\n : 0\n }\n />\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-2 text-xs\">\n <div className=\"text-center p-2 bg-default-50 dark:bg-default-100 rounded\">\n <div className=\"text-default-600 mb-0.5\">\n 上传\n </div>\n <div className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.uploadSpeed,\n )\n : \"-\"}\n </div>\n </div>\n <div className=\"text-center p-2 bg-default-50 dark:bg-default-100 rounded\">\n <div className=\"text-default-600 mb-0.5\">\n 下载\n </div>\n <div className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.downloadSpeed,\n )\n : \"-\"}\n </div>\n </div>\n </div>\n\n {/* 流量统计 */}\n <div className=\"grid grid-cols-2 gap-2 text-xs\">\n <div className=\"text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20\">\n <div className=\"text-primary-600 dark:text-primary-400 mb-0.5\">\n ↑ 上行流量\n </div>\n <div className=\"font-mono text-primary-700 dark:text-primary-300\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.uploadTraffic,\n )\n : \"-\"}\n </div>\n </div>\n <div className=\"text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20\">\n <div className=\"text-success-600 dark:text-success-400 mb-0.5\">\n ↓ 下行流量\n </div>\n <div className=\"font-mono text-success-700 dark:text-success-300\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.downloadTraffic,\n )\n : \"-\"}\n </div>\n </div>\n </div>\n </div>\n </>\n )}\n\n {/* 操作按钮 */}\n <div className=\"space-y-1.5 mt-auto\">\n {!isRemoteNode && (\n <div className=\"grid grid-cols-3 gap-1.5\">\n <Button\n className=\"min-h-8\"\n color=\"success\"\n isLoading={node.copyLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => openInstallSelector(node)}\n >\n 安装\n </Button>\n <Button\n className=\"min-h-8\"\n color=\"warning\"\n isDisabled={\n node.connectionStatus !== \"online\"\n }\n isLoading={node.upgradeLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() =>\n openUpgradeModal(\"single\", node.id)\n }\n >\n 升级\n </Button>\n <Button\n className=\"min-h-8\"\n color=\"secondary\"\n isDisabled={\n node.connectionStatus !== \"online\"\n }\n isLoading={node.rollbackLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleRollbackNode(node)}\n >\n 回退\n </Button>\n </div>\n )}\n <div\n className={`grid gap-1.5 ${isRemoteNode ? \"grid-cols-1\" : \"grid-cols-2\"}`}\n >\n {!isRemoteNode && (\n <Button\n className=\"min-h-8\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleEdit(node)}\n >\n 编辑\n </Button>\n )}\n <Button\n className=\"min-h-8\"\n color=\"danger\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleDelete(node)}\n >\n 删除\n </Button>\n </div>\n </div>\n </CardBody>\n </Card>\n )}\n </SortableItem>\n );\n })}\n </div>\n </SortableContext>\n </DndContext>\n )}\n\n {/* 新增/编辑节点对话框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={dialogVisible}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setDialogVisible(false)}\n >\n <ModalContent>\n <ModalHeader>{dialogTitle}</ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <Input\n errorMessage={errors.name}\n isInvalid={!!errors.name}\n label=\"节点名称\"\n placeholder=\"请输入节点名称\"\n value={form.name}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n <Textarea\n description=\"可记录供应商、用途、续费说明等补充信息\"\n label=\"备注\"\n maxRows={4}\n minRows={3}\n placeholder=\"例如: 搬瓦工年付,2026-12 续费,日本中转\"\n value={form.remark}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, remark: e.target.value }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n description=\"多个标签用逗号分隔,可用于搜索过滤\"\n label=\"标签\"\n placeholder=\"例如: 年付,日本中转,测试机\"\n value={form.tags}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, tags: e.target.value }))\n }\n />\n\n <Select\n label=\"续费周期\"\n placeholder=\"选择续费周期\"\n selectedKeys={form.renewalCycle ? [form.renewalCycle] : []}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as\n | NodeRenewalCycle\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n renewalCycle: selected || \"\",\n }));\n }}\n >\n <SelectItem key=\"month\" textValue=\"month\">\n 月付\n </SelectItem>\n <SelectItem key=\"quarter\" textValue=\"quarter\">\n 季付\n </SelectItem>\n <SelectItem key=\"year\" textValue=\"year\">\n 年付\n </SelectItem>\n </Select>\n </div>\n\n <Input\n description=\"填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费\"\n errorMessage={errors.expiryTime}\n isInvalid={!!errors.expiryTime}\n label=\"续费基准时间\"\n type=\"datetime-local\"\n value={\n form.expiryTime > 0\n ? new Date(form.expiryTime).toISOString().slice(0, 16)\n : \"\"\n }\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n expiryTime: e.target.value\n ? new Date(e.target.value).getTime()\n : 0,\n }))\n }\n />\n\n <Alert\n color=\"primary\"\n description=\"例如选择月付并填写 2026-03-01,系统会自动按每月同日推算下次续费时间。\"\n variant=\"flat\"\n />\n\n <Input\n description=\"可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名\"\n errorMessage={errors.serverHost}\n isInvalid={!!errors.serverHost}\n label=\"服务器域名/主机名\"\n placeholder=\"例如: node.example.com\"\n value={form.serverHost}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverHost: e.target.value }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n description=\"双栈节点组隧道时优先使用 IPv4\"\n errorMessage={errors.serverIpV4}\n isInvalid={!!errors.serverIpV4}\n label=\"服务器IPv4\"\n placeholder=\"例如: 203.0.113.10\"\n value={form.serverIpV4}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverIpV4: e.target.value }))\n }\n />\n\n <Input\n description=\"至少填写一个 IPv4/IPv6/域名\"\n errorMessage={errors.serverIpV6}\n isInvalid={!!errors.serverIpV6}\n label=\"服务器IPv6\"\n placeholder=\"例如: 2001:db8::10\"\n value={form.serverIpV6}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverIpV6: e.target.value }))\n }\n />\n </div>\n\n <Input\n classNames={{\n input: \"font-mono\",\n }}\n description=\"支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔\"\n errorMessage={errors.port}\n isInvalid={!!errors.port}\n label=\"可用端口\"\n placeholder=\"例如: 80,443,1000-65535\"\n value={form.port}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, port: e.target.value }))\n }\n />\n\n {/* 高级配置 */}\n <Accordion variant=\"bordered\">\n <AccordionItem\n key=\"advanced\"\n aria-label=\"高级配置\"\n title=\"高级配置\"\n >\n <div className=\"space-y-4 pb-2\">\n <Input\n description=\"用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行\"\n errorMessage={errors.interfaceName}\n isInvalid={!!errors.interfaceName}\n label=\"出口网卡名或IP\"\n placeholder=\"请输入出口网卡名或IP\"\n value={form.interfaceName}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n interfaceName: e.target.value,\n }))\n }\n />\n\n <Input\n description=\"多IP服务器可填写额外IP地址,逗号分隔\"\n label=\"额外IP地址\"\n placeholder=\"例如: 192.168.1.100, 10.0.0.5\"\n value={form.extraIPs}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n extraIPs: e.target.value,\n }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n errorMessage={errors.tcpListenAddr}\n isInvalid={!!errors.tcpListenAddr}\n label=\"TCP监听地址\"\n placeholder=\"请输入TCP监听地址\"\n startContent={\n <div className=\"pointer-events-none flex items-center\">\n <span className=\"text-default-400 text-small\">\n TCP\n </span>\n </div>\n }\n value={form.tcpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n tcpListenAddr: e.target.value,\n }))\n }\n />\n\n <Input\n errorMessage={errors.udpListenAddr}\n isInvalid={!!errors.udpListenAddr}\n label=\"UDP监听地址\"\n placeholder=\"请输入UDP监听地址\"\n startContent={\n <div className=\"pointer-events-none flex items-center\">\n <span className=\"text-default-400 text-small\">\n UDP\n </span>\n </div>\n }\n value={form.udpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n udpListenAddr: e.target.value,\n }))\n }\n />\n </div>\n {/* 屏蔽协议 */}\n <div>\n <div className=\"text-sm font-medium text-default-700 mb-2\">\n 屏蔽协议\n </div>\n <div className=\"text-xs text-default-500 mb-2\">\n 开启开关以屏蔽对应协议\n </div>\n {protocolDisabled && (\n <Alert\n className=\"mb-2\"\n color=\"warning\"\n description={\n protocolDisabledReason || \"等待节点上线后再设置\"\n }\n variant=\"flat\"\n />\n )}\n <div\n className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-default-50 dark:bg-default-100 p-3 rounded-md border border-default-200 dark:border-default-100/30 ${protocolDisabled ? \"opacity-70\" : \"\"}`}\n >\n {/* HTTP tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <rect height=\"16\" rx=\"2\" width=\"20\" x=\"2\" y=\"4\" />\n <path d=\"M2 10h20\" />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n HTTP\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.http === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({\n ...prev,\n http: v ? 1 : 0,\n }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.http === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n\n {/* TLS tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M6 10V7a6 6 0 1 1 12 0v3\" />\n <rect\n height=\"10\"\n rx=\"2\"\n width=\"16\"\n x=\"4\"\n y=\"10\"\n />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n TLS\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.tls === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({ ...prev, tls: v ? 1 : 0 }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.tls === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n\n {/* SOCKS tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n <polyline points=\"7 10 12 15 17 10\" />\n <line x1=\"12\" x2=\"12\" y1=\"15\" y2=\"3\" />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n SOCKS\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.socks === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({\n ...prev,\n socks: v ? 1 : 0,\n }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.socks === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n </div>\n </div>\n\n <Alert\n color=\"danger\"\n description=\"请不要在出口节点执行屏蔽协议,否则可能影响转发;屏蔽协议仅需在入口节点执行。\"\n variant=\"flat\"\n />\n </div>\n </AccordionItem>\n </Accordion>\n\n <Alert\n className=\"mt-4\"\n color=\"primary\"\n description=\"服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。\"\n variant=\"flat\"\n />\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"flat\" onPress={() => setDialogVisible(false)}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isLoading={submitLoading}\n onPress={handleSubmit}\n >\n {submitLoading ? \"提交中...\" : \"确定\"}\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={deleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onOpenChange={setDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p>\n 确定要删除节点{\" \"}\n <strong>&quot;{nodeToDelete?.name}&quot;</strong> 吗?\n </p>\n <p className=\"text-small text-default-500\">\n 此操作不可恢复,请谨慎操作。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={deleteLoading}\n onPress={confirmDelete}\n >\n {deleteLoading ? \"删除中...\" : \"确认删除\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n <Modal\n backdrop=\"blur\"\n isOpen={installSelectorOpen}\n placement=\"center\"\n size=\"md\"\n onOpenChange={setInstallSelectorOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n 选择安装通道\n {installTargetNode ? ` - ${installTargetNode.name}` : \"\"}\n </h2>\n </ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <Select\n label=\"版本通道\"\n selectedKeys={[installChannel]}\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as ReleaseChannel;\n\n setInstallChannel(selected || \"stable\");\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 正式版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 测试版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button color=\"primary\" onPress={handleConfirmInstallCommand}>\n 生成命令\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 安装命令模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={installCommandModal}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setInstallCommandModal(false)}\n >\n <ModalContent>\n <ModalHeader>安装命令 - {currentNodeName}</ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <p className=\"text-sm text-default-600\">\n 请复制以下安装命令到服务器上执行:\n </p>\n <div className=\"relative\">\n <Textarea\n readOnly\n className=\"font-mono text-sm\"\n classNames={{\n input: \"font-mono text-sm\",\n }}\n maxRows={10}\n minRows={6}\n value={installCommand}\n variant=\"bordered\"\n />\n <Button\n className=\"absolute top-2 right-2\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleManualCopy}\n >\n 复制\n </Button>\n </div>\n <div className=\"text-xs text-default-500\">\n 💡 提示:如果复制按钮失效,请手动选择上方文本进行复制\n </div>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button\n variant=\"flat\"\n onPress={() => setInstallCommandModal(false)}\n >\n 关闭\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 版本选择升级模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={upgradeModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"md\"\n onOpenChange={setUpgradeModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n {upgradeTarget === \"batch\"\n ? `批量升级 (${selectedIds.size} 个节点)`\n : \"升级节点\"}\n </h2>\n </ModalHeader>\n <ModalBody>\n {releasesLoading ? (\n <div className=\"flex justify-center py-8\">\n <Spinner size=\"lg\" />\n </div>\n ) : (\n <div className=\"space-y-4\">\n <Select\n label=\"版本通道\"\n selectedKeys={[releaseChannel]}\n onSelectionChange={(keys) => {\n const selected =\n (Array.from(keys)[0] as ReleaseChannel) || \"stable\";\n\n setReleaseChannel(selected);\n setSelectedVersion(\"\");\n void loadReleasesByChannel(selected);\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 正式版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 测试版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n <Select\n label=\"选择版本\"\n placeholder=\"留空则使用当前通道最新版本\"\n selectedKeys={selectedVersion ? [selectedVersion] : []}\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as string;\n\n setSelectedVersion(selected || \"\");\n }}\n >\n {releases.map((r) => (\n <SelectItem key={r.version} textValue={r.version}>\n <div className=\"flex justify-between items-center\">\n <span>{r.version}</span>\n <span className=\"text-xs text-default-400\">\n {r.publishedAt\n ? new Date(r.publishedAt).toLocaleDateString()\n : \"\"}\n {r.channel === \"dev\" && (\n <Chip\n className=\"ml-1\"\n color=\"warning\"\n size=\"sm\"\n variant=\"flat\"\n >\n 测试\n </Chip>\n )}\n </span>\n </div>\n </SelectItem>\n ))}\n </Select>\n <p className=\"text-sm text-default-500\">\n {selectedVersion\n ? `将升级到版本 ${selectedVersion}`\n : `未选择版本,将自动使用最新${releaseChannel === \"stable\" ? \"正式\" : \"测试\"}版`}\n </p>\n </div>\n )}\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"warning\"\n isDisabled={releasesLoading}\n onPress={handleConfirmUpgrade}\n >\n 确认升级\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 批量删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={batchDeleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"md\"\n onOpenChange={setBatchDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p>\n 确定要删除选中的 <strong>{selectedIds.size}</strong>{\" \"}\n 个节点吗?\n </p>\n <p className=\"text-small text-default-500\">\n 此操作不可恢复,请谨慎操作。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={batchLoading}\n onPress={handleBatchDelete}\n >\n {batchLoading ? \"删除中...\" : \"确认删除\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n </AnimatedPage>\n );\n}\n",
"additions": 2,
"deletions": 0,
"status": "modified"
}
]
},
"agent": "build",
"model": {
"providerID": "zai-coding-plan",
"modelID": "glm-5"
},
"id": "msg_cd08cf2e5001UoNixpQwGQiGYH",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "text",
"text": "编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送",
"id": "prt_cd08cf2e5002IWDBM1PgtTYwKH",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08cf2e5001UoNixpQwGQiGYH"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773025424208,
"completed": 1773025453357
},
"parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 25994,
"input": 1583,
"output": 155,
"reasoning": 0,
"cache": {
"read": 24256,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd08cf350001s54Kz845iXm86b",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"id": "prt_cd08d034f001eGHicZWBP7GlSv",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08cf350001s54Kz845iXm86b"
},
{
"type": "reasoning",
"text": "用户再次要求编译并推送 docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta,架构为 linux/amd64。这是一个重复的请求,我之前已经完成了这个任务。我应该直接执行这个命令。",
"time": {
"start": 1773025428304,
"end": 1773025433693
},
"id": "prt_cd08d0350001F5YITSsdbqW1RR",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08cf350001s54Kz845iXm86b"
},
{
"type": "tool",
"callID": "call_f420a784161e4f419746bdce",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .",
"description": "Build and push vite-frontend Docker image",
"timeout": 600000
},
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#5 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#5 DONE 0.0s\n\n#6 [internal] load build context\n#6 ...\n\n#7 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#7 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#7 DONE 0.1s\n\n#6 [internal] load build context\n#6 transferring context: 2.66MB 1.0s done\n#6 DONE 1.0s\n\n#8 [builder 3/6] COPY package*.json ./\n#8 CACHED\n\n#9 [builder 2/6] WORKDIR /app\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.6s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.419 \n#12 0.419 > flvx@0.0.0 build\n#12 0.419 > tsc && vite build\n#12 0.419 \n#12 13.72 src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n#12 13.72 src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n#12 ERROR: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 2\n------\n > [builder 6/6] RUN npm run build:\n0.419 \n0.419 > flvx@0.0.0 build\n0.419 > tsc && vite build\n0.419 \n13.72 src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n13.72 src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n------\nDockerfile:10\n--------------------\n 8 | \n 9 | COPY . .\n 10 | >>> RUN npm run build\n 11 | \n 12 | # 生产阶段\n--------------------\nERROR: failed to build: failed to solve: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 2\n",
"title": "Build and push vite-frontend Docker image",
"metadata": {
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#5 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#5 DONE 0.0s\n\n#6 [internal] load build context\n#6 ...\n\n#7 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#7 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#7 DONE 0.1s\n\n#6 [internal] load build context\n#6 transferring context: 2.66MB 1.0s done\n#6 DONE 1.0s\n\n#8 [builder 3/6] COPY package*.json ./\n#8 CACHED\n\n#9 [builder 2/6] WORKDIR /app\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.6s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.419 \n#12 0.419 > flvx@0.0.0 build\n#12 0.419 > tsc && vite build\n#12 0.419 \n#12 13.72 src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n#12 13.72 src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n#12 ERROR: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 2\n------\n > [builder 6/6] RUN npm run build:\n0.419 \n0.419 > flvx@0.0.0 build\n0.419 > tsc && vite build\n0.419 \n13.72 src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n13.72 src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n------\nDockerfile:10\n--------------------\n 8 | \n 9 | COPY . .\n 10 | >>> RUN npm run build\n 11 | \n 12 | # 生产阶段\n--------------------\nERROR: failed to build: failed to solve: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 2\n",
"exit": 1,
"description": "Build and push vite-frontend Docker image",
"truncated": false
},
"time": {
"start": 1773025433596,
"end": 1773025453099
}
},
"id": "prt_cd08d17f6001KnzMesDNd0bc0f",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08cf350001s54Kz845iXm86b"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"cost": 0,
"tokens": {
"total": 25994,
"input": 1583,
"output": 155,
"reasoning": 0,
"cache": {
"read": 24256,
"write": 0
}
},
"id": "prt_cd08d642e001iIQ80oQ1AYmeO0",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08cf350001s54Kz845iXm86b"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773025453359,
"completed": 1773025465366
},
"parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 27143,
"input": 1109,
"output": 242,
"reasoning": 0,
"cache": {
"read": 25792,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd08d652f001t28GCHihEM8wQb",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"id": "prt_cd08d76db001kzU2bRQpyR9qYh",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d652f001t28GCHihEM8wQb"
},
{
"type": "reasoning",
"text": "构建失败了,有 TypeScript 编译错误。我需要查看并修复这些错误:\n- src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n- src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n\n让我先查看这个文件,找到这些缺失的函数。",
"time": {
"start": 1773025457883,
"end": 1773025465123
},
"id": "prt_cd08d76db002VJeXVgRhSpZvXk",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d652f001t28GCHihEM8wQb"
},
{
"type": "text",
"text": "构建失败,TypeScript 错误。让我查看并修复:",
"time": {
"start": 1773025465124,
"end": 1773025465124
},
"id": "prt_cd08d8233001gUwstFOP9wLKdb",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d652f001t28GCHihEM8wQb"
},
{
"type": "tool",
"callID": "call_30ca1eb38ed149d39064005f",
"tool": "read",
"state": {
"status": "completed",
"input": {
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx",
"offset": 1460,
"limit": 30
},
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx</path>\n<type>file</type>\n<content>1460: </div>\n1461: <div className=\"flex items-center gap-1.5 ml-2\">\n1462: {node.expiryTime &&\n1463: node.expiryTime > 0 &&\n1464: node.renewalCycle && (\n1465: <Chip\n1466: className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n1467: color={expiryMeta.tone}\n1468: size=\"sm\"\n1469: title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}\n1470: variant=\"flat\"\n1471: >\n1472: {expiryMeta.label}\n1473: </Chip>\n1474: )}\n1475: <div\n1476: className=\"cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100\"\n1477: {...listeners}\n1478: style={{ touchAction: \"none\" }}\n1479: title=\"拖拽排序\"\n1480: >\n1481: <svg\n1482: aria-hidden=\"true\"\n1483: className=\"w-4 h-4\"\n1484: fill=\"currentColor\"\n1485: viewBox=\"0 0 20 20\"\n1486: >\n1487: <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n1488: </svg>\n1489: </div>\n\n(Showing lines 1460-1489 of 2562. Use offset=1490 to continue.)\n</content>",
"title": "vite-frontend/src/pages/node.tsx",
"metadata": {
"preview": " </div>\n <div className=\"flex items-center gap-1.5 ml-2\">\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n <Chip\n className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n color={expiryMeta.tone}\n size=\"sm\"\n title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}\n variant=\"flat\"\n >\n {expiryMeta.label}\n </Chip>\n )}\n <div\n className=\"cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100\"\n {...listeners}\n style={{ touchAction: \"none\" }}\n title=\"拖拽排序\"",
"truncated": true,
"loaded": []
},
"time": {
"start": 1773025463628,
"end": 1773025463638
}
},
"id": "prt_cd08d8d44001xuKPVr8dx0u2ah",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d652f001t28GCHihEM8wQb"
},
{
"type": "tool",
"callID": "call_94a4fd4e5a474d98a64964b1",
"tool": "grep",
"state": {
"status": "completed",
"input": {
"pattern": "formatNodeRenewalTime|getNodeRenewalCycleLabel",
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src"
},
"output": "Found 7 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx:\n Line 1469: title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard.tsx:\n Line 18: formatNodeRenewalTime,\n Line 19: getNodeRenewalCycleLabel,\n Line 168: {formatNodeRenewalTime(renewalSnapshot.nextDueTime)}\n Line 172: {getNodeRenewalCycleLabel(node.renewalCycle)}\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/renewal.ts:\n Line 34: export const getNodeRenewalCycleLabel = (cycle?: string): string => {\n Line 112: export const formatNodeRenewalTime = (timestamp?: number): string => {",
"title": "formatNodeRenewalTime|getNodeRenewalCycleLabel",
"metadata": {
"matches": 7,
"truncated": false
},
"time": {
"start": 1773025465051,
"end": 1773025465075
}
},
"id": "prt_cd08d92d100115XxWcwAxZnk7F",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d652f001t28GCHihEM8wQb"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"cost": 0,
"tokens": {
"total": 27143,
"input": 1109,
"output": 242,
"reasoning": 0,
"cache": {
"read": 25792,
"write": 0
}
},
"id": "prt_cd08d93260015nDMM3wGHB9q3r",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d652f001t28GCHihEM8wQb"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773025465369,
"completed": 1773025480260
},
"parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 28285,
"input": 1216,
"output": 189,
"reasoning": 0,
"cache": {
"read": 26880,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd08d9419001W1KMqd4fxHLjwr",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"id": "prt_cd08db8c9001HAo9TiY2OkrFUG",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d9419001W1KMqd4fxHLjwr"
},
{
"type": "reasoning",
"text": "现在我看到问题了。这些函数定义在 `src/pages/node/renewal.ts` 中,在 `dashboard.tsx` 中被导入了,但在 `node.tsx` 中没有导入。\n\n让我查看 node.tsx 的导入部分,看看是否需要添加这些导入。",
"time": {
"start": 1773025474762,
"end": 1773025480025
},
"id": "prt_cd08db8ca0013Q73bTEstsiLfx",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d9419001W1KMqd4fxHLjwr"
},
{
"type": "tool",
"callID": "call_6d4a266590e848ee98342331",
"tool": "grep",
"state": {
"status": "completed",
"input": {
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx",
"pattern": "^import.*renewal"
},
"output": "No files found",
"title": "^import.*renewal",
"metadata": {
"matches": 0,
"truncated": false
},
"time": {
"start": 1773025478488,
"end": 1773025478501
}
},
"id": "prt_cd08dc74f0015IGG02WAO3X5xP",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d9419001W1KMqd4fxHLjwr"
},
{
"type": "tool",
"callID": "call_dad61f576a1747c78910d119",
"tool": "read",
"state": {
"status": "completed",
"input": {
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx",
"limit": 50
},
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx</path>\n<type>file</type>\n<content>1: import { useState, useEffect, useMemo, useCallback } from \"react\";\n2: import toast from \"react-hot-toast\";\n3: import {\n4: DndContext,\n5: KeyboardSensor,\n6: MouseSensor,\n7: TouchSensor,\n8: type DragEndEvent,\n9: useSensor,\n10: useSensors,\n11: } from \"@dnd-kit/core\";\n12: import {\n13: SortableContext,\n14: arrayMove,\n15: rectSortingStrategy,\n16: sortableKeyboardCoordinates,\n17: useSortable,\n18: } from \"@dnd-kit/sortable\";\n19: import { CSS } from \"@dnd-kit/utilities\";\n20: \n21: import { SearchBar } from \"@/components/search-bar\";\n22: import { AnimatedPage } from \"@/components/animated-page\";\n23: import { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\n24: import { Button } from \"@/shadcn-bridge/heroui/button\";\n25: import { Input } from \"@/shadcn-bridge/heroui/input\";\n26: import { Textarea } from \"@/shadcn-bridge/heroui/input\";\n27: import {\n28: Modal,\n29: ModalContent,\n30: ModalHeader,\n31: ModalBody,\n32: ModalFooter,\n33: } from \"@/shadcn-bridge/heroui/modal\";\n34: import { Chip } from \"@/shadcn-bridge/heroui/chip\";\n35: import { Switch } from \"@/shadcn-bridge/heroui/switch\";\n36: import { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\n37: import { Alert } from \"@/shadcn-bridge/heroui/alert\";\n38: import { Progress } from \"@/shadcn-bridge/heroui/progress\";\n39: import { Accordion, AccordionItem } from \"@/shadcn-bridge/heroui/accordion\";\n40: import { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\n41: import { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\n42: import {\n43: createNode,\n44: getNodeList,\n45: updateNode,\n46: deleteNode,\n47: getNodeInstallCommand,\n48: updateNodeOrder,\n49: batchDeleteNodes,\n50: upgradeNode,\n\n(Showing lines 1-50 of 2562. Use offset=51 to continue.)\n</content>",
"title": "vite-frontend/src/pages/node.tsx",
"metadata": {
"preview": "import { useState, useEffect, useMemo, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n type DragEndEvent,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport {\n SortableContext,\n arrayMove,\n rectSortingStrategy,\n sortableKeyboardCoordinates,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n",
"truncated": true,
"loaded": []
},
"time": {
"start": 1773025479979,
"end": 1773025479993
}
},
"id": "prt_cd08dcd26001v98AsU5mk5vFIQ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d9419001W1KMqd4fxHLjwr"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"cost": 0,
"tokens": {
"total": 28285,
"input": 1216,
"output": 189,
"reasoning": 0,
"cache": {
"read": 26880,
"write": 0
}
},
"id": "prt_cd08dcd5b0016QnDhsaeG5207I",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08d9419001W1KMqd4fxHLjwr"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773025480262,
"completed": 1773025489386
},
"parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 28983,
"input": 854,
"output": 97,
"reasoning": 0,
"cache": {
"read": 28032,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd08dce46001PNlok5YsEpEpN3",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"id": "prt_cd08de304001hbv952WnFPc2qU",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08dce46001PNlok5YsEpEpN3"
},
{
"type": "reasoning",
"text": "我需要查看更多的 import 部分,看看 renewal 相关的函数是否已经从别处导入。让我继续看更多的导入部分。",
"time": {
"start": 1773025485572,
"end": 1773025489133
},
"id": "prt_cd08de304002nO5Vzi6wUyzjDU",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08dce46001PNlok5YsEpEpN3"
},
{
"type": "tool",
"callID": "call_96761b75543745dcadd29398",
"tool": "read",
"state": {
"status": "completed",
"input": {
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx",
"limit": 80,
"offset": 50
},
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx</path>\n<type>file</type>\n<content>50: upgradeNode,\n51: batchUpgradeNodes,\n52: getNodeReleases,\n53: rollbackNode,\n54: type ReleaseChannel,\n55: } from \"@/api\";\n56: import { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\n57: import {\n58: getConnectionStatusMeta,\n59: getRemoteSyncErrorMessage,\n60: } from \"@/pages/node/display\";\n61: import { tryCopyInstallCommand } from \"@/pages/node/install-command\";\n62: import {\n63: getNodeRenewalSnapshot,\n64: type NodeRenewalCycle,\n65: } from \"@/pages/node/renewal\";\n66: import { buildNodeSystemInfo } from \"@/pages/node/system-info\";\n67: import { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\n68: import { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\n69: import { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\n70: import { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n71: \n72: const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n73: \n74: interface Node {\n75: id: number;\n76: inx?: number;\n77: name: string;\n78: remark?: string;\n79: tags?: string;\n80: expiryTime?: number;\n81: renewalCycle?: NodeRenewalCycle;\n82: ip: string;\n83: serverIp: string;\n84: serverIpV4?: string;\n85: serverIpV6?: string;\n86: port: string;\n87: tcpListenAddr?: string;\n88: udpListenAddr?: string;\n89: extraIPs?: string;\n90: version?: string;\n91: http?: number; // 0 关 1 开\n92: tls?: number; // 0 关 1 开\n93: socks?: number; // 0 关 1 开\n94: status: number;\n95: isRemote?: number;\n96: remoteUrl?: string;\n97: syncError?: string;\n98: connectionStatus: \"online\" | \"offline\";\n99: systemInfo?: {\n100: cpuUsage: number;\n101: memoryUsage: number;\n102: uploadTraffic: number;\n103: downloadTraffic: number;\n104: uploadSpeed: number;\n105: downloadSpeed: number;\n106: uptime: number;\n107: } | null;\n108: copyLoading?: boolean;\n109: upgradeLoading?: boolean;\n110: rollbackLoading?: boolean;\n111: }\n112: \n113: interface NodeForm {\n114: id: number | null;\n115: name: string;\n116: remark: string;\n117: tags: string;\n118: expiryTime: number;\n119: renewalCycle: NodeRenewalCycle;\n120: serverHost: string;\n121: serverIpV4: string;\n122: serverIpV6: string;\n123: port: string;\n124: tcpListenAddr: string;\n125: udpListenAddr: string;\n126: interfaceName: string;\n127: extraIPs: string;\n128: http: number; // 0 关 1 开\n129: tls: number; // 0 关 1 开\n\n(Showing lines 50-129 of 2562. Use offset=130 to continue.)\n</content>",
"title": "vite-frontend/src/pages/node.tsx",
"metadata": {
"preview": " upgradeNode,\n batchUpgradeNodes,\n getNodeReleases,\n rollbackNode,\n type ReleaseChannel,\n} from \"@/api\";\nimport { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\nimport {\n getConnectionStatusMeta,\n getRemoteSyncErrorMessage,\n} from \"@/pages/node/display\";\nimport { tryCopyInstallCommand } from \"@/pages/node/install-command\";\nimport {\n getNodeRenewalSnapshot,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";\nimport { buildNodeSystemInfo } from \"@/pages/node/system-info\";\nimport { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\nimport { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";",
"truncated": true,
"loaded": []
},
"time": {
"start": 1773025489051,
"end": 1773025489057
}
},
"id": "prt_cd08df096001WGhezq15rE7jyG",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08dce46001PNlok5YsEpEpN3"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"cost": 0,
"tokens": {
"total": 28983,
"input": 854,
"output": 97,
"reasoning": 0,
"cache": {
"read": 28032,
"write": 0
}
},
"id": "prt_cd08df0ef001SBUPLu9UUbilho",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08dce46001PNlok5YsEpEpN3"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773025489388,
"completed": 1773025500253
},
"parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 30011,
"input": 934,
"output": 213,
"reasoning": 0,
"cache": {
"read": 28864,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd08df1ec0016p8ElQV7HboEkQ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"id": "prt_cd08e057b001QV4ndtT3PWa23R",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ"
},
{
"type": "reasoning",
"text": "好的,我看到了。在第 62-65 行,node.tsx 从 `@/pages/node/renewal` 导入了 `getNodeRenewalSnapshot` 和 `type NodeRenewalCycle`,但没有导入 `formatNodeRenewalTime` 和 `getNodeRenewalCycleLabel`。\n\n我需要修改这个导入,添加这两个函数。",
"time": {
"start": 1773025494395,
"end": 1773025499976
},
"id": "prt_cd08e057b002Di46cCubns5awm",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ"
},
{
"type": "tool",
"callID": "call_d4633a42dfa7434aa777f2cf",
"tool": "edit",
"state": {
"status": "completed",
"input": {
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx",
"oldString": "import {\n getNodeRenewalSnapshot,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";",
"newString": "import {\n getNodeRenewalSnapshot,\n formatNodeRenewalTime,\n getNodeRenewalCycleLabel,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";"
},
"output": "Edit applied successfully.",
"title": "vite-frontend/src/pages/node.tsx",
"metadata": {
"diagnostics": {
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/store/repo/repository.go": [
{
"range": {
"start": {
"line": 161,
"character": 13
},
"end": {
"line": 161,
"character": 24
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 356,
"character": 77
},
"end": {
"line": 356,
"character": 88
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 436,
"character": 74
},
"end": {
"line": 436,
"character": 85
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 573,
"character": 77
},
"end": {
"line": 573,
"character": 88
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 583,
"character": 77
},
"end": {
"line": 583,
"character": 88
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 596,
"character": 28
},
"end": {
"line": 596,
"character": 39
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 603,
"character": 28
},
"end": {
"line": 603,
"character": 39
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 611,
"character": 29
},
"end": {
"line": 611,
"character": 40
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 624,
"character": 47
},
"end": {
"line": 624,
"character": 58
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 632,
"character": 28
},
"end": {
"line": 632,
"character": 39
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 634,
"character": 35
},
"end": {
"line": 634,
"character": 46
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 658,
"character": 47
},
"end": {
"line": 658,
"character": 58
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 666,
"character": 28
},
"end": {
"line": 666,
"character": 39
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 668,
"character": 35
},
"end": {
"line": 668,
"character": 46
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 680,
"character": 53
},
"end": {
"line": 680,
"character": 64
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 688,
"character": 28
},
"end": {
"line": 688,
"character": 39
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 690,
"character": 21
},
"end": {
"line": 690,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 700,
"character": 50
},
"end": {
"line": 700,
"character": 61
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 733,
"character": 28
},
"end": {
"line": 733,
"character": 39
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 739,
"character": 21
},
"end": {
"line": 739,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 756,
"character": 75
},
"end": {
"line": 756,
"character": 86
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 782,
"character": 28
},
"end": {
"line": 782,
"character": 39
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 784,
"character": 21
},
"end": {
"line": 784,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 794,
"character": 64
},
"end": {
"line": 794,
"character": 75
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 815,
"character": 28
},
"end": {
"line": 815,
"character": 39
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 817,
"character": 21
},
"end": {
"line": 817,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 902,
"character": 10
},
"end": {
"line": 902,
"character": 23
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
},
"source": "stringsseq",
"message": "Ranging over SplitSeq is more efficient"
},
{
"range": {
"start": {
"line": 948,
"character": 49
},
"end": {
"line": 948,
"character": 60
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 958,
"character": 40
},
"end": {
"line": 958,
"character": 51
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 962,
"character": 31
},
"end": {
"line": 962,
"character": 42
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 968,
"character": 37
},
"end": {
"line": 968,
"character": 48
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 969,
"character": 37
},
"end": {
"line": 969,
"character": 48
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 970,
"character": 39
},
"end": {
"line": 970,
"character": 50
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 990,
"character": 47
},
"end": {
"line": 990,
"character": 58
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1007,
"character": 24
},
"end": {
"line": 1007,
"character": 35
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1024,
"character": 54
},
"end": {
"line": 1024,
"character": 65
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1030,
"character": 51
},
"end": {
"line": 1030,
"character": 62
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1034,
"character": 56
},
"end": {
"line": 1034,
"character": 67
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1048,
"character": 33
},
"end": {
"line": 1048,
"character": 44
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1061,
"character": 29
},
"end": {
"line": 1061,
"character": 40
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1072,
"character": 54
},
"end": {
"line": 1072,
"character": 65
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1080,
"character": 29
},
"end": {
"line": 1080,
"character": 40
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1086,
"character": 37
},
"end": {
"line": 1086,
"character": 48
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1095,
"character": 52
},
"end": {
"line": 1095,
"character": 63
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1103,
"character": 29
},
"end": {
"line": 1103,
"character": 40
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1109,
"character": 37
},
"end": {
"line": 1109,
"character": 48
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1118,
"character": 58
},
"end": {
"line": 1118,
"character": 69
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1141,
"character": 29
},
"end": {
"line": 1141,
"character": 40
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1143,
"character": 37
},
"end": {
"line": 1143,
"character": 48
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1215,
"character": 84
},
"end": {
"line": 1215,
"character": 95
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1281,
"character": 27
},
"end": {
"line": 1281,
"character": 38
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1297,
"character": 83
},
"end": {
"line": 1297,
"character": 94
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1321,
"character": 90
},
"end": {
"line": 1321,
"character": 101
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1335,
"character": 85
},
"end": {
"line": 1335,
"character": 96
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1555,
"character": 85
},
"end": {
"line": 1555,
"character": 96
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1572,
"character": 130
},
"end": {
"line": 1572,
"character": 141
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1593,
"character": 21
},
"end": {
"line": 1593,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2541,
"character": 23
},
"end": {
"line": 2541,
"character": 34
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2556,
"character": 23
},
"end": {
"line": 2556,
"character": 34
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2663,
"character": 36
},
"end": {
"line": 2663,
"character": 47
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2757,
"character": 27
},
"end": {
"line": 2757,
"character": 38
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2960,
"character": 16
},
"end": {
"line": 2960,
"character": 29
}
},
"severity": 4,
"code": "stringscut",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize#stringscut"
},
"source": "stringscut",
"message": "strings.Index can be simplified using strings.Cut"
},
{
"range": {
"start": {
"line": 3036,
"character": 38
},
"end": {
"line": 3036,
"character": 49
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3043,
"character": 38
},
"end": {
"line": 3043,
"character": 49
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3051,
"character": 36
},
"end": {
"line": 3051,
"character": 47
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
}
],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/store/repo/repository_migrate_test.go": [],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/store/model/model.go": [
{
"range": {
"start": {
"line": 234,
"character": 27
},
"end": {
"line": 234,
"character": 85
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#omitzero"
},
"source": "omitzero",
"message": "Omitempty has no effect on nested struct fields"
}
],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/store/repo/repository_mutations.go": [
{
"range": {
"start": {
"line": 79,
"character": 21
},
"end": {
"line": 79,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 97,
"character": 21
},
"end": {
"line": 97,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 114,
"character": 21
},
"end": {
"line": 114,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 157,
"character": 21
},
"end": {
"line": 157,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 164,
"character": 21
},
"end": {
"line": 164,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 173,
"character": 21
},
"end": {
"line": 173,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 199,
"character": 156
},
"end": {
"line": 199,
"character": 167
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 199,
"character": 317
},
"end": {
"line": 199,
"character": 328
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 246,
"character": 159
},
"end": {
"line": 246,
"character": 170
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 252,
"character": 21
},
"end": {
"line": 252,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 303,
"character": 21
},
"end": {
"line": 303,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 380,
"character": 21
},
"end": {
"line": 380,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 389,
"character": 21
},
"end": {
"line": 389,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 568,
"character": 107
},
"end": {
"line": 568,
"character": 118
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 574,
"character": 21
},
"end": {
"line": 574,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 610,
"character": 70
},
"end": {
"line": 610,
"character": 81
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 629,
"character": 62
},
"end": {
"line": 629,
"character": 73
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 635,
"character": 21
},
"end": {
"line": 635,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 657,
"character": 122
},
"end": {
"line": 657,
"character": 133
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 663,
"character": 21
},
"end": {
"line": 663,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 679,
"character": 21
},
"end": {
"line": 679,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 688,
"character": 21
},
"end": {
"line": 688,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 743,
"character": 149
},
"end": {
"line": 743,
"character": 160
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 749,
"character": 21
},
"end": {
"line": 749,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 807,
"character": 23
},
"end": {
"line": 807,
"character": 34
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 858,
"character": 23
},
"end": {
"line": 858,
"character": 34
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1200,
"character": 192
},
"end": {
"line": 1200,
"character": 203
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1249,
"character": 80
},
"end": {
"line": 1249,
"character": 91
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1258,
"character": 137
},
"end": {
"line": 1258,
"character": 148
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1286,
"character": 31
},
"end": {
"line": 1286,
"character": 42
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1312,
"character": 30
},
"end": {
"line": 1312,
"character": 41
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1355,
"character": 27
},
"end": {
"line": 1355,
"character": 38
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1384,
"character": 10
},
"end": {
"line": 1384,
"character": 23
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
},
"source": "stringsseq",
"message": "Ranging over SplitSeq is more efficient"
}
],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/mutations.go": [
{
"range": {
"start": {
"line": 34,
"character": 20
},
"end": {
"line": 34,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 88,
"character": 20
},
"end": {
"line": 88,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 215,
"character": 20
},
"end": {
"line": 215,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 240,
"character": 20
},
"end": {
"line": 240,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 292,
"character": 20
},
"end": {
"line": 292,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 463,
"character": 20
},
"end": {
"line": 463,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 635,
"character": 2
},
"end": {
"line": 635,
"character": 23
}
},
"severity": 3,
"code": "default",
"codeDescription": {
"href": "https://staticcheck.dev/docs/checks/#QF1003"
},
"source": "QF1003",
"message": "could use tagged switch on row.ChainType"
},
{
"range": {
"start": {
"line": 636,
"character": 67
},
"end": {
"line": 636,
"character": 78
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 638,
"character": 67
},
"end": {
"line": 638,
"character": 78
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 639,
"character": 68
},
"end": {
"line": 639,
"character": 79
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 641,
"character": 68
},
"end": {
"line": 641,
"character": 79
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 674,
"character": 20
},
"end": {
"line": 674,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 861,
"character": 46
},
"end": {
"line": 861,
"character": 57
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1011,
"character": 46
},
"end": {
"line": 1011,
"character": 57
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1015,
"character": 20
},
"end": {
"line": 1015,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1040,
"character": 18
},
"end": {
"line": 1040,
"character": 29
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1065,
"character": 20
},
"end": {
"line": 1065,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1131,
"character": 20
},
"end": {
"line": 1131,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1233,
"character": 20
},
"end": {
"line": 1233,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1377,
"character": 47
},
"end": {
"line": 1377,
"character": 58
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1529,
"character": 46
},
"end": {
"line": 1529,
"character": 57
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1560,
"character": 46
},
"end": {
"line": 1560,
"character": 57
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1591,
"character": 46
},
"end": {
"line": 1591,
"character": 57
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1618,
"character": 46
},
"end": {
"line": 1618,
"character": 57
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1714,
"character": 46
},
"end": {
"line": 1714,
"character": 57
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1718,
"character": 20
},
"end": {
"line": 1718,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1743,
"character": 20
},
"end": {
"line": 1743,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1926,
"character": 20
},
"end": {
"line": 1926,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 1945,
"character": 20
},
"end": {
"line": 1945,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2036,
"character": 71
},
"end": {
"line": 2036,
"character": 82
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2222,
"character": 55
},
"end": {
"line": 2222,
"character": 66
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2248,
"character": 46
},
"end": {
"line": 2248,
"character": 57
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2296,
"character": 20
},
"end": {
"line": 2296,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2637,
"character": 90
},
"end": {
"line": 2637,
"character": 101
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2648,
"character": 67
},
"end": {
"line": 2648,
"character": 78
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2667,
"character": 63
},
"end": {
"line": 2667,
"character": 74
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2678,
"character": 62
},
"end": {
"line": 2678,
"character": 73
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2699,
"character": 153
},
"end": {
"line": 2699,
"character": 164
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2707,
"character": 32
},
"end": {
"line": 2707,
"character": 43
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2722,
"character": 26
},
"end": {
"line": 2722,
"character": 37
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2726,
"character": 38
},
"end": {
"line": 2726,
"character": 49
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2728,
"character": 43
},
"end": {
"line": 2728,
"character": 54
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2732,
"character": 24
},
"end": {
"line": 2732,
"character": 35
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2739,
"character": 19
},
"end": {
"line": 2739,
"character": 30
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2741,
"character": 25
},
"end": {
"line": 2741,
"character": 36
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2752,
"character": 19
},
"end": {
"line": 2752,
"character": 30
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2754,
"character": 23
},
"end": {
"line": 2754,
"character": 34
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2758,
"character": 111
},
"end": {
"line": 2758,
"character": 122
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2763,
"character": 26
},
"end": {
"line": 2763,
"character": 37
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2767,
"character": 38
},
"end": {
"line": 2767,
"character": 49
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2769,
"character": 23
},
"end": {
"line": 2769,
"character": 34
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2773,
"character": 25
},
"end": {
"line": 2773,
"character": 36
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2778,
"character": 33
},
"end": {
"line": 2778,
"character": 44
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2781,
"character": 35
},
"end": {
"line": 2781,
"character": 46
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2783,
"character": 21
},
"end": {
"line": 2783,
"character": 32
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 2910,
"character": 84
},
"end": {
"line": 2910,
"character": 95
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3112,
"character": 10
},
"end": {
"line": 3112,
"character": 23
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
},
"source": "stringsseq",
"message": "Ranging over SplitSeq is more efficient"
},
{
"range": {
"start": {
"line": 3221,
"character": 50
},
"end": {
"line": 3221,
"character": 61
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3398,
"character": 18
},
"end": {
"line": 3398,
"character": 29
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3398,
"character": 33
},
"end": {
"line": 3398,
"character": 44
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3402,
"character": 20
},
"end": {
"line": 3402,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3408,
"character": 18
},
"end": {
"line": 3408,
"character": 29
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3408,
"character": 44
},
"end": {
"line": 3408,
"character": 55
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3413,
"character": 26
},
"end": {
"line": 3413,
"character": 37
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3415,
"character": 29
},
"end": {
"line": 3415,
"character": 40
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3422,
"character": 16
},
"end": {
"line": 3422,
"character": 27
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3441,
"character": 13
},
"end": {
"line": 3441,
"character": 24
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3453,
"character": 15
},
"end": {
"line": 3453,
"character": 26
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3465,
"character": 15
},
"end": {
"line": 3465,
"character": 26
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3477,
"character": 23
},
"end": {
"line": 3477,
"character": 34
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3498,
"character": 20
},
"end": {
"line": 3498,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3516,
"character": 20
},
"end": {
"line": 3516,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3533,
"character": 1
},
"end": {
"line": 3533,
"character": 11
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#slicessort"
},
"source": "slicessort",
"message": "sort.Slice can be modernized using slices.Sort"
},
{
"range": {
"start": {
"line": 3537,
"character": 28
},
"end": {
"line": 3537,
"character": 39
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3544,
"character": 32
},
"end": {
"line": 3544,
"character": 43
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3560,
"character": 27
},
"end": {
"line": 3560,
"character": 38
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3582,
"character": 20
},
"end": {
"line": 3582,
"character": 31
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
},
"source": "any",
"message": "interface{} can be replaced by any"
},
{
"range": {
"start": {
"line": 3634,
"character": 10
},
"end": {
"line": 3634,
"character": 23
}
},
"severity": 4,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
},
"source": "stringsseq",
"message": "Ranging over SplitSeq is more efficient"
}
],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/control_plane_test.go": [
{
"range": {
"start": {
"line": 204,
"character": 19
},
"end": {
"line": 204,
"character": 19
}
},
"severity": 3,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/unusedwrite"
},
"source": "unusedwrite",
"message": "unused write to field repo"
},
{
"range": {
"start": {
"line": 205,
"character": 23
},
"end": {
"line": 205,
"character": 23
}
},
"severity": 3,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/unusedwrite"
},
"source": "unusedwrite",
"message": "unused write to field ID"
},
{
"range": {
"start": {
"line": 205,
"character": 32
},
"end": {
"line": 205,
"character": 32
}
},
"severity": 3,
"code": "default",
"codeDescription": {
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/unusedwrite"
},
"source": "unusedwrite",
"message": "unused write to field Name"
}
],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/app/app.go": [],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/cmd/paneld/main.go": [],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/jobs.go": [],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/jobs_renewal_test.go": [],
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/jobs_test.go": []
},
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx\n@@ -60,8 +60,10 @@\n } from \"@/pages/node/display\";\n import { tryCopyInstallCommand } from \"@/pages/node/install-command\";\n import {\n getNodeRenewalSnapshot,\n+ formatNodeRenewalTime,\n+ getNodeRenewalCycleLabel,\n type NodeRenewalCycle,\n } from \"@/pages/node/renewal\";\n import { buildNodeSystemInfo } from \"@/pages/node/system-info\";\n import { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\n",
"filediff": {
"file": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx",
"before": "import { useState, useEffect, useMemo, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n type DragEndEvent,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport {\n SortableContext,\n arrayMove,\n rectSortingStrategy,\n sortableKeyboardCoordinates,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nimport { SearchBar } from \"@/components/search-bar\";\nimport { AnimatedPage } from \"@/components/animated-page\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport {\n Modal,\n ModalContent,\n ModalHeader,\n ModalBody,\n ModalFooter,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport { Chip } from \"@/shadcn-bridge/heroui/chip\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Alert } from \"@/shadcn-bridge/heroui/alert\";\nimport { Progress } from \"@/shadcn-bridge/heroui/progress\";\nimport { Accordion, AccordionItem } from \"@/shadcn-bridge/heroui/accordion\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n createNode,\n getNodeList,\n updateNode,\n deleteNode,\n getNodeInstallCommand,\n updateNodeOrder,\n batchDeleteNodes,\n upgradeNode,\n batchUpgradeNodes,\n getNodeReleases,\n rollbackNode,\n type ReleaseChannel,\n} from \"@/api\";\nimport { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\nimport {\n getConnectionStatusMeta,\n getRemoteSyncErrorMessage,\n} from \"@/pages/node/display\";\nimport { tryCopyInstallCommand } from \"@/pages/node/install-command\";\nimport {\n getNodeRenewalSnapshot,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";\nimport { buildNodeSystemInfo } from \"@/pages/node/system-info\";\nimport { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\nimport { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\nimport { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n\nconst NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n\ninterface Node {\n id: number;\n inx?: number;\n name: string;\n remark?: string;\n tags?: string;\n expiryTime?: number;\n renewalCycle?: NodeRenewalCycle;\n ip: string;\n serverIp: string;\n serverIpV4?: string;\n serverIpV6?: string;\n port: string;\n tcpListenAddr?: string;\n udpListenAddr?: string;\n extraIPs?: string;\n version?: string;\n http?: number; // 0 关 1 开\n tls?: number; // 0 关 1 开\n socks?: number; // 0 关 1 开\n status: number;\n isRemote?: number;\n remoteUrl?: string;\n syncError?: string;\n connectionStatus: \"online\" | \"offline\";\n systemInfo?: {\n cpuUsage: number;\n memoryUsage: number;\n uploadTraffic: number;\n downloadTraffic: number;\n uploadSpeed: number;\n downloadSpeed: number;\n uptime: number;\n } | null;\n copyLoading?: boolean;\n upgradeLoading?: boolean;\n rollbackLoading?: boolean;\n}\n\ninterface NodeForm {\n id: number | null;\n name: string;\n remark: string;\n tags: string;\n expiryTime: number;\n renewalCycle: NodeRenewalCycle;\n serverHost: string;\n serverIpV4: string;\n serverIpV6: string;\n port: string;\n tcpListenAddr: string;\n udpListenAddr: string;\n interfaceName: string;\n extraIPs: string;\n http: number; // 0 关 1 开\n tls: number; // 0 关 1 开\n socks: number; // 0 关 1 开\n}\n\nconst EXPIRING_SOON_DAYS = 7;\n\ntype NodeExpiryState = \"permanent\" | \"healthy\" | \"expiringSoon\" | \"expired\";\n\ntype NodeFilterMode = \"all\" | \"expiringSoon\" | \"expired\" | \"withExpiry\";\n\nconst getNodeReminderEnabled = (node: Node): boolean => {\n return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;\n};\n\nconst getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {\n const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);\n\n if (renewal.state === \"unset\") {\n return {\n state: \"permanent\" as NodeExpiryState,\n label: \"未设置续费周期\",\n tone: \"default\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 3,\n nextDueTime: undefined,\n };\n }\n\n if (renewal.state === \"expired\") {\n return {\n state: \"expired\" as NodeExpiryState,\n label: \"已过期\",\n tone: \"danger\" as const,\n accentClassName:\n \"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20\",\n bannerClassName:\n \"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300\",\n isHighlighted: true,\n sortWeight: 0,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n if (renewal.state === \"dueSoon\") {\n return {\n state: \"expiringSoon\" as NodeExpiryState,\n label: renewal.label,\n tone: \"warning\" as const,\n accentClassName:\n \"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20\",\n bannerClassName:\n \"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300\",\n isHighlighted: true,\n sortWeight: 1,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n return {\n state: \"healthy\" as NodeExpiryState,\n label: renewal.label,\n tone: \"success\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 2,\n nextDueTime: renewal.nextDueTime,\n };\n};\n\nconst normalizeNodeTags = (tags?: string): string[] => {\n if (!tags) return [];\n\n return tags\n .split(\",\")\n .map((tag) => tag.trim())\n .filter(Boolean);\n};\n\nconst mergeNodeRealtimeState = (\n incomingNode: Node,\n existingNode?: Node,\n): Node => {\n return {\n ...incomingNode,\n systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,\n copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,\n upgradeLoading:\n existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,\n rollbackLoading:\n existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,\n };\n};\n\nconst SortableItem = ({\n id,\n children,\n}: {\n id: number;\n children: (listeners: any) => any;\n}) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n };\n\n return (\n <div\n ref={setNodeRef}\n style={style}\n {...attributes}\n className=\"overflow-hidden h-full\"\n {...listeners}\n >\n {children(listeners)}\n </div>\n );\n};\n\nexport default function NodePage() {\n const [nodeList, setNodeList] = useState<Node[]>([]);\n const [nodeOrder, setNodeOrder] = useState<number[]>([]);\n const [loading, setLoading] = useState(false);\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"node-search-keyword\",\n \"\",\n );\n const [nodeFilterMode, setNodeFilterMode] =\n useLocalStorageState<NodeFilterMode>(\"node-expiry-filter-mode\", \"all\");\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [dialogVisible, setDialogVisible] = useState(false);\n const [dialogTitle, setDialogTitle] = useState(\"\");\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);\n const [protocolDisabled, setProtocolDisabled] = useState(false);\n const [protocolDisabledReason, setProtocolDisabledReason] = useState(\"\");\n const [form, setForm] = useState<NodeForm>({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n const [errors, setErrors] = useState<Record<string, string>>({});\n\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchLoading, setBatchLoading] = useState(false);\n\n // 安装命令相关状态\n const [installCommandModal, setInstallCommandModal] = useState(false);\n const [installCommand, setInstallCommand] = useState(\"\");\n const [currentNodeName, setCurrentNodeName] = useState(\"\");\n const [installSelectorOpen, setInstallSelectorOpen] = useState(false);\n const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);\n const [installChannel, setInstallChannel] =\n useState<ReleaseChannel>(\"stable\");\n\n // 升级相关状态\n const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);\n const [upgradeTarget, setUpgradeTarget] = useState<\"single\" | \"batch\">(\n \"single\",\n );\n const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(\n null,\n );\n const [releases, setReleases] = useState<\n Array<{\n version: string;\n name: string;\n publishedAt: string;\n prerelease: boolean;\n channel: ReleaseChannel;\n }>\n >([]);\n const [releasesLoading, setReleasesLoading] = useState(false);\n const [releaseChannel, setReleaseChannel] =\n useState<ReleaseChannel>(\"stable\");\n const [selectedVersion, setSelectedVersion] = useState(\"\");\n const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);\n const [upgradeProgress, setUpgradeProgress] = useState<\n Record<number, { stage: string; percent: number; message: string }>\n >({});\n\n const handleNodeOffline = useCallback((nodeId: number) => {\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"offline\" && node.systemInfo === null) {\n return node;\n }\n\n return { ...node, connectionStatus: \"offline\", systemInfo: null };\n }),\n );\n }, []);\n\n const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({\n delayMs: 3000,\n onNodeOffline: handleNodeOffline,\n });\n\n // 加载节点列表\n const loadNodes = useCallback(async (options?: { silent?: boolean }) => {\n const silent = options?.silent ?? false;\n\n if (!silent) {\n setLoading(true);\n }\n\n try {\n const res = await getNodeList();\n\n if (res.code === 0) {\n const nodesData: Node[] = (res.data || []).map((node: any) => ({\n ...node,\n inx: node.inx ?? 0,\n connectionStatus: node.syncError\n ? \"offline\"\n : node.status === 1\n ? \"online\"\n : \"offline\",\n syncError: node.syncError || undefined,\n systemInfo: null,\n copyLoading: false,\n }));\n\n setNodeList((prev) => {\n const previousById = new Map(prev.map((node) => [node.id, node]));\n\n return nodesData.map((node) =>\n mergeNodeRealtimeState(node, previousById.get(node.id)),\n );\n });\n\n // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序\n const hasDbOrdering = nodesData.some(\n (n) => n.inx !== undefined && n.inx !== 0,\n );\n\n if (hasDbOrdering) {\n const dbOrder = [...nodesData]\n .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n .map((n) => n.id);\n\n setNodeOrder(dbOrder);\n } else {\n setNodeOrder(\n loadStoredOrder(\n \"node-order\",\n nodesData.map((n) => n.id),\n ),\n );\n }\n } else {\n if (!silent) {\n toast.error(res.msg || \"加载节点列表失败\");\n }\n }\n } catch {\n if (!silent) {\n toast.error(\"网络错误,请重试\");\n }\n } finally {\n if (!silent) {\n setLoading(false);\n }\n }\n }, []);\n\n // 处理WebSocket消息\n const handleWebSocketMessage = (data: any) => {\n const { id, type, data: messageData } = data;\n const nodeId = Number(id);\n\n if (Number.isNaN(nodeId)) return;\n\n if (type === \"status\") {\n if (messageData === 1) {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"online\") return node;\n\n return { ...node, connectionStatus: \"online\" };\n }),\n );\n } else {\n // 离线事件做延迟处理,避免短抖动导致频繁闪烁\n scheduleNodeOffline(nodeId);\n }\n } else if (type === \"info\") {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id === nodeId) {\n const systemInfo = buildNodeSystemInfo(\n messageData,\n node.systemInfo,\n );\n\n if (!systemInfo) {\n return node;\n }\n\n return {\n ...node,\n connectionStatus: \"online\",\n systemInfo,\n };\n }\n\n return node;\n }),\n );\n } else if (type === \"upgrade_progress\") {\n try {\n const progressData =\n typeof messageData === \"string\"\n ? JSON.parse(messageData)\n : messageData;\n\n if (progressData?.data) {\n setUpgradeProgress((prev) => ({\n ...prev,\n [nodeId]: {\n stage: progressData.data.stage || \"\",\n percent: progressData.data.percent || 0,\n message: progressData.message || \"\",\n },\n }));\n }\n } catch {\n // ignore parse errors\n }\n }\n };\n\n const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n\n useEffect(() => {\n loadNodes();\n }, [loadNodes]);\n\n useEffect(() => {\n if (!usingPollingFallback) {\n return;\n }\n\n void loadNodes({ silent: true });\n\n const interval = window.setInterval(() => {\n void loadNodes({ silent: true });\n }, NODE_FALLBACK_REFRESH_INTERVAL_MS);\n\n return () => {\n window.clearInterval(interval);\n };\n }, [loadNodes, usingPollingFallback]);\n\n // 格式化速度\n const formatSpeed = (bytesPerSecond: number): string => {\n if (bytesPerSecond === 0) return \"0 B/s\";\n\n const k = 1024;\n const sizes = [\"B/s\", \"KB/s\", \"MB/s\", \"GB/s\", \"TB/s\"];\n const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));\n\n return (\n parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i]\n );\n };\n\n // 格式化开机时间\n const formatUptime = (seconds: number): string => {\n if (seconds === 0) return \"-\";\n\n const days = Math.floor(seconds / 86400);\n const hours = Math.floor((seconds % 86400) / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n\n if (days > 0) {\n return `${days}天${hours}小时`;\n } else if (hours > 0) {\n return `${hours}小时${minutes}分钟`;\n } else {\n return `${minutes}分钟`;\n }\n };\n\n // 格式化流量\n const formatTraffic = (bytes: number): string => {\n if (bytes === 0) return \"0 B\";\n\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n };\n\n // 获取进度条颜色\n const getProgressColor = (\n value: number,\n offline = false,\n ): \"default\" | \"primary\" | \"secondary\" | \"success\" | \"warning\" | \"danger\" => {\n if (offline) return \"default\";\n if (value <= 50) return \"success\";\n if (value <= 80) return \"warning\";\n\n return \"danger\";\n };\n\n // IPv4/IPv6 格式验证(仅用于判定地址族)\n const ipv4Regex =\n /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;\n const ipv6Regex =\n /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;\n\n const validateIpv4Literal = (ip: string): boolean =>\n ipv4Regex.test(ip.trim());\n const validateIpv6Literal = (ip: string): boolean =>\n ipv6Regex.test(ip.trim());\n\n // Hostname/domain validation (no scheme/port)\n const hostnameRegex =\n /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;\n const validateHostname = (host: string): boolean => {\n const v = host.trim();\n\n if (!v) return false;\n if (v === \"localhost\") return true;\n\n return hostnameRegex.test(v);\n };\n\n // 验证端口格式:支持 80,443,100-600\n const validatePort = (\n portStr: string,\n ): { valid: boolean; error?: string } => {\n if (!portStr || !portStr.trim()) {\n return { valid: false, error: \"请输入端口\" };\n }\n\n const trimmed = portStr.trim();\n const parts = trimmed\n .split(\",\")\n .map((p) => p.trim())\n .filter((p) => p);\n\n if (parts.length === 0) {\n return { valid: false, error: \"请输入有效的端口\" };\n }\n\n for (const part of parts) {\n // 检查是否是端口范围 (如 100-600)\n if (part.includes(\"-\")) {\n const range = part.split(\"-\").map((p) => p.trim());\n\n if (range.length !== 2) {\n return { valid: false, error: `端口范围格式错误: ${part}` };\n }\n\n const start = parseInt(range[0]);\n const end = parseInt(range[1]);\n\n if (isNaN(start) || isNaN(end)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (start < 1 || start > 65535 || end < 1 || end > 65535) {\n return {\n valid: false,\n error: `端口范围必须在 1-65535 之间: ${part}`,\n };\n }\n\n if (start >= end) {\n return { valid: false, error: `起始端口必须小于结束端口: ${part}` };\n }\n } else {\n // 单个端口\n const port = parseInt(part);\n\n if (isNaN(port)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (port < 1 || port > 65535) {\n return { valid: false, error: `端口必须在 1-65535 之间: ${part}` };\n }\n }\n }\n\n return { valid: true };\n };\n\n // 表单验证\n const validateForm = (): boolean => {\n const newErrors: Record<string, string> = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入节点名称\";\n } else if (form.name.trim().length < 2) {\n newErrors.name = \"节点名称长度至少2位\";\n } else if (form.name.trim().length > 50) {\n newErrors.name = \"节点名称长度不能超过50位\";\n }\n\n if (\n (form.renewalCycle && !form.expiryTime) ||\n (!form.renewalCycle && form.expiryTime)\n ) {\n newErrors.expiryTime = \"请同时设置续费周期和续费基准时间\";\n }\n\n const v4 = form.serverIpV4.trim();\n const v6 = form.serverIpV6.trim();\n const host = form.serverHost.trim();\n\n if (!v4 && !v6 && !host) {\n const msg = \"请至少填写一个 IPv4/IPv6 地址或域名\";\n\n newErrors.serverIpV4 = msg;\n newErrors.serverIpV6 = msg;\n newErrors.serverHost = msg;\n } else {\n if (v4 && !validateIpv4Literal(v4)) {\n newErrors.serverIpV4 = \"请输入有效的IPv4地址\";\n }\n if (v6 && !validateIpv6Literal(v6)) {\n newErrors.serverIpV6 = \"请输入有效的IPv6地址\";\n }\n if (host && !validateHostname(host)) {\n newErrors.serverHost = \"请输入有效的域名/主机名\";\n }\n }\n\n const portValidation = validatePort(form.port);\n\n if (!portValidation.valid) {\n newErrors.port = portValidation.error || \"端口格式错误\";\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增节点\n const handleAdd = () => {\n setDialogTitle(\"新增节点\");\n setIsEdit(false);\n setDialogVisible(true);\n resetForm();\n setProtocolDisabled(true);\n setProtocolDisabledReason(\"节点未在线,等待节点上线后再设置\");\n };\n\n // 编辑节点\n const handleEdit = (node: Node) => {\n setDialogTitle(\"编辑节点\");\n setIsEdit(true);\n\n const legacy = (node.serverIp || \"\").trim();\n const normalizedV4 =\n node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : \"\");\n const normalizedV6 =\n node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : \"\");\n const normalizedHost =\n !normalizedV4 && !normalizedV6 && legacy ? legacy : \"\";\n\n setForm({\n id: node.id,\n name: node.name,\n remark: node.remark || \"\",\n tags: node.tags || \"\",\n expiryTime: node.expiryTime || 0,\n renewalCycle: node.renewalCycle || \"\",\n serverHost: normalizedHost,\n serverIpV4: normalizedV4,\n serverIpV6: normalizedV6,\n port: node.port || \"1000-65535\",\n tcpListenAddr: node.tcpListenAddr || \"[::]\",\n udpListenAddr: node.udpListenAddr || \"[::]\",\n interfaceName: (node as any).interfaceName || \"\",\n extraIPs: node.extraIPs || \"\",\n http: typeof node.http === \"number\" ? node.http : 1,\n tls: typeof node.tls === \"number\" ? node.tls : 1,\n socks: typeof node.socks === \"number\" ? node.socks : 1,\n });\n const offline = node.connectionStatus !== \"online\";\n\n setProtocolDisabled(offline);\n setProtocolDisabledReason(\n offline ? \"节点未在线,等待节点上线后再设置\" : \"\",\n );\n setDialogVisible(true);\n };\n\n // 删除节点\n const handleDelete = (node: Node) => {\n setNodeToDelete(node);\n setDeleteModalOpen(true);\n };\n\n const confirmDelete = async () => {\n if (!nodeToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteNode(nodeToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id));\n setDeleteModalOpen(false);\n setNodeToDelete(null);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n const openInstallSelector = (node: Node) => {\n setInstallTargetNode(node);\n setInstallChannel(\"stable\");\n setInstallSelectorOpen(true);\n };\n\n // 复制安装命令\n const handleCopyInstallCommand = async (\n node: Node,\n channel: ReleaseChannel,\n ) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),\n );\n\n try {\n const res = await getNodeInstallCommand(node.id, channel);\n\n if (res.code === 0 && res.data) {\n const copied = await tryCopyInstallCommand(res.data);\n\n if (copied) {\n toast.success(\n `${channel === \"stable\" ? \"正式版\" : \"测试版\"}安装命令已复制到剪贴板`,\n );\n } else {\n setInstallCommand(res.data);\n setCurrentNodeName(node.name);\n setInstallCommandModal(true);\n }\n } else {\n toast.error(res.msg || \"获取安装命令失败\");\n }\n } catch {\n toast.error(\"获取安装命令失败\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)),\n );\n }\n };\n\n const handleConfirmInstallCommand = async () => {\n if (!installTargetNode) return;\n\n setInstallSelectorOpen(false);\n await handleCopyInstallCommand(installTargetNode, installChannel);\n };\n\n // 手动复制安装命令\n const handleManualCopy = async () => {\n try {\n await navigator.clipboard.writeText(installCommand);\n toast.success(\"安装命令已复制到剪贴板\");\n setInstallCommandModal(false);\n } catch {\n toast.error(\"复制失败,请手动选择文本复制\");\n }\n };\n\n const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {\n setReleasesLoading(true);\n try {\n const res = await getNodeReleases(channel);\n\n if (res.code === 0 && Array.isArray(res.data)) {\n setReleases(res.data);\n } else {\n toast.error(res.msg || \"获取版本列表失败\");\n }\n } catch {\n toast.error(\"获取版本列表失败\");\n } finally {\n setReleasesLoading(false);\n }\n }, []);\n\n // 打开版本选择弹窗\n const openUpgradeModal = async (\n target: \"single\" | \"batch\",\n nodeId?: number,\n ) => {\n const defaultChannel: ReleaseChannel = \"stable\";\n\n setUpgradeTarget(target);\n setUpgradeTargetNodeId(nodeId || null);\n setReleaseChannel(defaultChannel);\n setSelectedVersion(\"\");\n setUpgradeModalOpen(true);\n await loadReleasesByChannel(defaultChannel);\n };\n\n // 确认升级(从版本弹窗)\n const handleConfirmUpgrade = async () => {\n const version = selectedVersion || undefined;\n\n if (upgradeTarget === \"single\" && upgradeTargetNodeId) {\n setUpgradeModalOpen(false);\n // Find the node\n const node = nodeList.find((n) => n.id === upgradeTargetNodeId);\n\n if (!node) return;\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,\n ),\n );\n try {\n const res = await upgradeNode(\n upgradeTargetNodeId,\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`节点升级命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,\n ),\n );\n }\n } else if (upgradeTarget === \"batch\") {\n setBatchUpgradeLoading(true);\n setUpgradeModalOpen(false);\n try {\n const res = await batchUpgradeNodes(\n Array.from(selectedIds),\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);\n } else {\n toast.error(res.msg || \"批量升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchUpgradeLoading(false);\n }\n }\n };\n\n // 回退节点\n const handleRollbackNode = async (node: Node) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),\n );\n try {\n const res = await rollbackNode(node.id);\n\n if (res.code === 0) {\n toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"回退失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === node.id ? { ...n, rollbackLoading: false } : n,\n ),\n );\n }\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n\n try {\n const apiCall = isEdit ? updateNode : createNode;\n const { serverHost, ...rest } = form;\n const data = {\n ...rest,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n extraIPs: form.extraIPs,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n serverHost?.trim() ||\n \"\",\n };\n\n const res = await apiCall(data);\n\n if (res.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n setDialogVisible(false);\n\n if (isEdit) {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === form.id\n ? {\n ...n,\n name: form.name,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n form.serverHost?.trim() ||\n \"\",\n serverIpV4: form.serverIpV4,\n serverIpV6: form.serverIpV6,\n port: form.port,\n tcpListenAddr: form.tcpListenAddr,\n udpListenAddr: form.udpListenAddr,\n interfaceName: form.interfaceName,\n http: form.http,\n tls: form.tls,\n socks: form.socks,\n }\n : n,\n ),\n );\n } else {\n loadNodes();\n }\n } else {\n toast.error(res.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 重置表单\n const resetForm = () => {\n setForm({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n setErrors({});\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n if (!nodeOrder || nodeOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const oldIndex = nodeOrder.indexOf(activeId);\n const newIndex = nodeOrder.indexOf(overId);\n\n if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;\n\n const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);\n\n setNodeOrder(newOrder);\n\n saveOrder(\"node-order\", newOrder);\n\n // 持久化到数据库\n try {\n const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));\n const response = await updateNodeOrder({ nodes: nodesToUpdate });\n\n if (response.code === 0) {\n setNodeList((prev) =>\n prev.map((node) => {\n const updated = nodesToUpdate.find((n) => n.id === node.id);\n\n return updated ? { ...node, inx: updated.inx } : node;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n };\n\n // 批量操作处理函数\n const toggleSelectMode = () => {\n setSelectMode((prev) => {\n if (prev) {\n setSelectedIds(new Set());\n }\n\n return !prev;\n });\n };\n\n const toggleSelect = (id: number) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n };\n\n const selectAll = () => {\n setSelectedIds(new Set(sortedNodes.map((n) => n.id)));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n try {\n const res = await batchDeleteNodes(Array.from(selectedIds));\n\n if (res.code === 0) {\n toast.success(`成功删除 ${selectedIds.size} 个节点`);\n setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id)));\n setSelectedIds(new Set());\n setBatchDeleteModalOpen(false);\n setSelectMode(false);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchLoading(false);\n }\n };\n\n // 传感器配置\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n const nodeExpiryStats = useMemo(() => {\n return nodeList.reduce(\n (acc, node) => {\n const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);\n\n if (meta.state === \"expired\") acc.expired += 1;\n if (meta.state === \"expiringSoon\") acc.expiringSoon += 1;\n if (getNodeReminderEnabled(node)) {\n acc.withExpiry += 1;\n }\n\n return acc;\n },\n { expired: 0, expiringSoon: 0, withExpiry: 0 },\n );\n }, [nodeList]);\n\n // 根据排序顺序获取节点列表\n const sortedNodes = useMemo((): Node[] => {\n if (!nodeList || nodeList.length === 0) return [];\n\n let filteredNodes = nodeList;\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredNodes = filteredNodes.filter(\n (n) =>\n (n.name && n.name.toLowerCase().includes(lowerKeyword)) ||\n (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||\n (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n if (nodeFilterMode !== \"all\") {\n filteredNodes = filteredNodes.filter((node) => {\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n switch (nodeFilterMode) {\n case \"expiringSoon\":\n return expiryMeta.state === \"expiringSoon\";\n case \"expired\":\n return expiryMeta.state === \"expired\";\n case \"withExpiry\":\n return getNodeReminderEnabled(node);\n default:\n return true;\n }\n });\n }\n\n const sortedByDb = [...filteredNodes].sort((a, b) => {\n const expiryDiff =\n getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -\n getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;\n\n if (expiryDiff !== 0) {\n return expiryDiff;\n }\n\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n return aInx - bInx;\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n nodeOrder &&\n nodeOrder.length > 0 &&\n sortedByDb.every((n) => n.inx === undefined || n.inx === 0)\n ) {\n const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));\n const localSorted: Node[] = [];\n\n nodeOrder.forEach((id) => {\n const node = nodeMap.get(id);\n\n if (node) localSorted.push(node);\n });\n\n filteredNodes.forEach((node) => {\n if (!nodeOrder.includes(node.id)) {\n localSorted.push(node);\n }\n });\n\n return localSorted;\n }\n\n return sortedByDb;\n }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);\n\n const sortableNodeIds = useMemo(\n () => sortedNodes.map((n) => n.id),\n [sortedNodes],\n );\n\n return (\n <AnimatedPage className=\"px-3 lg:px-6 py-8\">\n <div className=\"flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3\">\n <div className=\"flex flex-1 flex-col gap-3 lg:flex-row lg:items-center\">\n <SearchBar\n isVisible={isSearchVisible}\n placeholder=\"搜索名称、IP、备注或标签\"\n value={searchKeyword}\n onChange={setSearchKeyword}\n onClose={() => setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n\n <Select\n className=\"w-full max-w-sm lg:w-56\"\n label=\"到期筛选\"\n selectedKeys={[nodeFilterMode]}\n size=\"sm\"\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as\n | NodeFilterMode\n | undefined;\n\n setNodeFilterMode(selected || \"all\");\n }}\n >\n <SelectItem key=\"all\" textValue=\"全部节点\">\n 全部节点\n </SelectItem>\n <SelectItem key=\"expiringSoon\" textValue=\"7天内到期\">\n 7天内续费 ({nodeExpiryStats.expiringSoon})\n </SelectItem>\n <SelectItem key=\"expired\" textValue=\"已过期\">\n 已逾期 ({nodeExpiryStats.expired})\n </SelectItem>\n <SelectItem key=\"withExpiry\" textValue=\"已设置到期时间\">\n 已启用续费提醒 ({nodeExpiryStats.withExpiry})\n </SelectItem>\n </Select>\n </div>\n\n <div className=\"min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x\">\n <div className=\"flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0\">\n {selectMode ? (\n <>\n <span className=\"text-sm text-default-600 shrink-0\">\n 已选 {selectedIds.size} 项\n </span>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={selectAll}\n >\n 全选\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={deselectAll}\n >\n 清空\n </Button>\n <Button\n color=\"warning\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchUpgradeLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => openUpgradeModal(\"batch\")}\n >\n 升级\n </Button>\n <Button\n color=\"danger\"\n isDisabled={selectedIds.size === 0}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setBatchDeleteModalOpen(true)}\n >\n 删除\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"solid\"\n onPress={toggleSelectMode}\n >\n 退出\n </Button>\n </>\n ) : (\n <>\n <Button\n className=\"bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45\"\n color=\"default\"\n size=\"sm\"\n variant=\"flat\"\n onPress={toggleSelectMode}\n >\n 批量\n </Button>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleAdd}\n >\n 新增\n </Button>\n </>\n )}\n </div>\n </div>\n </div>\n\n {!wsConnected && (\n <Alert\n className=\"mb-4\"\n color=\"warning\"\n description={\n wsConnecting\n ? \"监控连接中...\"\n : usingPollingFallback\n ? \"监控连接已断开,已切换为列表自动刷新兜底模式。\"\n : \"监控连接已断开,正在重连...\"\n }\n variant=\"flat\"\n />\n )}\n\n {/* 节点列表 */}\n {loading ? (\n <PageLoadingState message=\"正在加载...\" />\n ) : nodeList.length === 0 ? (\n <PageEmptyState\n className=\"h-64\"\n message=\"暂无节点配置,点击上方按钮开始创建\"\n />\n ) : (\n <DndContext sensors={sensors} onDragEnd={handleDragEnd}>\n <SortableContext\n items={sortableNodeIds}\n strategy={rectSortingStrategy}\n >\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4\">\n {sortedNodes.map((node) => {\n const isRemoteNode = node.isRemote === 1;\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n return (\n <SortableItem key={node.id} id={node.id}>\n {(listeners) => (\n <Card\n key={node.id}\n className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full flex flex-col ${expiryMeta.accentClassName}`}\n >\n <CardHeader className=\"pb-2 md:pb-2\">\n <div className=\"flex justify-between items-start w-full\">\n <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n {selectMode && (\n <Checkbox\n isSelected={selectedIds.has(node.id)}\n onValueChange={() => toggleSelect(node.id)}\n />\n )}\n <h3 className=\"font-semibold text-foreground truncate text-sm\">\n {node.name}\n </h3>\n </div>\n <div className=\"flex items-center gap-1.5 ml-2\">\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n <Chip\n className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n color={expiryMeta.tone}\n size=\"sm\"\n title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}\n variant=\"flat\"\n >\n {expiryMeta.label}\n </Chip>\n )}\n <div\n className=\"cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100\"\n {...listeners}\n style={{ touchAction: \"none\" }}\n title=\"拖拽排序\"\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n {isRemoteNode && (\n <Chip\n className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n 远程\n </Chip>\n )}\n {(() => {\n const connectionStatusMeta =\n getConnectionStatusMeta(\n node.connectionStatus,\n );\n\n return (\n <Chip\n className=\"text-[10px] h-5 px-1\"\n color={connectionStatusMeta.color}\n size=\"sm\"\n variant=\"flat\"\n >\n {connectionStatusMeta.text}\n </Chip>\n );\n })()}\n </div>\n </div>\n </CardHeader>\n\n <CardBody className=\"pt-0 pb-3 md:pt-0 md:pb-3 flex-1 flex flex-col\">\n {isRemoteNode && node.syncError && (\n <div className=\"mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs\">\n {getRemoteSyncErrorMessage(node.syncError)}\n </div>\n )}\n {expiryMeta.isHighlighted && (\n <div\n className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}\n >\n <span>节点到期提醒</span>\n <span>{expiryMeta.label}</span>\n </div>\n )}\n {/* 基础信息 */}\n <div className=\"space-y-2 mb-4\">\n {(node.remark?.trim() || node.tags?.trim()) && (\n <div className=\"space-y-1.5\">\n {node.remark?.trim() && (\n <div className=\"rounded-md border border-divider/80 bg-default-50/80 px-2.5 py-1.5\">\n <div\n className=\"text-xs leading-5 text-default-600 line-clamp-1 break-all\"\n title={node.remark.trim()}\n >\n {node.remark.trim()}\n </div>\n </div>\n )}\n {normalizeNodeTags(node.tags).length > 0 && (\n <div className=\"flex flex-wrap gap-1.5\">\n {normalizeNodeTags(node.tags).map((tag) => (\n <Chip\n key={`${node.id}-${tag}`}\n className=\"text-[10px] h-5 px-1\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n #{tag}\n </Chip>\n ))}\n </div>\n )}\n </div>\n )}\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && <div className=\"hidden\" />}\n <div className=\"flex justify-between items-center text-sm min-w-0\">\n <span className=\"text-default-600 flex-shrink-0\">\n IP\n </span>\n <div className=\"text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]\">\n {node.serverIpV4?.trim() ||\n node.serverIpV6?.trim() ? (\n <div className=\"space-y-0.5\">\n {node.serverIpV4?.trim() && (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIpV4.trim()}\n >\n {node.serverIpV4.trim()}\n </span>\n )}\n {node.serverIpV6?.trim() && (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIpV6.trim()}\n >\n {node.serverIpV6.trim()}\n </span>\n )}\n </div>\n ) : (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIp.trim()}\n >\n {node.serverIp.trim()}\n </span>\n )}\n </div>\n </div>\n {!isRemoteNode && (\n <>\n <div className=\"flex justify-between text-sm\">\n <span className=\"text-default-600\">版本</span>\n <span className=\"text-xs\">\n {node.version || \"未知\"}\n </span>\n </div>\n {upgradeProgress[node.id] &&\n upgradeProgress[node.id].percent < 100 && (\n <div className=\"mt-1\">\n <Progress\n showValueLabel\n aria-label=\"升级进度\"\n color=\"warning\"\n label={upgradeProgress[node.id].message}\n size=\"sm\"\n value={upgradeProgress[node.id].percent}\n />\n </div>\n )}\n <div className=\"flex justify-between text-sm\">\n <span className=\"text-default-600\">\n 开机时间\n </span>\n <span className=\"text-xs\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatUptime(node.systemInfo.uptime)\n : \"-\"}\n </span>\n </div>\n </>\n )}\n </div>\n\n {!isRemoteNode && (\n <>\n {/* 系统监控 */}\n <div className=\"space-y-3 mb-4\">\n <div className=\"grid grid-cols-2 gap-3\">\n <div>\n <div className=\"flex justify-between text-xs mb-1\">\n <span>CPU</span>\n <span className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.cpuUsage.toFixed(1)}%`\n : \"-\"}\n </span>\n </div>\n <Progress\n aria-label=\"CPU使用率\"\n color={getProgressColor(\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.cpuUsage\n : 0,\n node.connectionStatus !== \"online\",\n )}\n size=\"sm\"\n value={\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.cpuUsage\n : 0\n }\n />\n </div>\n <div>\n <div className=\"flex justify-between text-xs mb-1\">\n <span>内存</span>\n <span className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.memoryUsage.toFixed(1)}%`\n : \"-\"}\n </span>\n </div>\n <Progress\n aria-label=\"内存使用率\"\n color={getProgressColor(\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.memoryUsage\n : 0,\n node.connectionStatus !== \"online\",\n )}\n size=\"sm\"\n value={\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.memoryUsage\n : 0\n }\n />\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-2 text-xs\">\n <div className=\"text-center p-2 bg-default-50 dark:bg-default-100 rounded\">\n <div className=\"text-default-600 mb-0.5\">\n 上传\n </div>\n <div className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.uploadSpeed,\n )\n : \"-\"}\n </div>\n </div>\n <div className=\"text-center p-2 bg-default-50 dark:bg-default-100 rounded\">\n <div className=\"text-default-600 mb-0.5\">\n 下载\n </div>\n <div className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.downloadSpeed,\n )\n : \"-\"}\n </div>\n </div>\n </div>\n\n {/* 流量统计 */}\n <div className=\"grid grid-cols-2 gap-2 text-xs\">\n <div className=\"text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20\">\n <div className=\"text-primary-600 dark:text-primary-400 mb-0.5\">\n ↑ 上行流量\n </div>\n <div className=\"font-mono text-primary-700 dark:text-primary-300\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.uploadTraffic,\n )\n : \"-\"}\n </div>\n </div>\n <div className=\"text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20\">\n <div className=\"text-success-600 dark:text-success-400 mb-0.5\">\n ↓ 下行流量\n </div>\n <div className=\"font-mono text-success-700 dark:text-success-300\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.downloadTraffic,\n )\n : \"-\"}\n </div>\n </div>\n </div>\n </div>\n </>\n )}\n\n {/* 操作按钮 */}\n <div className=\"space-y-1.5 mt-auto\">\n {!isRemoteNode && (\n <div className=\"grid grid-cols-3 gap-1.5\">\n <Button\n className=\"min-h-8\"\n color=\"success\"\n isLoading={node.copyLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => openInstallSelector(node)}\n >\n 安装\n </Button>\n <Button\n className=\"min-h-8\"\n color=\"warning\"\n isDisabled={\n node.connectionStatus !== \"online\"\n }\n isLoading={node.upgradeLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() =>\n openUpgradeModal(\"single\", node.id)\n }\n >\n 升级\n </Button>\n <Button\n className=\"min-h-8\"\n color=\"secondary\"\n isDisabled={\n node.connectionStatus !== \"online\"\n }\n isLoading={node.rollbackLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleRollbackNode(node)}\n >\n 回退\n </Button>\n </div>\n )}\n <div\n className={`grid gap-1.5 ${isRemoteNode ? \"grid-cols-1\" : \"grid-cols-2\"}`}\n >\n {!isRemoteNode && (\n <Button\n className=\"min-h-8\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleEdit(node)}\n >\n 编辑\n </Button>\n )}\n <Button\n className=\"min-h-8\"\n color=\"danger\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleDelete(node)}\n >\n 删除\n </Button>\n </div>\n </div>\n </CardBody>\n </Card>\n )}\n </SortableItem>\n );\n })}\n </div>\n </SortableContext>\n </DndContext>\n )}\n\n {/* 新增/编辑节点对话框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={dialogVisible}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setDialogVisible(false)}\n >\n <ModalContent>\n <ModalHeader>{dialogTitle}</ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <Input\n errorMessage={errors.name}\n isInvalid={!!errors.name}\n label=\"节点名称\"\n placeholder=\"请输入节点名称\"\n value={form.name}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n <Textarea\n description=\"可记录供应商、用途、续费说明等补充信息\"\n label=\"备注\"\n maxRows={4}\n minRows={3}\n placeholder=\"例如: 搬瓦工年付,2026-12 续费,日本中转\"\n value={form.remark}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, remark: e.target.value }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n description=\"多个标签用逗号分隔,可用于搜索过滤\"\n label=\"标签\"\n placeholder=\"例如: 年付,日本中转,测试机\"\n value={form.tags}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, tags: e.target.value }))\n }\n />\n\n <Select\n label=\"续费周期\"\n placeholder=\"选择续费周期\"\n selectedKeys={form.renewalCycle ? [form.renewalCycle] : []}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as\n | NodeRenewalCycle\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n renewalCycle: selected || \"\",\n }));\n }}\n >\n <SelectItem key=\"month\" textValue=\"month\">\n 月付\n </SelectItem>\n <SelectItem key=\"quarter\" textValue=\"quarter\">\n 季付\n </SelectItem>\n <SelectItem key=\"year\" textValue=\"year\">\n 年付\n </SelectItem>\n </Select>\n </div>\n\n <Input\n description=\"填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费\"\n errorMessage={errors.expiryTime}\n isInvalid={!!errors.expiryTime}\n label=\"续费基准时间\"\n type=\"datetime-local\"\n value={\n form.expiryTime > 0\n ? new Date(form.expiryTime).toISOString().slice(0, 16)\n : \"\"\n }\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n expiryTime: e.target.value\n ? new Date(e.target.value).getTime()\n : 0,\n }))\n }\n />\n\n <Alert\n color=\"primary\"\n description=\"例如选择月付并填写 2026-03-01,系统会自动按每月同日推算下次续费时间。\"\n variant=\"flat\"\n />\n\n <Input\n description=\"可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名\"\n errorMessage={errors.serverHost}\n isInvalid={!!errors.serverHost}\n label=\"服务器域名/主机名\"\n placeholder=\"例如: node.example.com\"\n value={form.serverHost}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverHost: e.target.value }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n description=\"双栈节点组隧道时优先使用 IPv4\"\n errorMessage={errors.serverIpV4}\n isInvalid={!!errors.serverIpV4}\n label=\"服务器IPv4\"\n placeholder=\"例如: 203.0.113.10\"\n value={form.serverIpV4}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverIpV4: e.target.value }))\n }\n />\n\n <Input\n description=\"至少填写一个 IPv4/IPv6/域名\"\n errorMessage={errors.serverIpV6}\n isInvalid={!!errors.serverIpV6}\n label=\"服务器IPv6\"\n placeholder=\"例如: 2001:db8::10\"\n value={form.serverIpV6}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverIpV6: e.target.value }))\n }\n />\n </div>\n\n <Input\n classNames={{\n input: \"font-mono\",\n }}\n description=\"支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔\"\n errorMessage={errors.port}\n isInvalid={!!errors.port}\n label=\"可用端口\"\n placeholder=\"例如: 80,443,1000-65535\"\n value={form.port}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, port: e.target.value }))\n }\n />\n\n {/* 高级配置 */}\n <Accordion variant=\"bordered\">\n <AccordionItem\n key=\"advanced\"\n aria-label=\"高级配置\"\n title=\"高级配置\"\n >\n <div className=\"space-y-4 pb-2\">\n <Input\n description=\"用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行\"\n errorMessage={errors.interfaceName}\n isInvalid={!!errors.interfaceName}\n label=\"出口网卡名或IP\"\n placeholder=\"请输入出口网卡名或IP\"\n value={form.interfaceName}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n interfaceName: e.target.value,\n }))\n }\n />\n\n <Input\n description=\"多IP服务器可填写额外IP地址,逗号分隔\"\n label=\"额外IP地址\"\n placeholder=\"例如: 192.168.1.100, 10.0.0.5\"\n value={form.extraIPs}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n extraIPs: e.target.value,\n }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n errorMessage={errors.tcpListenAddr}\n isInvalid={!!errors.tcpListenAddr}\n label=\"TCP监听地址\"\n placeholder=\"请输入TCP监听地址\"\n startContent={\n <div className=\"pointer-events-none flex items-center\">\n <span className=\"text-default-400 text-small\">\n TCP\n </span>\n </div>\n }\n value={form.tcpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n tcpListenAddr: e.target.value,\n }))\n }\n />\n\n <Input\n errorMessage={errors.udpListenAddr}\n isInvalid={!!errors.udpListenAddr}\n label=\"UDP监听地址\"\n placeholder=\"请输入UDP监听地址\"\n startContent={\n <div className=\"pointer-events-none flex items-center\">\n <span className=\"text-default-400 text-small\">\n UDP\n </span>\n </div>\n }\n value={form.udpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n udpListenAddr: e.target.value,\n }))\n }\n />\n </div>\n {/* 屏蔽协议 */}\n <div>\n <div className=\"text-sm font-medium text-default-700 mb-2\">\n 屏蔽协议\n </div>\n <div className=\"text-xs text-default-500 mb-2\">\n 开启开关以屏蔽对应协议\n </div>\n {protocolDisabled && (\n <Alert\n className=\"mb-2\"\n color=\"warning\"\n description={\n protocolDisabledReason || \"等待节点上线后再设置\"\n }\n variant=\"flat\"\n />\n )}\n <div\n className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-default-50 dark:bg-default-100 p-3 rounded-md border border-default-200 dark:border-default-100/30 ${protocolDisabled ? \"opacity-70\" : \"\"}`}\n >\n {/* HTTP tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <rect height=\"16\" rx=\"2\" width=\"20\" x=\"2\" y=\"4\" />\n <path d=\"M2 10h20\" />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n HTTP\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.http === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({\n ...prev,\n http: v ? 1 : 0,\n }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.http === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n\n {/* TLS tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M6 10V7a6 6 0 1 1 12 0v3\" />\n <rect\n height=\"10\"\n rx=\"2\"\n width=\"16\"\n x=\"4\"\n y=\"10\"\n />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n TLS\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.tls === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({ ...prev, tls: v ? 1 : 0 }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.tls === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n\n {/* SOCKS tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n <polyline points=\"7 10 12 15 17 10\" />\n <line x1=\"12\" x2=\"12\" y1=\"15\" y2=\"3\" />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n SOCKS\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.socks === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({\n ...prev,\n socks: v ? 1 : 0,\n }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.socks === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n </div>\n </div>\n\n <Alert\n color=\"danger\"\n description=\"请不要在出口节点执行屏蔽协议,否则可能影响转发;屏蔽协议仅需在入口节点执行。\"\n variant=\"flat\"\n />\n </div>\n </AccordionItem>\n </Accordion>\n\n <Alert\n className=\"mt-4\"\n color=\"primary\"\n description=\"服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。\"\n variant=\"flat\"\n />\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"flat\" onPress={() => setDialogVisible(false)}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isLoading={submitLoading}\n onPress={handleSubmit}\n >\n {submitLoading ? \"提交中...\" : \"确定\"}\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={deleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onOpenChange={setDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p>\n 确定要删除节点{\" \"}\n <strong>&quot;{nodeToDelete?.name}&quot;</strong> 吗?\n </p>\n <p className=\"text-small text-default-500\">\n 此操作不可恢复,请谨慎操作。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={deleteLoading}\n onPress={confirmDelete}\n >\n {deleteLoading ? \"删除中...\" : \"确认删除\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n <Modal\n backdrop=\"blur\"\n isOpen={installSelectorOpen}\n placement=\"center\"\n size=\"md\"\n onOpenChange={setInstallSelectorOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n 选择安装通道\n {installTargetNode ? ` - ${installTargetNode.name}` : \"\"}\n </h2>\n </ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <Select\n label=\"版本通道\"\n selectedKeys={[installChannel]}\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as ReleaseChannel;\n\n setInstallChannel(selected || \"stable\");\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 正式版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 测试版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button color=\"primary\" onPress={handleConfirmInstallCommand}>\n 生成命令\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 安装命令模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={installCommandModal}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setInstallCommandModal(false)}\n >\n <ModalContent>\n <ModalHeader>安装命令 - {currentNodeName}</ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <p className=\"text-sm text-default-600\">\n 请复制以下安装命令到服务器上执行:\n </p>\n <div className=\"relative\">\n <Textarea\n readOnly\n className=\"font-mono text-sm\"\n classNames={{\n input: \"font-mono text-sm\",\n }}\n maxRows={10}\n minRows={6}\n value={installCommand}\n variant=\"bordered\"\n />\n <Button\n className=\"absolute top-2 right-2\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleManualCopy}\n >\n 复制\n </Button>\n </div>\n <div className=\"text-xs text-default-500\">\n 💡 提示:如果复制按钮失效,请手动选择上方文本进行复制\n </div>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button\n variant=\"flat\"\n onPress={() => setInstallCommandModal(false)}\n >\n 关闭\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 版本选择升级模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={upgradeModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"md\"\n onOpenChange={setUpgradeModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n {upgradeTarget === \"batch\"\n ? `批量升级 (${selectedIds.size} 个节点)`\n : \"升级节点\"}\n </h2>\n </ModalHeader>\n <ModalBody>\n {releasesLoading ? (\n <div className=\"flex justify-center py-8\">\n <Spinner size=\"lg\" />\n </div>\n ) : (\n <div className=\"space-y-4\">\n <Select\n label=\"版本通道\"\n selectedKeys={[releaseChannel]}\n onSelectionChange={(keys) => {\n const selected =\n (Array.from(keys)[0] as ReleaseChannel) || \"stable\";\n\n setReleaseChannel(selected);\n setSelectedVersion(\"\");\n void loadReleasesByChannel(selected);\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 正式版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 测试版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n <Select\n label=\"选择版本\"\n placeholder=\"留空则使用当前通道最新版本\"\n selectedKeys={selectedVersion ? [selectedVersion] : []}\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as string;\n\n setSelectedVersion(selected || \"\");\n }}\n >\n {releases.map((r) => (\n <SelectItem key={r.version} textValue={r.version}>\n <div className=\"flex justify-between items-center\">\n <span>{r.version}</span>\n <span className=\"text-xs text-default-400\">\n {r.publishedAt\n ? new Date(r.publishedAt).toLocaleDateString()\n : \"\"}\n {r.channel === \"dev\" && (\n <Chip\n className=\"ml-1\"\n color=\"warning\"\n size=\"sm\"\n variant=\"flat\"\n >\n 测试\n </Chip>\n )}\n </span>\n </div>\n </SelectItem>\n ))}\n </Select>\n <p className=\"text-sm text-default-500\">\n {selectedVersion\n ? `将升级到版本 ${selectedVersion}`\n : `未选择版本,将自动使用最新${releaseChannel === \"stable\" ? \"正式\" : \"测试\"}版`}\n </p>\n </div>\n )}\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"warning\"\n isDisabled={releasesLoading}\n onPress={handleConfirmUpgrade}\n >\n 确认升级\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 批量删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={batchDeleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"md\"\n onOpenChange={setBatchDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p>\n 确定要删除选中的 <strong>{selectedIds.size}</strong>{\" \"}\n 个节点吗?\n </p>\n <p className=\"text-small text-default-500\">\n 此操作不可恢复,请谨慎操作。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={batchLoading}\n onPress={handleBatchDelete}\n >\n {batchLoading ? \"删除中...\" : \"确认删除\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n </AnimatedPage>\n );\n}\n",
"after": "import { useState, useEffect, useMemo, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n type DragEndEvent,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport {\n SortableContext,\n arrayMove,\n rectSortingStrategy,\n sortableKeyboardCoordinates,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nimport { SearchBar } from \"@/components/search-bar\";\nimport { AnimatedPage } from \"@/components/animated-page\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport {\n Modal,\n ModalContent,\n ModalHeader,\n ModalBody,\n ModalFooter,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport { Chip } from \"@/shadcn-bridge/heroui/chip\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Alert } from \"@/shadcn-bridge/heroui/alert\";\nimport { Progress } from \"@/shadcn-bridge/heroui/progress\";\nimport { Accordion, AccordionItem } from \"@/shadcn-bridge/heroui/accordion\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n createNode,\n getNodeList,\n updateNode,\n deleteNode,\n getNodeInstallCommand,\n updateNodeOrder,\n batchDeleteNodes,\n upgradeNode,\n batchUpgradeNodes,\n getNodeReleases,\n rollbackNode,\n type ReleaseChannel,\n} from \"@/api\";\nimport { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\nimport {\n getConnectionStatusMeta,\n getRemoteSyncErrorMessage,\n} from \"@/pages/node/display\";\nimport { tryCopyInstallCommand } from \"@/pages/node/install-command\";\nimport {\n getNodeRenewalSnapshot,\n formatNodeRenewalTime,\n getNodeRenewalCycleLabel,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";\nimport { buildNodeSystemInfo } from \"@/pages/node/system-info\";\nimport { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\nimport { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\nimport { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n\nconst NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n\ninterface Node {\n id: number;\n inx?: number;\n name: string;\n remark?: string;\n tags?: string;\n expiryTime?: number;\n renewalCycle?: NodeRenewalCycle;\n ip: string;\n serverIp: string;\n serverIpV4?: string;\n serverIpV6?: string;\n port: string;\n tcpListenAddr?: string;\n udpListenAddr?: string;\n extraIPs?: string;\n version?: string;\n http?: number; // 0 关 1 开\n tls?: number; // 0 关 1 开\n socks?: number; // 0 关 1 开\n status: number;\n isRemote?: number;\n remoteUrl?: string;\n syncError?: string;\n connectionStatus: \"online\" | \"offline\";\n systemInfo?: {\n cpuUsage: number;\n memoryUsage: number;\n uploadTraffic: number;\n downloadTraffic: number;\n uploadSpeed: number;\n downloadSpeed: number;\n uptime: number;\n } | null;\n copyLoading?: boolean;\n upgradeLoading?: boolean;\n rollbackLoading?: boolean;\n}\n\ninterface NodeForm {\n id: number | null;\n name: string;\n remark: string;\n tags: string;\n expiryTime: number;\n renewalCycle: NodeRenewalCycle;\n serverHost: string;\n serverIpV4: string;\n serverIpV6: string;\n port: string;\n tcpListenAddr: string;\n udpListenAddr: string;\n interfaceName: string;\n extraIPs: string;\n http: number; // 0 关 1 开\n tls: number; // 0 关 1 开\n socks: number; // 0 关 1 开\n}\n\nconst EXPIRING_SOON_DAYS = 7;\n\ntype NodeExpiryState = \"permanent\" | \"healthy\" | \"expiringSoon\" | \"expired\";\n\ntype NodeFilterMode = \"all\" | \"expiringSoon\" | \"expired\" | \"withExpiry\";\n\nconst getNodeReminderEnabled = (node: Node): boolean => {\n return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;\n};\n\nconst getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {\n const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);\n\n if (renewal.state === \"unset\") {\n return {\n state: \"permanent\" as NodeExpiryState,\n label: \"未设置续费周期\",\n tone: \"default\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 3,\n nextDueTime: undefined,\n };\n }\n\n if (renewal.state === \"expired\") {\n return {\n state: \"expired\" as NodeExpiryState,\n label: \"已过期\",\n tone: \"danger\" as const,\n accentClassName:\n \"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20\",\n bannerClassName:\n \"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300\",\n isHighlighted: true,\n sortWeight: 0,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n if (renewal.state === \"dueSoon\") {\n return {\n state: \"expiringSoon\" as NodeExpiryState,\n label: renewal.label,\n tone: \"warning\" as const,\n accentClassName:\n \"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20\",\n bannerClassName:\n \"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300\",\n isHighlighted: true,\n sortWeight: 1,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n return {\n state: \"healthy\" as NodeExpiryState,\n label: renewal.label,\n tone: \"success\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 2,\n nextDueTime: renewal.nextDueTime,\n };\n};\n\nconst normalizeNodeTags = (tags?: string): string[] => {\n if (!tags) return [];\n\n return tags\n .split(\",\")\n .map((tag) => tag.trim())\n .filter(Boolean);\n};\n\nconst mergeNodeRealtimeState = (\n incomingNode: Node,\n existingNode?: Node,\n): Node => {\n return {\n ...incomingNode,\n systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,\n copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,\n upgradeLoading:\n existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,\n rollbackLoading:\n existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,\n };\n};\n\nconst SortableItem = ({\n id,\n children,\n}: {\n id: number;\n children: (listeners: any) => any;\n}) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n };\n\n return (\n <div\n ref={setNodeRef}\n style={style}\n {...attributes}\n className=\"overflow-hidden h-full\"\n {...listeners}\n >\n {children(listeners)}\n </div>\n );\n};\n\nexport default function NodePage() {\n const [nodeList, setNodeList] = useState<Node[]>([]);\n const [nodeOrder, setNodeOrder] = useState<number[]>([]);\n const [loading, setLoading] = useState(false);\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"node-search-keyword\",\n \"\",\n );\n const [nodeFilterMode, setNodeFilterMode] =\n useLocalStorageState<NodeFilterMode>(\"node-expiry-filter-mode\", \"all\");\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [dialogVisible, setDialogVisible] = useState(false);\n const [dialogTitle, setDialogTitle] = useState(\"\");\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);\n const [protocolDisabled, setProtocolDisabled] = useState(false);\n const [protocolDisabledReason, setProtocolDisabledReason] = useState(\"\");\n const [form, setForm] = useState<NodeForm>({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n const [errors, setErrors] = useState<Record<string, string>>({});\n\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchLoading, setBatchLoading] = useState(false);\n\n // 安装命令相关状态\n const [installCommandModal, setInstallCommandModal] = useState(false);\n const [installCommand, setInstallCommand] = useState(\"\");\n const [currentNodeName, setCurrentNodeName] = useState(\"\");\n const [installSelectorOpen, setInstallSelectorOpen] = useState(false);\n const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);\n const [installChannel, setInstallChannel] =\n useState<ReleaseChannel>(\"stable\");\n\n // 升级相关状态\n const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);\n const [upgradeTarget, setUpgradeTarget] = useState<\"single\" | \"batch\">(\n \"single\",\n );\n const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(\n null,\n );\n const [releases, setReleases] = useState<\n Array<{\n version: string;\n name: string;\n publishedAt: string;\n prerelease: boolean;\n channel: ReleaseChannel;\n }>\n >([]);\n const [releasesLoading, setReleasesLoading] = useState(false);\n const [releaseChannel, setReleaseChannel] =\n useState<ReleaseChannel>(\"stable\");\n const [selectedVersion, setSelectedVersion] = useState(\"\");\n const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);\n const [upgradeProgress, setUpgradeProgress] = useState<\n Record<number, { stage: string; percent: number; message: string }>\n >({});\n\n const handleNodeOffline = useCallback((nodeId: number) => {\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"offline\" && node.systemInfo === null) {\n return node;\n }\n\n return { ...node, connectionStatus: \"offline\", systemInfo: null };\n }),\n );\n }, []);\n\n const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({\n delayMs: 3000,\n onNodeOffline: handleNodeOffline,\n });\n\n // 加载节点列表\n const loadNodes = useCallback(async (options?: { silent?: boolean }) => {\n const silent = options?.silent ?? false;\n\n if (!silent) {\n setLoading(true);\n }\n\n try {\n const res = await getNodeList();\n\n if (res.code === 0) {\n const nodesData: Node[] = (res.data || []).map((node: any) => ({\n ...node,\n inx: node.inx ?? 0,\n connectionStatus: node.syncError\n ? \"offline\"\n : node.status === 1\n ? \"online\"\n : \"offline\",\n syncError: node.syncError || undefined,\n systemInfo: null,\n copyLoading: false,\n }));\n\n setNodeList((prev) => {\n const previousById = new Map(prev.map((node) => [node.id, node]));\n\n return nodesData.map((node) =>\n mergeNodeRealtimeState(node, previousById.get(node.id)),\n );\n });\n\n // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序\n const hasDbOrdering = nodesData.some(\n (n) => n.inx !== undefined && n.inx !== 0,\n );\n\n if (hasDbOrdering) {\n const dbOrder = [...nodesData]\n .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n .map((n) => n.id);\n\n setNodeOrder(dbOrder);\n } else {\n setNodeOrder(\n loadStoredOrder(\n \"node-order\",\n nodesData.map((n) => n.id),\n ),\n );\n }\n } else {\n if (!silent) {\n toast.error(res.msg || \"加载节点列表失败\");\n }\n }\n } catch {\n if (!silent) {\n toast.error(\"网络错误,请重试\");\n }\n } finally {\n if (!silent) {\n setLoading(false);\n }\n }\n }, []);\n\n // 处理WebSocket消息\n const handleWebSocketMessage = (data: any) => {\n const { id, type, data: messageData } = data;\n const nodeId = Number(id);\n\n if (Number.isNaN(nodeId)) return;\n\n if (type === \"status\") {\n if (messageData === 1) {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"online\") return node;\n\n return { ...node, connectionStatus: \"online\" };\n }),\n );\n } else {\n // 离线事件做延迟处理,避免短抖动导致频繁闪烁\n scheduleNodeOffline(nodeId);\n }\n } else if (type === \"info\") {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id === nodeId) {\n const systemInfo = buildNodeSystemInfo(\n messageData,\n node.systemInfo,\n );\n\n if (!systemInfo) {\n return node;\n }\n\n return {\n ...node,\n connectionStatus: \"online\",\n systemInfo,\n };\n }\n\n return node;\n }),\n );\n } else if (type === \"upgrade_progress\") {\n try {\n const progressData =\n typeof messageData === \"string\"\n ? JSON.parse(messageData)\n : messageData;\n\n if (progressData?.data) {\n setUpgradeProgress((prev) => ({\n ...prev,\n [nodeId]: {\n stage: progressData.data.stage || \"\",\n percent: progressData.data.percent || 0,\n message: progressData.message || \"\",\n },\n }));\n }\n } catch {\n // ignore parse errors\n }\n }\n };\n\n const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n\n useEffect(() => {\n loadNodes();\n }, [loadNodes]);\n\n useEffect(() => {\n if (!usingPollingFallback) {\n return;\n }\n\n void loadNodes({ silent: true });\n\n const interval = window.setInterval(() => {\n void loadNodes({ silent: true });\n }, NODE_FALLBACK_REFRESH_INTERVAL_MS);\n\n return () => {\n window.clearInterval(interval);\n };\n }, [loadNodes, usingPollingFallback]);\n\n // 格式化速度\n const formatSpeed = (bytesPerSecond: number): string => {\n if (bytesPerSecond === 0) return \"0 B/s\";\n\n const k = 1024;\n const sizes = [\"B/s\", \"KB/s\", \"MB/s\", \"GB/s\", \"TB/s\"];\n const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));\n\n return (\n parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i]\n );\n };\n\n // 格式化开机时间\n const formatUptime = (seconds: number): string => {\n if (seconds === 0) return \"-\";\n\n const days = Math.floor(seconds / 86400);\n const hours = Math.floor((seconds % 86400) / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n\n if (days > 0) {\n return `${days}天${hours}小时`;\n } else if (hours > 0) {\n return `${hours}小时${minutes}分钟`;\n } else {\n return `${minutes}分钟`;\n }\n };\n\n // 格式化流量\n const formatTraffic = (bytes: number): string => {\n if (bytes === 0) return \"0 B\";\n\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n };\n\n // 获取进度条颜色\n const getProgressColor = (\n value: number,\n offline = false,\n ): \"default\" | \"primary\" | \"secondary\" | \"success\" | \"warning\" | \"danger\" => {\n if (offline) return \"default\";\n if (value <= 50) return \"success\";\n if (value <= 80) return \"warning\";\n\n return \"danger\";\n };\n\n // IPv4/IPv6 格式验证(仅用于判定地址族)\n const ipv4Regex =\n /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;\n const ipv6Regex =\n /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;\n\n const validateIpv4Literal = (ip: string): boolean =>\n ipv4Regex.test(ip.trim());\n const validateIpv6Literal = (ip: string): boolean =>\n ipv6Regex.test(ip.trim());\n\n // Hostname/domain validation (no scheme/port)\n const hostnameRegex =\n /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;\n const validateHostname = (host: string): boolean => {\n const v = host.trim();\n\n if (!v) return false;\n if (v === \"localhost\") return true;\n\n return hostnameRegex.test(v);\n };\n\n // 验证端口格式:支持 80,443,100-600\n const validatePort = (\n portStr: string,\n ): { valid: boolean; error?: string } => {\n if (!portStr || !portStr.trim()) {\n return { valid: false, error: \"请输入端口\" };\n }\n\n const trimmed = portStr.trim();\n const parts = trimmed\n .split(\",\")\n .map((p) => p.trim())\n .filter((p) => p);\n\n if (parts.length === 0) {\n return { valid: false, error: \"请输入有效的端口\" };\n }\n\n for (const part of parts) {\n // 检查是否是端口范围 (如 100-600)\n if (part.includes(\"-\")) {\n const range = part.split(\"-\").map((p) => p.trim());\n\n if (range.length !== 2) {\n return { valid: false, error: `端口范围格式错误: ${part}` };\n }\n\n const start = parseInt(range[0]);\n const end = parseInt(range[1]);\n\n if (isNaN(start) || isNaN(end)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (start < 1 || start > 65535 || end < 1 || end > 65535) {\n return {\n valid: false,\n error: `端口范围必须在 1-65535 之间: ${part}`,\n };\n }\n\n if (start >= end) {\n return { valid: false, error: `起始端口必须小于结束端口: ${part}` };\n }\n } else {\n // 单个端口\n const port = parseInt(part);\n\n if (isNaN(port)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (port < 1 || port > 65535) {\n return { valid: false, error: `端口必须在 1-65535 之间: ${part}` };\n }\n }\n }\n\n return { valid: true };\n };\n\n // 表单验证\n const validateForm = (): boolean => {\n const newErrors: Record<string, string> = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入节点名称\";\n } else if (form.name.trim().length < 2) {\n newErrors.name = \"节点名称长度至少2位\";\n } else if (form.name.trim().length > 50) {\n newErrors.name = \"节点名称长度不能超过50位\";\n }\n\n if (\n (form.renewalCycle && !form.expiryTime) ||\n (!form.renewalCycle && form.expiryTime)\n ) {\n newErrors.expiryTime = \"请同时设置续费周期和续费基准时间\";\n }\n\n const v4 = form.serverIpV4.trim();\n const v6 = form.serverIpV6.trim();\n const host = form.serverHost.trim();\n\n if (!v4 && !v6 && !host) {\n const msg = \"请至少填写一个 IPv4/IPv6 地址或域名\";\n\n newErrors.serverIpV4 = msg;\n newErrors.serverIpV6 = msg;\n newErrors.serverHost = msg;\n } else {\n if (v4 && !validateIpv4Literal(v4)) {\n newErrors.serverIpV4 = \"请输入有效的IPv4地址\";\n }\n if (v6 && !validateIpv6Literal(v6)) {\n newErrors.serverIpV6 = \"请输入有效的IPv6地址\";\n }\n if (host && !validateHostname(host)) {\n newErrors.serverHost = \"请输入有效的域名/主机名\";\n }\n }\n\n const portValidation = validatePort(form.port);\n\n if (!portValidation.valid) {\n newErrors.port = portValidation.error || \"端口格式错误\";\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增节点\n const handleAdd = () => {\n setDialogTitle(\"新增节点\");\n setIsEdit(false);\n setDialogVisible(true);\n resetForm();\n setProtocolDisabled(true);\n setProtocolDisabledReason(\"节点未在线,等待节点上线后再设置\");\n };\n\n // 编辑节点\n const handleEdit = (node: Node) => {\n setDialogTitle(\"编辑节点\");\n setIsEdit(true);\n\n const legacy = (node.serverIp || \"\").trim();\n const normalizedV4 =\n node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : \"\");\n const normalizedV6 =\n node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : \"\");\n const normalizedHost =\n !normalizedV4 && !normalizedV6 && legacy ? legacy : \"\";\n\n setForm({\n id: node.id,\n name: node.name,\n remark: node.remark || \"\",\n tags: node.tags || \"\",\n expiryTime: node.expiryTime || 0,\n renewalCycle: node.renewalCycle || \"\",\n serverHost: normalizedHost,\n serverIpV4: normalizedV4,\n serverIpV6: normalizedV6,\n port: node.port || \"1000-65535\",\n tcpListenAddr: node.tcpListenAddr || \"[::]\",\n udpListenAddr: node.udpListenAddr || \"[::]\",\n interfaceName: (node as any).interfaceName || \"\",\n extraIPs: node.extraIPs || \"\",\n http: typeof node.http === \"number\" ? node.http : 1,\n tls: typeof node.tls === \"number\" ? node.tls : 1,\n socks: typeof node.socks === \"number\" ? node.socks : 1,\n });\n const offline = node.connectionStatus !== \"online\";\n\n setProtocolDisabled(offline);\n setProtocolDisabledReason(\n offline ? \"节点未在线,等待节点上线后再设置\" : \"\",\n );\n setDialogVisible(true);\n };\n\n // 删除节点\n const handleDelete = (node: Node) => {\n setNodeToDelete(node);\n setDeleteModalOpen(true);\n };\n\n const confirmDelete = async () => {\n if (!nodeToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteNode(nodeToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id));\n setDeleteModalOpen(false);\n setNodeToDelete(null);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n const openInstallSelector = (node: Node) => {\n setInstallTargetNode(node);\n setInstallChannel(\"stable\");\n setInstallSelectorOpen(true);\n };\n\n // 复制安装命令\n const handleCopyInstallCommand = async (\n node: Node,\n channel: ReleaseChannel,\n ) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),\n );\n\n try {\n const res = await getNodeInstallCommand(node.id, channel);\n\n if (res.code === 0 && res.data) {\n const copied = await tryCopyInstallCommand(res.data);\n\n if (copied) {\n toast.success(\n `${channel === \"stable\" ? \"正式版\" : \"测试版\"}安装命令已复制到剪贴板`,\n );\n } else {\n setInstallCommand(res.data);\n setCurrentNodeName(node.name);\n setInstallCommandModal(true);\n }\n } else {\n toast.error(res.msg || \"获取安装命令失败\");\n }\n } catch {\n toast.error(\"获取安装命令失败\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)),\n );\n }\n };\n\n const handleConfirmInstallCommand = async () => {\n if (!installTargetNode) return;\n\n setInstallSelectorOpen(false);\n await handleCopyInstallCommand(installTargetNode, installChannel);\n };\n\n // 手动复制安装命令\n const handleManualCopy = async () => {\n try {\n await navigator.clipboard.writeText(installCommand);\n toast.success(\"安装命令已复制到剪贴板\");\n setInstallCommandModal(false);\n } catch {\n toast.error(\"复制失败,请手动选择文本复制\");\n }\n };\n\n const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {\n setReleasesLoading(true);\n try {\n const res = await getNodeReleases(channel);\n\n if (res.code === 0 && Array.isArray(res.data)) {\n setReleases(res.data);\n } else {\n toast.error(res.msg || \"获取版本列表失败\");\n }\n } catch {\n toast.error(\"获取版本列表失败\");\n } finally {\n setReleasesLoading(false);\n }\n }, []);\n\n // 打开版本选择弹窗\n const openUpgradeModal = async (\n target: \"single\" | \"batch\",\n nodeId?: number,\n ) => {\n const defaultChannel: ReleaseChannel = \"stable\";\n\n setUpgradeTarget(target);\n setUpgradeTargetNodeId(nodeId || null);\n setReleaseChannel(defaultChannel);\n setSelectedVersion(\"\");\n setUpgradeModalOpen(true);\n await loadReleasesByChannel(defaultChannel);\n };\n\n // 确认升级(从版本弹窗)\n const handleConfirmUpgrade = async () => {\n const version = selectedVersion || undefined;\n\n if (upgradeTarget === \"single\" && upgradeTargetNodeId) {\n setUpgradeModalOpen(false);\n // Find the node\n const node = nodeList.find((n) => n.id === upgradeTargetNodeId);\n\n if (!node) return;\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,\n ),\n );\n try {\n const res = await upgradeNode(\n upgradeTargetNodeId,\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`节点升级命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,\n ),\n );\n }\n } else if (upgradeTarget === \"batch\") {\n setBatchUpgradeLoading(true);\n setUpgradeModalOpen(false);\n try {\n const res = await batchUpgradeNodes(\n Array.from(selectedIds),\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);\n } else {\n toast.error(res.msg || \"批量升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchUpgradeLoading(false);\n }\n }\n };\n\n // 回退节点\n const handleRollbackNode = async (node: Node) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),\n );\n try {\n const res = await rollbackNode(node.id);\n\n if (res.code === 0) {\n toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"回退失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === node.id ? { ...n, rollbackLoading: false } : n,\n ),\n );\n }\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n\n try {\n const apiCall = isEdit ? updateNode : createNode;\n const { serverHost, ...rest } = form;\n const data = {\n ...rest,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n extraIPs: form.extraIPs,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n serverHost?.trim() ||\n \"\",\n };\n\n const res = await apiCall(data);\n\n if (res.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n setDialogVisible(false);\n\n if (isEdit) {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === form.id\n ? {\n ...n,\n name: form.name,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n form.serverHost?.trim() ||\n \"\",\n serverIpV4: form.serverIpV4,\n serverIpV6: form.serverIpV6,\n port: form.port,\n tcpListenAddr: form.tcpListenAddr,\n udpListenAddr: form.udpListenAddr,\n interfaceName: form.interfaceName,\n http: form.http,\n tls: form.tls,\n socks: form.socks,\n }\n : n,\n ),\n );\n } else {\n loadNodes();\n }\n } else {\n toast.error(res.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 重置表单\n const resetForm = () => {\n setForm({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n setErrors({});\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n if (!nodeOrder || nodeOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const oldIndex = nodeOrder.indexOf(activeId);\n const newIndex = nodeOrder.indexOf(overId);\n\n if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;\n\n const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);\n\n setNodeOrder(newOrder);\n\n saveOrder(\"node-order\", newOrder);\n\n // 持久化到数据库\n try {\n const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));\n const response = await updateNodeOrder({ nodes: nodesToUpdate });\n\n if (response.code === 0) {\n setNodeList((prev) =>\n prev.map((node) => {\n const updated = nodesToUpdate.find((n) => n.id === node.id);\n\n return updated ? { ...node, inx: updated.inx } : node;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n };\n\n // 批量操作处理函数\n const toggleSelectMode = () => {\n setSelectMode((prev) => {\n if (prev) {\n setSelectedIds(new Set());\n }\n\n return !prev;\n });\n };\n\n const toggleSelect = (id: number) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n };\n\n const selectAll = () => {\n setSelectedIds(new Set(sortedNodes.map((n) => n.id)));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n try {\n const res = await batchDeleteNodes(Array.from(selectedIds));\n\n if (res.code === 0) {\n toast.success(`成功删除 ${selectedIds.size} 个节点`);\n setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id)));\n setSelectedIds(new Set());\n setBatchDeleteModalOpen(false);\n setSelectMode(false);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchLoading(false);\n }\n };\n\n // 传感器配置\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n const nodeExpiryStats = useMemo(() => {\n return nodeList.reduce(\n (acc, node) => {\n const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);\n\n if (meta.state === \"expired\") acc.expired += 1;\n if (meta.state === \"expiringSoon\") acc.expiringSoon += 1;\n if (getNodeReminderEnabled(node)) {\n acc.withExpiry += 1;\n }\n\n return acc;\n },\n { expired: 0, expiringSoon: 0, withExpiry: 0 },\n );\n }, [nodeList]);\n\n // 根据排序顺序获取节点列表\n const sortedNodes = useMemo((): Node[] => {\n if (!nodeList || nodeList.length === 0) return [];\n\n let filteredNodes = nodeList;\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredNodes = filteredNodes.filter(\n (n) =>\n (n.name && n.name.toLowerCase().includes(lowerKeyword)) ||\n (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||\n (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n if (nodeFilterMode !== \"all\") {\n filteredNodes = filteredNodes.filter((node) => {\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n switch (nodeFilterMode) {\n case \"expiringSoon\":\n return expiryMeta.state === \"expiringSoon\";\n case \"expired\":\n return expiryMeta.state === \"expired\";\n case \"withExpiry\":\n return getNodeReminderEnabled(node);\n default:\n return true;\n }\n });\n }\n\n const sortedByDb = [...filteredNodes].sort((a, b) => {\n const expiryDiff =\n getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -\n getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;\n\n if (expiryDiff !== 0) {\n return expiryDiff;\n }\n\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n return aInx - bInx;\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n nodeOrder &&\n nodeOrder.length > 0 &&\n sortedByDb.every((n) => n.inx === undefined || n.inx === 0)\n ) {\n const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));\n const localSorted: Node[] = [];\n\n nodeOrder.forEach((id) => {\n const node = nodeMap.get(id);\n\n if (node) localSorted.push(node);\n });\n\n filteredNodes.forEach((node) => {\n if (!nodeOrder.includes(node.id)) {\n localSorted.push(node);\n }\n });\n\n return localSorted;\n }\n\n return sortedByDb;\n }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);\n\n const sortableNodeIds = useMemo(\n () => sortedNodes.map((n) => n.id),\n [sortedNodes],\n );\n\n return (\n <AnimatedPage className=\"px-3 lg:px-6 py-8\">\n <div className=\"flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3\">\n <div className=\"flex flex-1 flex-col gap-3 lg:flex-row lg:items-center\">\n <SearchBar\n isVisible={isSearchVisible}\n placeholder=\"搜索名称、IP、备注或标签\"\n value={searchKeyword}\n onChange={setSearchKeyword}\n onClose={() => setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n\n <Select\n className=\"w-full max-w-sm lg:w-56\"\n label=\"到期筛选\"\n selectedKeys={[nodeFilterMode]}\n size=\"sm\"\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as\n | NodeFilterMode\n | undefined;\n\n setNodeFilterMode(selected || \"all\");\n }}\n >\n <SelectItem key=\"all\" textValue=\"全部节点\">\n 全部节点\n </SelectItem>\n <SelectItem key=\"expiringSoon\" textValue=\"7天内到期\">\n 7天内续费 ({nodeExpiryStats.expiringSoon})\n </SelectItem>\n <SelectItem key=\"expired\" textValue=\"已过期\">\n 已逾期 ({nodeExpiryStats.expired})\n </SelectItem>\n <SelectItem key=\"withExpiry\" textValue=\"已设置到期时间\">\n 已启用续费提醒 ({nodeExpiryStats.withExpiry})\n </SelectItem>\n </Select>\n </div>\n\n <div className=\"min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x\">\n <div className=\"flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0\">\n {selectMode ? (\n <>\n <span className=\"text-sm text-default-600 shrink-0\">\n 已选 {selectedIds.size} 项\n </span>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={selectAll}\n >\n 全选\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={deselectAll}\n >\n 清空\n </Button>\n <Button\n color=\"warning\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchUpgradeLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => openUpgradeModal(\"batch\")}\n >\n 升级\n </Button>\n <Button\n color=\"danger\"\n isDisabled={selectedIds.size === 0}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setBatchDeleteModalOpen(true)}\n >\n 删除\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"solid\"\n onPress={toggleSelectMode}\n >\n 退出\n </Button>\n </>\n ) : (\n <>\n <Button\n className=\"bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45\"\n color=\"default\"\n size=\"sm\"\n variant=\"flat\"\n onPress={toggleSelectMode}\n >\n 批量\n </Button>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleAdd}\n >\n 新增\n </Button>\n </>\n )}\n </div>\n </div>\n </div>\n\n {!wsConnected && (\n <Alert\n className=\"mb-4\"\n color=\"warning\"\n description={\n wsConnecting\n ? \"监控连接中...\"\n : usingPollingFallback\n ? \"监控连接已断开,已切换为列表自动刷新兜底模式。\"\n : \"监控连接已断开,正在重连...\"\n }\n variant=\"flat\"\n />\n )}\n\n {/* 节点列表 */}\n {loading ? (\n <PageLoadingState message=\"正在加载...\" />\n ) : nodeList.length === 0 ? (\n <PageEmptyState\n className=\"h-64\"\n message=\"暂无节点配置,点击上方按钮开始创建\"\n />\n ) : (\n <DndContext sensors={sensors} onDragEnd={handleDragEnd}>\n <SortableContext\n items={sortableNodeIds}\n strategy={rectSortingStrategy}\n >\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4\">\n {sortedNodes.map((node) => {\n const isRemoteNode = node.isRemote === 1;\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n return (\n <SortableItem key={node.id} id={node.id}>\n {(listeners) => (\n <Card\n key={node.id}\n className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full flex flex-col ${expiryMeta.accentClassName}`}\n >\n <CardHeader className=\"pb-2 md:pb-2\">\n <div className=\"flex justify-between items-start w-full\">\n <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n {selectMode && (\n <Checkbox\n isSelected={selectedIds.has(node.id)}\n onValueChange={() => toggleSelect(node.id)}\n />\n )}\n <h3 className=\"font-semibold text-foreground truncate text-sm\">\n {node.name}\n </h3>\n </div>\n <div className=\"flex items-center gap-1.5 ml-2\">\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n <Chip\n className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n color={expiryMeta.tone}\n size=\"sm\"\n title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}\n variant=\"flat\"\n >\n {expiryMeta.label}\n </Chip>\n )}\n <div\n className=\"cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100\"\n {...listeners}\n style={{ touchAction: \"none\" }}\n title=\"拖拽排序\"\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n {isRemoteNode && (\n <Chip\n className=\"text-[10px] h-5 px-1 flex-shrink-0\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n 远程\n </Chip>\n )}\n {(() => {\n const connectionStatusMeta =\n getConnectionStatusMeta(\n node.connectionStatus,\n );\n\n return (\n <Chip\n className=\"text-[10px] h-5 px-1\"\n color={connectionStatusMeta.color}\n size=\"sm\"\n variant=\"flat\"\n >\n {connectionStatusMeta.text}\n </Chip>\n );\n })()}\n </div>\n </div>\n </CardHeader>\n\n <CardBody className=\"pt-0 pb-3 md:pt-0 md:pb-3 flex-1 flex flex-col\">\n {isRemoteNode && node.syncError && (\n <div className=\"mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs\">\n {getRemoteSyncErrorMessage(node.syncError)}\n </div>\n )}\n {expiryMeta.isHighlighted && (\n <div\n className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}\n >\n <span>节点到期提醒</span>\n <span>{expiryMeta.label}</span>\n </div>\n )}\n {/* 基础信息 */}\n <div className=\"space-y-2 mb-4\">\n {(node.remark?.trim() || node.tags?.trim()) && (\n <div className=\"space-y-1.5\">\n {node.remark?.trim() && (\n <div className=\"rounded-md border border-divider/80 bg-default-50/80 px-2.5 py-1.5\">\n <div\n className=\"text-xs leading-5 text-default-600 line-clamp-1 break-all\"\n title={node.remark.trim()}\n >\n {node.remark.trim()}\n </div>\n </div>\n )}\n {normalizeNodeTags(node.tags).length > 0 && (\n <div className=\"flex flex-wrap gap-1.5\">\n {normalizeNodeTags(node.tags).map((tag) => (\n <Chip\n key={`${node.id}-${tag}`}\n className=\"text-[10px] h-5 px-1\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n #{tag}\n </Chip>\n ))}\n </div>\n )}\n </div>\n )}\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && <div className=\"hidden\" />}\n <div className=\"flex justify-between items-center text-sm min-w-0\">\n <span className=\"text-default-600 flex-shrink-0\">\n IP\n </span>\n <div className=\"text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]\">\n {node.serverIpV4?.trim() ||\n node.serverIpV6?.trim() ? (\n <div className=\"space-y-0.5\">\n {node.serverIpV4?.trim() && (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIpV4.trim()}\n >\n {node.serverIpV4.trim()}\n </span>\n )}\n {node.serverIpV6?.trim() && (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIpV6.trim()}\n >\n {node.serverIpV6.trim()}\n </span>\n )}\n </div>\n ) : (\n <span\n className=\"font-mono truncate block\"\n title={node.serverIp.trim()}\n >\n {node.serverIp.trim()}\n </span>\n )}\n </div>\n </div>\n {!isRemoteNode && (\n <>\n <div className=\"flex justify-between text-sm\">\n <span className=\"text-default-600\">版本</span>\n <span className=\"text-xs\">\n {node.version || \"未知\"}\n </span>\n </div>\n {upgradeProgress[node.id] &&\n upgradeProgress[node.id].percent < 100 && (\n <div className=\"mt-1\">\n <Progress\n showValueLabel\n aria-label=\"升级进度\"\n color=\"warning\"\n label={upgradeProgress[node.id].message}\n size=\"sm\"\n value={upgradeProgress[node.id].percent}\n />\n </div>\n )}\n <div className=\"flex justify-between text-sm\">\n <span className=\"text-default-600\">\n 开机时间\n </span>\n <span className=\"text-xs\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatUptime(node.systemInfo.uptime)\n : \"-\"}\n </span>\n </div>\n </>\n )}\n </div>\n\n {!isRemoteNode && (\n <>\n {/* 系统监控 */}\n <div className=\"space-y-3 mb-4\">\n <div className=\"grid grid-cols-2 gap-3\">\n <div>\n <div className=\"flex justify-between text-xs mb-1\">\n <span>CPU</span>\n <span className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.cpuUsage.toFixed(1)}%`\n : \"-\"}\n </span>\n </div>\n <Progress\n aria-label=\"CPU使用率\"\n color={getProgressColor(\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.cpuUsage\n : 0,\n node.connectionStatus !== \"online\",\n )}\n size=\"sm\"\n value={\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.cpuUsage\n : 0\n }\n />\n </div>\n <div>\n <div className=\"flex justify-between text-xs mb-1\">\n <span>内存</span>\n <span className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.memoryUsage.toFixed(1)}%`\n : \"-\"}\n </span>\n </div>\n <Progress\n aria-label=\"内存使用率\"\n color={getProgressColor(\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.memoryUsage\n : 0,\n node.connectionStatus !== \"online\",\n )}\n size=\"sm\"\n value={\n node.connectionStatus === \"online\" &&\n node.systemInfo\n ? node.systemInfo.memoryUsage\n : 0\n }\n />\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-2 text-xs\">\n <div className=\"text-center p-2 bg-default-50 dark:bg-default-100 rounded\">\n <div className=\"text-default-600 mb-0.5\">\n 上传\n </div>\n <div className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.uploadSpeed,\n )\n : \"-\"}\n </div>\n </div>\n <div className=\"text-center p-2 bg-default-50 dark:bg-default-100 rounded\">\n <div className=\"text-default-600 mb-0.5\">\n 下载\n </div>\n <div className=\"font-mono\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.downloadSpeed,\n )\n : \"-\"}\n </div>\n </div>\n </div>\n\n {/* 流量统计 */}\n <div className=\"grid grid-cols-2 gap-2 text-xs\">\n <div className=\"text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20\">\n <div className=\"text-primary-600 dark:text-primary-400 mb-0.5\">\n ↑ 上行流量\n </div>\n <div className=\"font-mono text-primary-700 dark:text-primary-300\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.uploadTraffic,\n )\n : \"-\"}\n </div>\n </div>\n <div className=\"text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20\">\n <div className=\"text-success-600 dark:text-success-400 mb-0.5\">\n ↓ 下行流量\n </div>\n <div className=\"font-mono text-success-700 dark:text-success-300\">\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.downloadTraffic,\n )\n : \"-\"}\n </div>\n </div>\n </div>\n </div>\n </>\n )}\n\n {/* 操作按钮 */}\n <div className=\"space-y-1.5 mt-auto\">\n {!isRemoteNode && (\n <div className=\"grid grid-cols-3 gap-1.5\">\n <Button\n className=\"min-h-8\"\n color=\"success\"\n isLoading={node.copyLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => openInstallSelector(node)}\n >\n 安装\n </Button>\n <Button\n className=\"min-h-8\"\n color=\"warning\"\n isDisabled={\n node.connectionStatus !== \"online\"\n }\n isLoading={node.upgradeLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() =>\n openUpgradeModal(\"single\", node.id)\n }\n >\n 升级\n </Button>\n <Button\n className=\"min-h-8\"\n color=\"secondary\"\n isDisabled={\n node.connectionStatus !== \"online\"\n }\n isLoading={node.rollbackLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleRollbackNode(node)}\n >\n 回退\n </Button>\n </div>\n )}\n <div\n className={`grid gap-1.5 ${isRemoteNode ? \"grid-cols-1\" : \"grid-cols-2\"}`}\n >\n {!isRemoteNode && (\n <Button\n className=\"min-h-8\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleEdit(node)}\n >\n 编辑\n </Button>\n )}\n <Button\n className=\"min-h-8\"\n color=\"danger\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleDelete(node)}\n >\n 删除\n </Button>\n </div>\n </div>\n </CardBody>\n </Card>\n )}\n </SortableItem>\n );\n })}\n </div>\n </SortableContext>\n </DndContext>\n )}\n\n {/* 新增/编辑节点对话框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={dialogVisible}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setDialogVisible(false)}\n >\n <ModalContent>\n <ModalHeader>{dialogTitle}</ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <Input\n errorMessage={errors.name}\n isInvalid={!!errors.name}\n label=\"节点名称\"\n placeholder=\"请输入节点名称\"\n value={form.name}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n <Textarea\n description=\"可记录供应商、用途、续费说明等补充信息\"\n label=\"备注\"\n maxRows={4}\n minRows={3}\n placeholder=\"例如: 搬瓦工年付,2026-12 续费,日本中转\"\n value={form.remark}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, remark: e.target.value }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n description=\"多个标签用逗号分隔,可用于搜索过滤\"\n label=\"标签\"\n placeholder=\"例如: 年付,日本中转,测试机\"\n value={form.tags}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, tags: e.target.value }))\n }\n />\n\n <Select\n label=\"续费周期\"\n placeholder=\"选择续费周期\"\n selectedKeys={form.renewalCycle ? [form.renewalCycle] : []}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as\n | NodeRenewalCycle\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n renewalCycle: selected || \"\",\n }));\n }}\n >\n <SelectItem key=\"month\" textValue=\"month\">\n 月付\n </SelectItem>\n <SelectItem key=\"quarter\" textValue=\"quarter\">\n 季付\n </SelectItem>\n <SelectItem key=\"year\" textValue=\"year\">\n 年付\n </SelectItem>\n </Select>\n </div>\n\n <Input\n description=\"填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费\"\n errorMessage={errors.expiryTime}\n isInvalid={!!errors.expiryTime}\n label=\"续费基准时间\"\n type=\"datetime-local\"\n value={\n form.expiryTime > 0\n ? new Date(form.expiryTime).toISOString().slice(0, 16)\n : \"\"\n }\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n expiryTime: e.target.value\n ? new Date(e.target.value).getTime()\n : 0,\n }))\n }\n />\n\n <Alert\n color=\"primary\"\n description=\"例如选择月付并填写 2026-03-01,系统会自动按每月同日推算下次续费时间。\"\n variant=\"flat\"\n />\n\n <Input\n description=\"可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名\"\n errorMessage={errors.serverHost}\n isInvalid={!!errors.serverHost}\n label=\"服务器域名/主机名\"\n placeholder=\"例如: node.example.com\"\n value={form.serverHost}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverHost: e.target.value }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n description=\"双栈节点组隧道时优先使用 IPv4\"\n errorMessage={errors.serverIpV4}\n isInvalid={!!errors.serverIpV4}\n label=\"服务器IPv4\"\n placeholder=\"例如: 203.0.113.10\"\n value={form.serverIpV4}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverIpV4: e.target.value }))\n }\n />\n\n <Input\n description=\"至少填写一个 IPv4/IPv6/域名\"\n errorMessage={errors.serverIpV6}\n isInvalid={!!errors.serverIpV6}\n label=\"服务器IPv6\"\n placeholder=\"例如: 2001:db8::10\"\n value={form.serverIpV6}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, serverIpV6: e.target.value }))\n }\n />\n </div>\n\n <Input\n classNames={{\n input: \"font-mono\",\n }}\n description=\"支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔\"\n errorMessage={errors.port}\n isInvalid={!!errors.port}\n label=\"可用端口\"\n placeholder=\"例如: 80,443,1000-65535\"\n value={form.port}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, port: e.target.value }))\n }\n />\n\n {/* 高级配置 */}\n <Accordion variant=\"bordered\">\n <AccordionItem\n key=\"advanced\"\n aria-label=\"高级配置\"\n title=\"高级配置\"\n >\n <div className=\"space-y-4 pb-2\">\n <Input\n description=\"用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行\"\n errorMessage={errors.interfaceName}\n isInvalid={!!errors.interfaceName}\n label=\"出口网卡名或IP\"\n placeholder=\"请输入出口网卡名或IP\"\n value={form.interfaceName}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n interfaceName: e.target.value,\n }))\n }\n />\n\n <Input\n description=\"多IP服务器可填写额外IP地址,逗号分隔\"\n label=\"额外IP地址\"\n placeholder=\"例如: 192.168.1.100, 10.0.0.5\"\n value={form.extraIPs}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n extraIPs: e.target.value,\n }))\n }\n />\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <Input\n errorMessage={errors.tcpListenAddr}\n isInvalid={!!errors.tcpListenAddr}\n label=\"TCP监听地址\"\n placeholder=\"请输入TCP监听地址\"\n startContent={\n <div className=\"pointer-events-none flex items-center\">\n <span className=\"text-default-400 text-small\">\n TCP\n </span>\n </div>\n }\n value={form.tcpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n tcpListenAddr: e.target.value,\n }))\n }\n />\n\n <Input\n errorMessage={errors.udpListenAddr}\n isInvalid={!!errors.udpListenAddr}\n label=\"UDP监听地址\"\n placeholder=\"请输入UDP监听地址\"\n startContent={\n <div className=\"pointer-events-none flex items-center\">\n <span className=\"text-default-400 text-small\">\n UDP\n </span>\n </div>\n }\n value={form.udpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n udpListenAddr: e.target.value,\n }))\n }\n />\n </div>\n {/* 屏蔽协议 */}\n <div>\n <div className=\"text-sm font-medium text-default-700 mb-2\">\n 屏蔽协议\n </div>\n <div className=\"text-xs text-default-500 mb-2\">\n 开启开关以屏蔽对应协议\n </div>\n {protocolDisabled && (\n <Alert\n className=\"mb-2\"\n color=\"warning\"\n description={\n protocolDisabledReason || \"等待节点上线后再设置\"\n }\n variant=\"flat\"\n />\n )}\n <div\n className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-default-50 dark:bg-default-100 p-3 rounded-md border border-default-200 dark:border-default-100/30 ${protocolDisabled ? \"opacity-70\" : \"\"}`}\n >\n {/* HTTP tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <rect height=\"16\" rx=\"2\" width=\"20\" x=\"2\" y=\"4\" />\n <path d=\"M2 10h20\" />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n HTTP\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.http === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({\n ...prev,\n http: v ? 1 : 0,\n }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.http === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n\n {/* TLS tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M6 10V7a6 6 0 1 1 12 0v3\" />\n <rect\n height=\"10\"\n rx=\"2\"\n width=\"16\"\n x=\"4\"\n y=\"10\"\n />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n TLS\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.tls === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({ ...prev, tls: v ? 1 : 0 }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.tls === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n\n {/* SOCKS tile */}\n <div className=\"px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors\">\n <div className=\"flex items-center gap-2 mb-2\">\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4 text-default-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n <polyline points=\"7 10 12 15 17 10\" />\n <line x1=\"12\" x2=\"12\" y1=\"15\" y2=\"3\" />\n </svg>\n <div className=\"text-sm font-medium text-default-700\">\n SOCKS\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <div className=\"text-xs text-default-500\">\n 禁用/启用\n </div>\n <Switch\n isDisabled={protocolDisabled}\n isSelected={form.socks === 1}\n size=\"sm\"\n onValueChange={(v) =>\n setForm((prev) => ({\n ...prev,\n socks: v ? 1 : 0,\n }))\n }\n />\n </div>\n <div className=\"mt-1 text-xs text-default-400\">\n {form.socks === 1 ? \"已开启\" : \"已关闭\"}\n </div>\n </div>\n </div>\n </div>\n\n <Alert\n color=\"danger\"\n description=\"请不要在出口节点执行屏蔽协议,否则可能影响转发;屏蔽协议仅需在入口节点执行。\"\n variant=\"flat\"\n />\n </div>\n </AccordionItem>\n </Accordion>\n\n <Alert\n className=\"mt-4\"\n color=\"primary\"\n description=\"服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。\"\n variant=\"flat\"\n />\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"flat\" onPress={() => setDialogVisible(false)}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isLoading={submitLoading}\n onPress={handleSubmit}\n >\n {submitLoading ? \"提交中...\" : \"确定\"}\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={deleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onOpenChange={setDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p>\n 确定要删除节点{\" \"}\n <strong>&quot;{nodeToDelete?.name}&quot;</strong> 吗?\n </p>\n <p className=\"text-small text-default-500\">\n 此操作不可恢复,请谨慎操作。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={deleteLoading}\n onPress={confirmDelete}\n >\n {deleteLoading ? \"删除中...\" : \"确认删除\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n <Modal\n backdrop=\"blur\"\n isOpen={installSelectorOpen}\n placement=\"center\"\n size=\"md\"\n onOpenChange={setInstallSelectorOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n 选择安装通道\n {installTargetNode ? ` - ${installTargetNode.name}` : \"\"}\n </h2>\n </ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <Select\n label=\"版本通道\"\n selectedKeys={[installChannel]}\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as ReleaseChannel;\n\n setInstallChannel(selected || \"stable\");\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 正式版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 测试版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button color=\"primary\" onPress={handleConfirmInstallCommand}>\n 生成命令\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 安装命令模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={installCommandModal}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setInstallCommandModal(false)}\n >\n <ModalContent>\n <ModalHeader>安装命令 - {currentNodeName}</ModalHeader>\n <ModalBody>\n <div className=\"space-y-4\">\n <p className=\"text-sm text-default-600\">\n 请复制以下安装命令到服务器上执行:\n </p>\n <div className=\"relative\">\n <Textarea\n readOnly\n className=\"font-mono text-sm\"\n classNames={{\n input: \"font-mono text-sm\",\n }}\n maxRows={10}\n minRows={6}\n value={installCommand}\n variant=\"bordered\"\n />\n <Button\n className=\"absolute top-2 right-2\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleManualCopy}\n >\n 复制\n </Button>\n </div>\n <div className=\"text-xs text-default-500\">\n 💡 提示:如果复制按钮失效,请手动选择上方文本进行复制\n </div>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button\n variant=\"flat\"\n onPress={() => setInstallCommandModal(false)}\n >\n 关闭\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 版本选择升级模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={upgradeModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"md\"\n onOpenChange={setUpgradeModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n {upgradeTarget === \"batch\"\n ? `批量升级 (${selectedIds.size} 个节点)`\n : \"升级节点\"}\n </h2>\n </ModalHeader>\n <ModalBody>\n {releasesLoading ? (\n <div className=\"flex justify-center py-8\">\n <Spinner size=\"lg\" />\n </div>\n ) : (\n <div className=\"space-y-4\">\n <Select\n label=\"版本通道\"\n selectedKeys={[releaseChannel]}\n onSelectionChange={(keys) => {\n const selected =\n (Array.from(keys)[0] as ReleaseChannel) || \"stable\";\n\n setReleaseChannel(selected);\n setSelectedVersion(\"\");\n void loadReleasesByChannel(selected);\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 正式版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 测试版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n <Select\n label=\"选择版本\"\n placeholder=\"留空则使用当前通道最新版本\"\n selectedKeys={selectedVersion ? [selectedVersion] : []}\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0] as string;\n\n setSelectedVersion(selected || \"\");\n }}\n >\n {releases.map((r) => (\n <SelectItem key={r.version} textValue={r.version}>\n <div className=\"flex justify-between items-center\">\n <span>{r.version}</span>\n <span className=\"text-xs text-default-400\">\n {r.publishedAt\n ? new Date(r.publishedAt).toLocaleDateString()\n : \"\"}\n {r.channel === \"dev\" && (\n <Chip\n className=\"ml-1\"\n color=\"warning\"\n size=\"sm\"\n variant=\"flat\"\n >\n 测试\n </Chip>\n )}\n </span>\n </div>\n </SelectItem>\n ))}\n </Select>\n <p className=\"text-sm text-default-500\">\n {selectedVersion\n ? `将升级到版本 ${selectedVersion}`\n : `未选择版本,将自动使用最新${releaseChannel === \"stable\" ? \"正式\" : \"测试\"}版`}\n </p>\n </div>\n )}\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"warning\"\n isDisabled={releasesLoading}\n onPress={handleConfirmUpgrade}\n >\n 确认升级\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 批量删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={batchDeleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"md\"\n onOpenChange={setBatchDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p>\n 确定要删除选中的 <strong>{selectedIds.size}</strong>{\" \"}\n 个节点吗?\n </p>\n <p className=\"text-small text-default-500\">\n 此操作不可恢复,请谨慎操作。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={batchLoading}\n onPress={handleBatchDelete}\n >\n {batchLoading ? \"删除中...\" : \"确认删除\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n </AnimatedPage>\n );\n}\n",
"additions": 2,
"deletions": 0
},
"truncated": false
},
"time": {
"start": 1773025499974,
"end": 1773025500013
}
},
"id": "prt_cd08e1b42001GX0VCW38a4pr3E",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a",
"cost": 0,
"tokens": {
"total": 30011,
"input": 934,
"output": 213,
"reasoning": 0,
"cache": {
"read": 28864,
"write": 0
}
},
"id": "prt_cd08e1b73001KTcw1yXoO02pkI",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ"
},
{
"type": "patch",
"hash": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc",
"files": [
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx"
],
"id": "prt_cd08e1c5b001X2tW6765N28K6Y",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773025500256,
"completed": 1773025537042
},
"parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 30137,
"input": 259,
"output": 118,
"reasoning": 0,
"cache": {
"read": 29760,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd08e1c60001MGJmguxKTeXd7M",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a",
"id": "prt_cd08e2a8c001knPGRcXAvGIQGP",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08e1c60001MGJmguxKTeXd7M"
},
{
"type": "reasoning",
"text": "现在让我再次尝试构建和推送 Docker 镜像。",
"time": {
"start": 1773025503885,
"end": 1773025507370
},
"id": "prt_cd08e2a8d001XYktnAN825govH",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08e1c60001MGJmguxKTeXd7M"
},
{
"type": "tool",
"callID": "call_3f4c04c5473e47188c58d08c",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .",
"description": "Build and push vite-frontend Docker image",
"timeout": 600000
},
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.0s\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.1s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#6 DONE 0.0s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.0s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.2s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.418 \n#12 0.418 > flvx@0.0.0 build\n#12 0.418 > tsc && vite build\n#12 0.418 \n#12 14.14 rolldown-vite v7.3.1 building client environment for production...\n#12 14.33 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.98 rendering chunks...\n#12 15.48 computing gzip size...\n#12 15.53 dist/manifest.webmanifest 0.43 kB\n#12 15.53 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.53 dist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\n#12 15.53 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.53 dist/assets/index-B17jGxVh.js 4,010.27 kB │ gzip: 827.77 kB\n#12 15.53 ✓ built in 1.39s\n#12 16.77 \n#12 16.77 PWA v1.2.0\n#12 16.77 mode generateSW\n#12 16.77 precache 9 entries (4044.58 KiB)\n#12 16.77 files generated\n#12 16.77 dist/sw.js\n#12 16.77 dist/workbox-8c29f6e4.js\n#12 DONE 16.9s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:4a360776a1358bf3e0b11ce4559463ebf82aefa197c6b5cd160eba164f225e8a done\n#15 exporting config sha256:7c231d03a9baac4032dd009a4349cd80ae740e1711001e1524a747d1762be049 done\n#15 exporting attestation manifest sha256:14db9dc48f0fdb5fd38e18457bfe23d20c14cd1b0b79a8c1d60f3c3a303eefd5 done\n#15 exporting manifest list sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.6s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e 2.6s done\n#15 DONE 7.3s\n",
"title": "Build and push vite-frontend Docker image",
"metadata": {
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.0s\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.1s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#6 DONE 0.0s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.0s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.2s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.418 \n#12 0.418 > flvx@0.0.0 build\n#12 0.418 > tsc && vite build\n#12 0.418 \n#12 14.14 rolldown-vite v7.3.1 building client environment for production...\n#12 14.33 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.98 rendering chunks...\n#12 15.48 computing gzip size...\n#12 15.53 dist/manifest.webmanifest 0.43 kB\n#12 15.53 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.53 dist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\n#12 15.53 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.53 dist/assets/index-B17jGxVh.js 4,010.27 kB │ gzip: 827.77 kB\n#12 15.53 ✓ built in 1.39s\n#12 16.77 \n#12 16.77 PWA v1.2.0\n#12 16.77 mode generateSW\n#12 16.77 precache 9 entries (4044.58 KiB)\n#12 16.77 files generated\n#12 16.77 dist/sw.js\n#12 16.77 dist/workbox-8c29f6e4.js\n#12 DONE 16.9s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:4a360776a1358bf3e0b11ce4559463ebf82aefa197c6b5cd160eba164f225e8a done\n#15 exporting config sha256:7c231d03a9baac4032dd009a4349cd80ae740e1711001e1524a747d1762be049 done\n#15 exporting attestation manifest sha256:14db9dc48f0fdb5fd38e18457bfe23d20c14cd1b0b79a8c1d60f3c3a303eefd5 done\n#15 exporting manifest list sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.6s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e 2.6s done\n#15 DONE 7.3s\n",
"exit": 0,
"description": "Build and push vite-frontend Docker image",
"truncated": false
},
"time": {
"start": 1773025507298,
"end": 1773025536775
}
},
"id": "prt_cd08e37dc001YqQfOC7Fg6K4Yc",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08e1c60001MGJmguxKTeXd7M"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a",
"cost": 0,
"tokens": {
"total": 30137,
"input": 259,
"output": 118,
"reasoning": 0,
"cache": {
"read": 29760,
"write": 0
}
},
"id": "prt_cd08eab0a001Gj0C7rfWuYu2Z6",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08e1c60001MGJmguxKTeXd7M"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773025537045,
"completed": 1773025545302
},
"parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 31684,
"input": 1553,
"output": 115,
"reasoning": 0,
"cache": {
"read": 30016,
"write": 0
}
},
"finish": "stop",
"id": "msg_cd08eac15001GyEg3wFqg0e0NK",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a",
"id": "prt_cd08ebc3d001T0PXxFykb0ddNH",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08eac15001GyEg3wFqg0e0NK"
},
{
"type": "reasoning",
"text": "成功!已经修复了 TypeScript 错误(添加了缺失的函数导入),并成功构建并推送了 Docker 镜像 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)。",
"time": {
"start": 1773025541181,
"end": 1773025545037
},
"id": "prt_cd08ebc3d002X0VPJQt5YNXpWy",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08eac15001GyEg3wFqg0e0NK"
},
{
"type": "text",
"text": "已成功修复构建错误并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)\n\n修复了 `src/pages/node.tsx:62` 缺失的函数导入:\n- `formatNodeRenewalTime`\n- `getNodeRenewalCycleLabel`",
"time": {
"start": 1773025545038,
"end": 1773025545038
},
"id": "prt_cd08ec2ed001IAsHlzMkfTrbur",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08eac15001GyEg3wFqg0e0NK"
},
{
"type": "step-finish",
"reason": "stop",
"snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a",
"cost": 0,
"tokens": {
"total": 31684,
"input": 1553,
"output": 115,
"reasoning": 0,
"cache": {
"read": 30016,
"write": 0
}
},
"id": "prt_cd08ecb50001doh3UrOnGkIgSj",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd08eac15001GyEg3wFqg0e0NK"
}
]
},
{
"info": {
"role": "user",
"time": {
"created": 1773026101933
},
"summary": {
"diffs": []
},
"agent": "build",
"model": {
"providerID": "zai-coding-plan",
"modelID": "glm-5"
},
"id": "msg_cd09749f9001NoVXJ4UDVnvCoH",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "text",
"text": "编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送",
"id": "prt_cd09749f9002uNbd23iQqOsGu3",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd09749f9001NoVXJ4UDVnvCoH"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773026101945,
"completed": 1773026150191
},
"parentID": "msg_cd09749f9001NoVXJ4UDVnvCoH",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 31478,
"input": 5561,
"output": 125,
"reasoning": 0,
"cache": {
"read": 25792,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd0974ab9001IY0UdT2b4QHUWc",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "61caed67021faa2beec21d9a9c4ba34cebbef899",
"id": "prt_cd09767ce001Gf05jp8OaQeBiJ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0974ab9001IY0UdT2b4QHUWc"
},
{
"type": "reasoning",
"text": "用户要求重新编译并推送 Docker 镜像。我将直接执行构建和推送命令。",
"time": {
"start": 1773026109390,
"end": 1773026119321
},
"id": "prt_cd09767ce0020oHHd0RzP21iM5",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0974ab9001IY0UdT2b4QHUWc"
},
{
"type": "tool",
"callID": "call_d20a65732da64cf1b091261d",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .",
"description": "Build and push vite-frontend Docker image",
"timeout": 600000
},
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 DONE 1.4s\n\n#3 [internal] load metadata for docker.io/library/node:20.19.0\n#3 DONE 1.4s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#5 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#5 DONE 0.0s\n\n#6 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#6 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.1s done\n#7 DONE 1.1s\n\n#8 [builder 3/6] COPY package*.json ./\n#8 CACHED\n\n#9 [builder 2/6] WORKDIR /app\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.6s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.432 \n#12 0.432 > flvx@0.0.0 build\n#12 0.432 > tsc && vite build\n#12 0.432 \n#12 14.15 rolldown-vite v7.3.1 building client environment for production...\n#12 14.34 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.99 rendering chunks...\n#12 15.46 computing gzip size...\n#12 15.51 dist/manifest.webmanifest 0.43 kB\n#12 15.51 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.51 dist/assets/index-CnmCYymI.css 115.54 kB │ gzip: 17.84 kB\n#12 15.51 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.51 dist/assets/index-BtDQkGJs.js 4,010.23 kB │ gzip: 827.74 kB\n#12 15.51 ✓ built in 1.36s\n#12 16.73 \n#12 16.73 PWA v1.2.0\n#12 16.73 mode generateSW\n#12 16.73 precache 9 entries (4044.53 KiB)\n#12 16.73 files generated\n#12 16.73 dist/sw.js\n#12 16.73 dist/workbox-8c29f6e4.js\n#12 DONE 16.8s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:56f055021f97e8b8168e1f45b5abb5f0b12f4c5023ef53e9ff643d70ff937d8d done\n#15 exporting config sha256:101683a67202e2ad0d5edc295b7b303f498a9aff5b55125f5520348ab0b3495c done\n#15 exporting attestation manifest sha256:edc472c2dd33a6f2b9890480f7c3fb1780c071f08285243bc8212c482b9ae728 done\n#15 exporting manifest list sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.8s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec 2.8s done\n#15 DONE 7.8s\n",
"title": "Build and push vite-frontend Docker image",
"metadata": {
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 DONE 1.4s\n\n#3 [internal] load metadata for docker.io/library/node:20.19.0\n#3 DONE 1.4s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#5 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#5 DONE 0.0s\n\n#6 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#6 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.1s done\n#7 DONE 1.1s\n\n#8 [builder 3/6] COPY package*.json ./\n#8 CACHED\n\n#9 [builder 2/6] WORKDIR /app\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.6s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.432 \n#12 0.432 > flvx@0.0.0 build\n#12 0.432 > tsc && vite build\n#12 0.432 \n#12 14.15 rolldown-vite v7.3.1 building client environment for production...\n#12 14.34 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.99 rendering chunks...\n#12 15.46 computing gzip size...\n#12 15.51 dist/manifest.webmanifest 0.43 kB\n#12 15.51 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.51 dist/assets/index-CnmCYymI.css 115.54 kB │ gzip: 17.84 kB\n#12 15.51 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.51 dist/assets/index-BtDQkGJs.js 4,010.23 kB │ gzip: 827.74 kB\n#12 15.51 ✓ built in 1.36s\n#12 16.73 \n#12 16.73 PWA v1.2.0\n#12 16.73 mode generateSW\n#12 16.73 precache 9 entries (4044.53 KiB)\n#12 16.73 files generated\n#12 16.73 dist/sw.js\n#12 16.73 dist/workbox-8c29f6e4.js\n#12 DONE 16.8s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:56f055021f97e8b8168e1f45b5abb5f0b12f4c5023ef53e9ff643d70ff937d8d done\n#15 exporting config sha256:101683a67202e2ad0d5edc295b7b303f498a9aff5b55125f5520348ab0b3495c done\n#15 exporting attestation manifest sha256:edc472c2dd33a6f2b9890480f7c3fb1780c071f08285243bc8212c482b9ae728 done\n#15 exporting manifest list sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.8s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec 2.8s done\n#15 DONE 7.8s\n",
"exit": 0,
"description": "Build and push vite-frontend Docker image",
"truncated": false
},
"time": {
"start": 1773026119243,
"end": 1773026149937
}
},
"id": "prt_cd0978e46001bIQ63I4a9mWW7I",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0974ab9001IY0UdT2b4QHUWc"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "61caed67021faa2beec21d9a9c4ba34cebbef899",
"cost": 0,
"tokens": {
"total": 31478,
"input": 5561,
"output": 125,
"reasoning": 0,
"cache": {
"read": 25792,
"write": 0
}
},
"id": "prt_cd0980633001rym719Z3ET7LU0",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0974ab9001IY0UdT2b4QHUWc"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773026150194,
"completed": 1773026153288
},
"parentID": "msg_cd09749f9001NoVXJ4UDVnvCoH",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 32941,
"input": 1609,
"output": 36,
"reasoning": 0,
"cache": {
"read": 31296,
"write": 0
}
},
"finish": "stop",
"id": "msg_cd0980732001AyJlwtIQC176Q0",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "61caed67021faa2beec21d9a9c4ba34cebbef899",
"id": "prt_cd0980e64001MCbR3FllNvnri2",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0980732001AyJlwtIQC176Q0"
},
{
"type": "reasoning",
"text": "构建成功完成,镜像已推送。",
"time": {
"start": 1773026152036,
"end": 1773026153032
},
"id": "prt_cd0980e64002pCmTQggvUSmlKj",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0980732001AyJlwtIQC176Q0"
},
{
"type": "text",
"text": "已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)",
"time": {
"start": 1773026153033,
"end": 1773026153033
},
"id": "prt_cd0980fd50015jamtnK6osgI38",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0980732001AyJlwtIQC176Q0"
},
{
"type": "step-finish",
"reason": "stop",
"snapshot": "61caed67021faa2beec21d9a9c4ba34cebbef899",
"cost": 0,
"tokens": {
"total": 32941,
"input": 1609,
"output": 36,
"reasoning": 0,
"cache": {
"read": 31296,
"write": 0
}
},
"id": "prt_cd098124a001qlkn2rZuKgt2PY",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0980732001AyJlwtIQC176Q0"
}
]
},
{
"info": {
"role": "user",
"time": {
"created": 1773026787572
},
"summary": {
"diffs": []
},
"agent": "build",
"model": {
"providerID": "zai-coding-plan",
"modelID": "glm-5"
},
"id": "msg_cd0a1c0b4001cSh2gogvuLZ5zm",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "text",
"text": "编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送",
"id": "prt_cd0a1c0b4002Miy1VXAQZPcAGD",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0a1c0b4001cSh2gogvuLZ5zm"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773026787585,
"completed": 1773026824007
},
"parentID": "msg_cd0a1c0b4001cSh2gogvuLZ5zm",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 33062,
"input": 1644,
"output": 122,
"reasoning": 0,
"cache": {
"read": 31296,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd0a1c101001txuuXScozzdjYZ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "b7337e6c039f4bfbe2c2371dc5dd65337ca209e1",
"id": "prt_cd0a1c7ee001h4u4qJGCwFT392",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0a1c101001txuuXScozzdjYZ"
},
{
"type": "reasoning",
"text": "用户再次请求构建并推送相同的Docker镜像。我直接执行命令即可。",
"time": {
"start": 1773026789359,
"end": 1773026792700
},
"id": "prt_cd0a1c7ef001lEobqFkEh46Jdm",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0a1c101001txuuXScozzdjYZ"
},
{
"type": "tool",
"callID": "call_80a8dfd19cbd44b9827b16b6",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .",
"description": "Build and push vite-frontend Docker image",
"timeout": 600000
},
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/node:20.19.0\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.1s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.8s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.433 \n#12 0.433 > flvx@0.0.0 build\n#12 0.433 > tsc && vite build\n#12 0.433 \n#12 14.22 rolldown-vite v7.3.1 building client environment for production...\n#12 14.43 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 15.11 rendering chunks...\n#12 15.64 computing gzip size...\n#12 15.69 dist/manifest.webmanifest 0.43 kB\n#12 15.69 dist/index.html 7.51 kB │ gzip: 2.51 kB\n#12 15.69 dist/assets/index-RHpHIZQG.css 115.51 kB │ gzip: 17.83 kB\n#12 15.69 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.69 dist/assets/index-CzPYPNjX.js 4,012.78 kB │ gzip: 828.11 kB\n#12 15.69 ✓ built in 1.48s\n#12 16.99 \n#12 16.99 PWA v1.2.0\n#12 16.99 mode generateSW\n#12 16.99 precache 9 entries (4046.99 KiB)\n#12 16.99 files generated\n#12 16.99 dist/sw.js\n#12 16.99 dist/workbox-8c29f6e4.js\n#12 DONE 17.1s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers\n#15 exporting layers 0.2s done\n#15 exporting manifest sha256:15918da3e65b1a1afceea74f596ea54c686db63a56dc99508c1a68f68f22b9fb\n#15 exporting manifest sha256:15918da3e65b1a1afceea74f596ea54c686db63a56dc99508c1a68f68f22b9fb done\n#15 exporting config sha256:8b439f8ffdf71de36200a2a1bc5644a2bf2ff990a7af259990082b79d6c9358f done\n#15 exporting attestation manifest sha256:4e5e440464fa21d9f5bd94d76b8389c1ef5a5ae9a2f28be14783b0607f431f73 0.0s done\n#15 exporting manifest list sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117 0.0s done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.8s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117 2.5s done\n#15 DONE 7.6s\n",
"title": "Build and push vite-frontend Docker image",
"metadata": {
"output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/node:20.19.0\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.1s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.8s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.433 \n#12 0.433 > flvx@0.0.0 build\n#12 0.433 > tsc && vite build\n#12 0.433 \n#12 14.22 rolldown-vite v7.3.1 building client environment for production...\n#12 14.43 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 15.11 rendering chunks...\n#12 15.64 computing gzip size...\n#12 15.69 dist/manifest.webmanifest 0.43 kB\n#12 15.69 dist/index.html 7.51 kB │ gzip: 2.51 kB\n#12 15.69 dist/assets/index-RHpHIZQG.css 115.51 kB │ gzip: 17.83 kB\n#12 15.69 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.69 dist/assets/index-CzPYPNjX.js 4,012.78 kB │ gzip: 828.11 kB\n#12 15.69 ✓ built in 1.48s\n#12 16.99 \n#12 16.99 PWA v1.2.0\n#12 16.99 mode generateSW\n#12 16.99 precache 9 entries (4046.99 KiB)\n#12 16.99 files generated\n#12 16.99 dist/sw.js\n#12 16.99 dist/workbox-8c29f6e4.js\n#12 DONE 17.1s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers\n#15 exporting layers 0.2s done\n#15 exporting manifest sha256:15918da3e65b1a1afceea74f596ea54c686db63a56dc99508c1a68f68f22b9fb\n#15 exporting manifest sha256:15918da3e65b1a1afceea74f596ea54c686db63a56dc99508c1a68f68f22b9fb done\n#15 exporting config sha256:8b439f8ffdf71de36200a2a1bc5644a2bf2ff990a7af259990082b79d6c9358f done\n#15 exporting attestation manifest sha256:4e5e440464fa21d9f5bd94d76b8389c1ef5a5ae9a2f28be14783b0607f431f73 0.0s done\n#15 exporting manifest list sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117 0.0s done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.8s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117 2.5s done\n#15 DONE 7.6s\n",
"exit": 0,
"description": "Build and push vite-frontend Docker image",
"truncated": false
},
"time": {
"start": 1773026792660,
"end": 1773026823756
}
},
"id": "prt_cd0a1d4ce001jU69DgRpI68o2L",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0a1c101001txuuXScozzdjYZ"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "b7337e6c039f4bfbe2c2371dc5dd65337ca209e1",
"cost": 0,
"tokens": {
"total": 33062,
"input": 1644,
"output": 122,
"reasoning": 0,
"cache": {
"read": 31296,
"write": 0
}
},
"id": "prt_cd0a24e4f001DhoQHXDUDQzcrw",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0a1c101001txuuXScozzdjYZ"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773026824011,
"completed": 1773026827727
},
"parentID": "msg_cd0a1c0b4001cSh2gogvuLZ5zm",
"modelID": "glm-5",
"providerID": "zai-coding-plan",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 34595,
"input": 1665,
"output": 34,
"reasoning": 0,
"cache": {
"read": 32896,
"write": 0
}
},
"finish": "stop",
"id": "msg_cd0a24f4b00157qwgrDBSP1LSt",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO"
},
"parts": [
{
"type": "step-start",
"snapshot": "b7337e6c039f4bfbe2c2371dc5dd65337ca209e1",
"id": "prt_cd0a25a75001E3eTRyLGIR2qA0",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0a24f4b00157qwgrDBSP1LSt"
},
{
"type": "reasoning",
"text": "构建和推送成功完成。",
"time": {
"start": 1773026826870,
"end": 1773026827445
},
"id": "prt_cd0a25a76001Wl5aIDxt5nVnJk",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0a24f4b00157qwgrDBSP1LSt"
},
{
"type": "text",
"text": "已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)",
"time": {
"start": 1773026827446,
"end": 1773026827446
},
"id": "prt_cd0a25a76002pH4LdzTRrrs3VO",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0a24f4b00157qwgrDBSP1LSt"
},
{
"type": "step-finish",
"reason": "stop",
"snapshot": "b7337e6c039f4bfbe2c2371dc5dd65337ca209e1",
"cost": 0,
"tokens": {
"total": 34595,
"input": 1665,
"output": 34,
"reasoning": 0,
"cache": {
"read": 32896,
"write": 0
}
},
"id": "prt_cd0a25cb70019nz7mc8Tq8wgEJ",
"sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO",
"messageID": "msg_cd0a24f4b00157qwgrDBSP1LSt"
}
]
}
]
}