Merge remote-tracking branch 'origin/main'

This commit is contained in:
cita-777
2026-04-09 19:18:39 +08:00
20 changed files with 1082 additions and 639 deletions
+6 -2
View File
@@ -342,6 +342,8 @@ jobs:
if: github.event_name == 'push'
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
env:
DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi
strategy:
fail-fast: false
matrix:
@@ -384,7 +386,7 @@ jobs:
id: meta
uses: docker/metadata-action@v6
with:
images: 1467078763/metapi
images: ${{ env.DOCKERHUB_IMAGE }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
@@ -424,6 +426,8 @@ jobs:
runs-on: ubuntu-latest
needs: publish-docker-arch
timeout-minutes: 15
env:
DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi
steps:
- name: Checkout
@@ -449,7 +453,7 @@ jobs:
id: meta
uses: docker/metadata-action@v6
with:
images: 1467078763/metapi
images: ${{ env.DOCKERHUB_IMAGE }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
+40 -36
View File
@@ -283,14 +283,16 @@ jobs:
generate_release_notes: true
files: release-assets/*
publish-docker-arch:
name: Publish Docker Image (${{ matrix.arch }})
needs: build-packages
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
if: startsWith(github.ref, 'refs/tags/')
strategy:
fail-fast: false
publish-docker-arch:
name: Publish Docker Image (${{ matrix.arch }})
needs: build-packages
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
if: startsWith(github.ref, 'refs/tags/')
env:
DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
@@ -334,16 +336,16 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
1467078763/metapi
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKERHUB_IMAGE }}
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Add architecture suffix to tags
id: arch-tags
@@ -373,14 +375,16 @@ jobs:
cache-from: type=gha,scope=release-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }}
publish-docker:
name: Publish Docker Manifests
needs: publish-docker-arch
runs-on: ubuntu-latest
timeout-minutes: 15
if: startsWith(github.ref, 'refs/tags/')
steps:
publish-docker:
name: Publish Docker Manifests
needs: publish-docker-arch
runs-on: ubuntu-latest
timeout-minutes: 15
if: startsWith(github.ref, 'refs/tags/')
env:
DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -407,16 +411,16 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
1467078763/metapi
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKERHUB_IMAGE }}
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Create multi-arch manifests
shell: bash
+1
View File
@@ -25,3 +25,4 @@ docker/.env
# Local planning docs
docs/plan/
docs/plans/
.ace-tool/
+97 -131
View File
@@ -14,16 +14,16 @@
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@visactor/react-vchart": "^2.0.20",
"@visactor/react-vchart": "^2.0.21",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.3.1",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",
"electron-log": "^5.2.4",
"electron-updater": "^6.6.2",
"fastify": "^5.8.4",
"get-port": "^7.2.0",
"marked": "^17.0.5",
"minimatch": "^10.2.4",
"minimatch": "^10.2.5",
"minimist": "^1.2.8",
"mysql2": "^3.20.0",
"node-cron": "^4.2.1",
@@ -41,7 +41,7 @@
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.10.1",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -50,13 +50,13 @@
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.10",
"electron": "^41.1.0",
"electron": "^41.1.1",
"electron-builder": "^26.0.12",
"jsdom": "^29.0.1",
"mermaid": "^11.13.0",
"jsdom": "^29.0.2",
"mermaid": "^11.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.13.2",
"react-router-dom": "^7.14.0",
"react-test-renderer": "^18.3.1",
"sharp": "^0.34.5",
"tailwindcss": "^4.0.0",
@@ -355,59 +355,37 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
"integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.6.tgz",
"integrity": "sha512-BXWCh8dHs9GOfpo/fWGDJtDmleta2VePN9rn6WQt3GjEbxzutVF4t0x2pmH+7dbMCLtuv3MlwqRsAuxlzFXqFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.2.6"
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz",
"integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.7.tgz",
"integrity": "sha512-d2BgqDUOS1Hfp4IzKUZqCNz+Kg3Y88AkaBvJK/ZVSQPU1f7OpPNi7nQTH6/oI47Dkdg+Z3e8Yp6ynOu4UMINAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7"
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
@@ -3440,9 +3418,9 @@
"optional": true
},
"node_modules/@mermaid-js/parser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz",
"integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3626,9 +3604,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3645,9 +3620,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3664,9 +3636,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3683,9 +3652,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5115,9 +5081,9 @@
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5256,15 +5222,15 @@
}
},
"node_modules/@visactor/react-vchart": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/@visactor/react-vchart/-/react-vchart-2.0.20.tgz",
"integrity": "sha512-SlOmCWQ/dd8QA4ez2pcE8dRSzKjt5/0/M6PQ0X8AkawFBcVwHgDPweZnEExsmDN6fvyfB2HqmKH0tefL/J6tUA==",
"version": "2.0.21",
"resolved": "https://registry.npmjs.org/@visactor/react-vchart/-/react-vchart-2.0.21.tgz",
"integrity": "sha512-1qgPlW820ZQEbemKiddLRx+JIoEdVUAF4pEyIEAIYoh4W49RPpa8kncMTgKw7030gs3lGCQneqIeLoZMWfZJtA==",
"license": "MIT",
"dependencies": {
"@visactor/vchart": "2.0.20",
"@visactor/vchart-extension": "2.0.20",
"@visactor/vrender-core": "1.0.44",
"@visactor/vrender-kits": "1.0.44",
"@visactor/vchart": "2.0.21",
"@visactor/vchart-extension": "2.0.21",
"@visactor/vrender-core": "1.0.45",
"@visactor/vrender-kits": "1.0.45",
"@visactor/vutils": "~1.0.23",
"react-is": "^18.2.0"
},
@@ -5274,35 +5240,35 @@
}
},
"node_modules/@visactor/vchart": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/@visactor/vchart/-/vchart-2.0.20.tgz",
"integrity": "sha512-nolgr7b+kSa1GQ8q99UjKCoMEYib3brmnRtDC+/iYr4Idbr9hSHS+60wteZgX8GOIUxivOEKYcpTPNXVRNmBAg==",
"version": "2.0.21",
"resolved": "https://registry.npmjs.org/@visactor/vchart/-/vchart-2.0.21.tgz",
"integrity": "sha512-jYrSwTS8EkV2qB3c+/iW9YBta4dCJSsbuxQzacHnhVuH2XAn7b+33wLdFHbG1h84L1EbVPMle6LrUoXe+CZjYw==",
"license": "MIT",
"dependencies": {
"@visactor/vdataset": "~1.0.23",
"@visactor/vlayouts": "~1.0.23",
"@visactor/vrender-animate": "1.0.44",
"@visactor/vrender-components": "1.0.44",
"@visactor/vrender-core": "1.0.44",
"@visactor/vrender-kits": "1.0.44",
"@visactor/vrender-animate": "1.0.45",
"@visactor/vrender-components": "1.0.45",
"@visactor/vrender-core": "1.0.45",
"@visactor/vrender-kits": "1.0.45",
"@visactor/vscale": "~1.0.23",
"@visactor/vutils": "~1.0.23",
"@visactor/vutils-extension": "2.0.20"
"@visactor/vutils-extension": "2.0.21"
}
},
"node_modules/@visactor/vchart-extension": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/@visactor/vchart-extension/-/vchart-extension-2.0.20.tgz",
"integrity": "sha512-+D1eVABTjQ/tla/nQUaWyECQ8GTcQU7oNU9ovPheOfYnPleSjPgDOJnEH2w2mEdiXNh8P+oIaVqm0ItwSPvprA==",
"version": "2.0.21",
"resolved": "https://registry.npmjs.org/@visactor/vchart-extension/-/vchart-extension-2.0.21.tgz",
"integrity": "sha512-c930SfptDFmRUVrc2tf5sX3uw6EOg7eUFHENYlMmN80ufjoSUqX6MqOXgK/fsZh3lnQi3vzBQI6+9TtY81T4wg==",
"license": "MIT",
"dependencies": {
"@visactor/vchart": "2.0.20",
"@visactor/vchart": "2.0.21",
"@visactor/vdataset": "~1.0.23",
"@visactor/vlayouts": "~1.0.23",
"@visactor/vrender-animate": "1.0.44",
"@visactor/vrender-components": "1.0.44",
"@visactor/vrender-core": "1.0.44",
"@visactor/vrender-kits": "1.0.44",
"@visactor/vrender-animate": "1.0.45",
"@visactor/vrender-components": "1.0.45",
"@visactor/vrender-core": "1.0.45",
"@visactor/vrender-kits": "1.0.45",
"@visactor/vutils": "~1.0.23"
}
},
@@ -5345,32 +5311,32 @@
}
},
"node_modules/@visactor/vrender-animate": {
"version": "1.0.44",
"resolved": "https://registry.npmjs.org/@visactor/vrender-animate/-/vrender-animate-1.0.44.tgz",
"integrity": "sha512-MKtoaucmbQNJnuff5F6ws+c8+mRBz1gXVY/bHw38nfvSTZcsJcXYug2aDPf4V9XaUSValDnkx9TyLLbKJ0o0TA==",
"version": "1.0.45",
"resolved": "https://registry.npmjs.org/@visactor/vrender-animate/-/vrender-animate-1.0.45.tgz",
"integrity": "sha512-6v7LRpr+zugxR8JH3RCnodYxrrzHkSp4GaBTNwXuJ7bwThi/YzXTvmBz2pfQNmUG/rRze8Bm7vqneHVdXuFhng==",
"license": "MIT",
"dependencies": {
"@visactor/vrender-core": "1.0.44",
"@visactor/vrender-core": "1.0.45",
"@visactor/vutils": "~1.0.12"
}
},
"node_modules/@visactor/vrender-components": {
"version": "1.0.44",
"resolved": "https://registry.npmjs.org/@visactor/vrender-components/-/vrender-components-1.0.44.tgz",
"integrity": "sha512-vbCa/eME1UJL2iy0l4JHYJh1m6SG3rR5jt5umblLVRQjjBEWPAnhnPwQAA2TNiyO5eHG6qnyvcjgV5aeCeuJYw==",
"version": "1.0.45",
"resolved": "https://registry.npmjs.org/@visactor/vrender-components/-/vrender-components-1.0.45.tgz",
"integrity": "sha512-rYsG/rncT5FgYYWqv09f6LkZEAk/IS45IEYWBD0SCgKguYnpEebYDmDba1muJGn+rRv/vGlqg1pYqXLw8mEU5w==",
"license": "MIT",
"dependencies": {
"@visactor/vrender-animate": "1.0.44",
"@visactor/vrender-core": "1.0.44",
"@visactor/vrender-kits": "1.0.44",
"@visactor/vrender-animate": "1.0.45",
"@visactor/vrender-core": "1.0.45",
"@visactor/vrender-kits": "1.0.45",
"@visactor/vscale": "~1.0.12",
"@visactor/vutils": "~1.0.12"
}
},
"node_modules/@visactor/vrender-core": {
"version": "1.0.44",
"resolved": "https://registry.npmjs.org/@visactor/vrender-core/-/vrender-core-1.0.44.tgz",
"integrity": "sha512-DrB+cg9//RkCZjchWConeXGOC99xwvEDdPfY7eEqajjPxUooJpSQ+pt51uD0VIwQCYtpsc2jVspefqdBh/e2Cg==",
"version": "1.0.45",
"resolved": "https://registry.npmjs.org/@visactor/vrender-core/-/vrender-core-1.0.45.tgz",
"integrity": "sha512-kvYAsKGZ+dXbhOQzjjbMkRzI815KgaHoWOW7iyVorXTldQBTsxLtFIi/J3VfYqGdM3apUgsFoy8uvjS5DepPUA==",
"license": "MIT",
"dependencies": {
"@visactor/vutils": "~1.0.12",
@@ -5378,13 +5344,13 @@
}
},
"node_modules/@visactor/vrender-kits": {
"version": "1.0.44",
"resolved": "https://registry.npmjs.org/@visactor/vrender-kits/-/vrender-kits-1.0.44.tgz",
"integrity": "sha512-B28zr+aTWlFIziH3b/ufUlpKQMv1Ie9ck8kTkHIGdBM/ES+CO6//GHMa/5OxuId19a9UhJxzj8cpeE/9mZLIoQ==",
"version": "1.0.45",
"resolved": "https://registry.npmjs.org/@visactor/vrender-kits/-/vrender-kits-1.0.45.tgz",
"integrity": "sha512-iaeRitht8IqNvJwdKmcPKAYL0a4+f8xhK2bWUumSQjNbZJQx9UzGuDp73cIdNXzCNoCiJZElpsOiIGu3kymqiw==",
"license": "MIT",
"dependencies": {
"@resvg/resvg-js": "2.4.1",
"@visactor/vrender-core": "1.0.44",
"@visactor/vrender-core": "1.0.45",
"@visactor/vutils": "~1.0.12",
"gifuct-js": "2.1.2",
"lottie-web": "^5.12.2",
@@ -5412,9 +5378,9 @@
}
},
"node_modules/@visactor/vutils-extension": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/@visactor/vutils-extension/-/vutils-extension-2.0.20.tgz",
"integrity": "sha512-now0AqIHimJr1/+7U9cJBkZ022VAsVlVANTN8mKLVIG1C5wnEYfKYG1Ew6ghjcSmkXi1c+pdrsAsj6i+ifRlwA==",
"version": "2.0.21",
"resolved": "https://registry.npmjs.org/@visactor/vutils-extension/-/vutils-extension-2.0.21.tgz",
"integrity": "sha512-E9owMC/COyfz481BgDrVPPOgK3O98NZtZldko2dCZBFWuByiRx+xcn4gvSO6vlt2SrBB0LzzyhLYkrNs9xb/0A==",
"license": "MIT",
"dependencies": {
"@visactor/vdataset": "~1.0.23",
@@ -6454,9 +6420,9 @@
"optional": true
},
"node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -8321,9 +8287,9 @@
}
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -8541,9 +8507,9 @@
}
},
"node_modules/electron": {
"version": "41.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-41.1.0.tgz",
"integrity": "sha512-0XRFyxRqetmqtkkBvV++wGbHYJ7bD++f6EgJW8y9kX4pPRagwlmKDtzqXZhKiu0DIQppm3sXxzHWK9GYP91OKQ==",
"version": "41.1.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-41.1.1.tgz",
"integrity": "sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -10398,14 +10364,14 @@
}
},
"node_modules/jsdom": {
"version": "29.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
"integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
"version": "29.0.2",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@asamuzakjp/dom-selector": "^7.0.3",
"@asamuzakjp/css-color": "^5.1.5",
"@asamuzakjp/dom-selector": "^7.0.6",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
@@ -11122,15 +11088,15 @@
"license": "CC0-1.0"
},
"node_modules/mermaid": {
"version": "11.13.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz",
"integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==",
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.0.1",
"@mermaid-js/parser": "^1.1.0",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
@@ -11330,12 +11296,12 @@
}
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -12550,9 +12516,9 @@
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
@@ -12664,9 +12630,9 @@
}
},
"node_modules/react-router": {
"version": "7.13.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
"integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12687,13 +12653,13 @@
}
},
"node_modules/react-router-dom": {
"version": "7.13.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
"integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-router": "7.13.2"
"react-router": "7.14.0"
},
"engines": {
"node": ">=20.0.0"
+8 -8
View File
@@ -72,16 +72,16 @@
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@visactor/react-vchart": "^2.0.20",
"@visactor/react-vchart": "^2.0.21",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.3.1",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",
"electron-log": "^5.2.4",
"electron-updater": "^6.6.2",
"fastify": "^5.8.4",
"get-port": "^7.2.0",
"marked": "^17.0.5",
"minimatch": "^10.2.4",
"minimatch": "^10.2.5",
"minimist": "^1.2.8",
"mysql2": "^3.20.0",
"node-cron": "^4.2.1",
@@ -99,7 +99,7 @@
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.10.1",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -108,13 +108,13 @@
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.10",
"electron": "^41.1.0",
"electron": "^41.1.1",
"electron-builder": "^26.0.12",
"jsdom": "^29.0.1",
"mermaid": "^11.13.0",
"jsdom": "^29.0.2",
"mermaid": "^11.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.13.2",
"react-router-dom": "^7.14.0",
"react-test-renderer": "^18.3.1",
"sharp": "^0.34.5",
"tailwindcss": "^4.0.0",
+201 -218
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -16,6 +16,17 @@ describe('docker workflows', () => {
expect(releaseWorkflow).toContain('"${tag}-armv7"');
});
it('derives Docker Hub image names from the configured username secret', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(ciWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi');
expect(ciWorkflow).not.toContain('images: 1467078763/metapi');
expect(releaseWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi');
expect(releaseWorkflow).not.toContain('1467078763/metapi');
});
it('uses an armv7-capable node base image in the Dockerfile', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
@@ -5,7 +5,8 @@ const accountCredentialModeSchema = z.enum(['auto', 'session', 'apikey']);
const accountCreatePayloadSchema = z.object({
siteId: z.number().int().positive(),
username: z.string().optional(),
accessToken: z.string(),
accessToken: z.string().optional(),
accessTokens: z.array(z.string()).optional(),
apiToken: z.string().optional(),
platformUserId: z.number().int().positive().optional(),
checkinEnabled: z.boolean().optional(),
@@ -95,6 +96,9 @@ function formatAccountsPayloadError(error: z.ZodError): string {
if (firstPath === 'apiToken') {
return 'Invalid apiToken. Expected string or null.';
}
if (firstPath === 'accessTokens') {
return 'Invalid accessTokens. Expected string[].';
}
if (firstPath === 'checkinEnabled') {
return 'Invalid checkinEnabled. Expected boolean.';
}
@@ -268,4 +268,97 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-create-a.example.com', 'sk-nihao-create-rotate', undefined);
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-create-b.example.com', 'sk-nihao-create-rotate', undefined);
});
it('supports batch creating multiple API key connections for one site', async () => {
getModelsMock
.mockResolvedValueOnce(['gpt-4o-mini'])
.mockResolvedValueOnce(['gpt-4.1-mini']);
const site = await db.insert(schema.sites).values({
name: 'Nihao Batch Pool',
url: 'https://console.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values({
siteId: site.id,
url: 'https://api.example.com',
enabled: true,
sortOrder: 0,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
username: 'batch-key',
accessToken: 'sk-batch-a\nsk-batch-b',
credentialMode: 'apikey',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
batch: true,
totalCount: 2,
createdCount: 2,
failedCount: 0,
});
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-batch-a', undefined);
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-batch-b', undefined);
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(2);
expect(accounts.map((item) => item.apiToken)).toEqual(['sk-batch-a', 'sk-batch-b']);
expect(accounts.map((item) => item.username)).toEqual(['batch-key #1', 'batch-key #2']);
});
it('treats accessTokens payloads as batch API key creation even without credentialMode', async () => {
getModelsMock
.mockResolvedValueOnce(['gpt-4o-mini'])
.mockResolvedValueOnce(['gpt-4.1-mini']);
const site = await db.insert(schema.sites).values({
name: 'Nihao Batch Array',
url: 'https://console.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values({
siteId: site.id,
url: 'https://api.example.com',
enabled: true,
sortOrder: 0,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
username: 'array-key',
accessTokens: ['sk-array-a', 'sk-array-b'],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
batch: true,
totalCount: 2,
createdCount: 2,
failedCount: 0,
});
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-array-a', undefined);
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-array-b', undefined);
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(2);
expect(accounts.map((item) => item.apiToken)).toEqual(['sk-array-a', 'sk-array-b']);
expect(accounts.map((item) => item.username)).toEqual(['array-key #1', 'array-key #2']);
});
});
+93 -219
View File
@@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify';
import { db, schema, runtimeDbDialect } from '../../db/index.js';
import { getInsertedRowId, insertAndGetById } from '../../db/insertHelpers.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import { and, eq, gte, lt, sql } from 'drizzle-orm';
import { refreshBalance } from '../../services/balanceService.js';
import { getAdapter } from '../../services/platforms/index.js';
@@ -34,6 +34,7 @@ import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
import { parseSiteProxyUrlInput, withAccountProxyOverride, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
import {
type AccountCreatePayload,
parseAccountBatchPayload,
parseAccountCreatePayload,
parseAccountHealthRefreshPayload,
@@ -44,6 +45,8 @@ import {
parseAccountVerifyTokenPayload,
} from '../../contracts/accountsRoutePayloads.js';
import { requireSiteApiBaseUrl, runWithSiteApiEndpointPool } from '../../services/siteApiEndpointService.js';
import { buildBatchApiKeyConnectionName, parseBatchApiKeys } from '../../services/apiKeyBatch.js';
import { createManualAccount } from '../../services/manualAccountCreationService.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
@@ -65,17 +68,6 @@ type AccountCapabilities = {
proxyOnly: boolean;
};
type AccountInitializationParams = {
accountId: number;
site: typeof schema.sites.$inferSelect;
adapter: NonNullable<ReturnType<typeof getAdapter>>;
tokenType: 'session' | 'apikey' | 'unknown';
accessToken: string;
apiToken: string;
platformUserId?: number;
skipModelFetch?: boolean;
};
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
const limitAccountLogin = createRateLimitGuard({
@@ -150,65 +142,18 @@ function normalizePinnedFlag(input: unknown): boolean | null {
return null;
}
async function initializeAccountInBackground({
accountId,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId,
skipModelFetch,
}: AccountInitializationParams) {
const summary = {
accountId,
syncedTokenCount: 0,
refreshedBalance: false,
refreshedModels: false,
rebuiltRoutes: false,
};
let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = [];
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : [];
} catch {}
function resolveRequestedCreateTokens(
body: AccountCreatePayload,
credentialMode: AccountCredentialMode,
): string[] {
if (credentialMode !== 'apikey') {
const single = String(body.accessToken || '').trim();
return single ? [single] : [];
}
const convergence = await convergeAccountMutation({
accountId,
preferredApiToken: tokenType === 'session' ? apiToken : null,
defaultTokenSource: 'manual',
ensurePreferredTokenBeforeSync: tokenType === 'session',
upstreamTokens: fetchedUpstreamTokens,
refreshBalance: tokenType === 'session',
refreshModels: skipModelFetch !== true,
rebuildRoutes: skipModelFetch !== true,
continueOnError: true,
});
summary.refreshedBalance = convergence.refreshedBalance;
summary.refreshedModels = convergence.refreshedModels;
summary.rebuiltRoutes = convergence.rebuiltRoutes;
return summary;
}
function buildQueuedAccountInitializationMessage(
tokenType: 'session' | 'apikey' | 'unknown',
skipModelFetch?: boolean,
) {
if (tokenType === 'session' && skipModelFetch === true) {
return '账号已添加,后台正在同步令牌和余额信息。';
}
if (tokenType === 'session') {
return '账号已添加,后台正在同步令牌、余额和模型信息。';
}
if (skipModelFetch === true) {
return '已添加为 API Key 账号(可用于代理转发)。';
}
return '已添加为 API Key 账号,后台正在同步模型和路由信息。';
const batchTokens = parseBatchApiKeys(body.accessTokens);
if (batchTokens.length > 0) return batchTokens;
return parseBatchApiKeys(body.accessToken);
}
function normalizeSortOrder(input: unknown): number | null {
@@ -1147,174 +1092,103 @@ export async function accountsRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` });
}
const credentialMode = resolveRequestedCredentialMode(body.credentialMode);
const rawAccessToken = (body.accessToken || '').trim();
if (!rawAccessToken) {
const explicitBatchTokens = parseBatchApiKeys(body.accessTokens);
const credentialMode = explicitBatchTokens.length > 0
? 'apikey'
: resolveRequestedCredentialMode(body.credentialMode);
const requestedTokens = explicitBatchTokens.length > 0
? explicitBatchTokens
: resolveRequestedCreateTokens(body, credentialMode);
if (requestedTokens.length === 0) {
return reply.code(400).send({ success: false, message: '请填写 Token' });
}
let username = (body.username || '').trim();
let accessToken = rawAccessToken;
let apiToken = (body.apiToken || '').trim();
let tokenType: 'session' | 'apikey' | 'unknown' = 'unknown';
let verifiedModels: string[] = [];
if (credentialMode === 'apikey' && requestedTokens.length > 1) {
const items: Array<Record<string, unknown>> = [];
let createdCount = 0;
if (credentialMode === 'apikey') {
if (body.skipModelFetch === true) {
tokenType = 'apikey';
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
} else {
for (const [index, token] of requestedTokens.entries()) {
try {
const models = await getModelsWithSiteApiEndpointPool(
const created = await createManualAccount({
body,
site,
adapter,
rawAccessToken,
body.platformUserId,
);
verifiedModels = Array.isArray(models)
? models.filter((item) => typeof item === 'string' && item.trim().length > 0)
: [];
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'API Key 验证失败',
credentialMode,
rawAccessToken: token,
usernameOverride: buildBatchApiKeyConnectionName(body.username, index, requestedTokens.length) || undefined,
});
createdCount += 1;
items.push({
index,
status: 'created',
id: created.account.id,
username: created.account.username || null,
queued: created.queued === true,
message: created.message || null,
modelCount: created.modelCount || 0,
});
} catch (error: any) {
items.push({
index,
status: 'failed',
message: error?.message || '创建失败',
requiresVerification: error?.requiresVerification === true,
});
}
if (verifiedModels.length === 0) {
return reply.code(400).send({
success: false,
requiresVerification: true,
message: 'API Key 验证失败:未获取到可用模型',
});
}
tokenType = 'apikey';
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
}
} else {
let verifyResult: any;
try {
verifyResult = await adapter.verifyToken(site.url, rawAccessToken, body.platformUserId);
} catch (err: any) {
if (createdCount === 0) {
return reply.code(400).send({
success: false,
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
batch: true,
totalCount: requestedTokens.length,
createdCount: 0,
failedCount: requestedTokens.length,
message: `批量添加失败(0/${requestedTokens.length}`,
items,
});
}
tokenType = verifyResult.tokenType;
if (tokenType === 'unknown') {
return reply.code(400).send({
success: false,
requiresVerification: true,
message: 'Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号',
});
}
if (credentialMode === 'session' && tokenType !== 'session') {
return reply.code(400).send({
success: false,
message: '当前凭证是 API Key,请切换到 API Key 模式,或改用 Session Token',
});
}
if (tokenType === 'session') {
if (!username && verifyResult.userInfo?.username) username = String(verifyResult.userInfo.username).trim();
if (!apiToken && verifyResult.apiToken) apiToken = String(verifyResult.apiToken).trim();
} else if (tokenType === 'apikey') {
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
verifiedModels = Array.isArray(verifyResult.models)
? verifyResult.models.filter((item: unknown) => typeof item === 'string' && item.trim().length > 0)
: [];
}
return {
success: true,
batch: true,
totalCount: requestedTokens.length,
createdCount,
failedCount: requestedTokens.length - createdCount,
message: `批量添加完成:成功 ${createdCount},失败 ${requestedTokens.length - createdCount}`,
items,
};
}
// Store platformUserId and credential mode in extraConfig.
const resolvedPlatformUserId =
body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined;
const resolvedCredentialMode: AccountCredentialMode = tokenType === 'apikey' ? 'apikey' : 'session';
const extraConfigPatch: Record<string, unknown> = { credentialMode: resolvedCredentialMode };
if (resolvedPlatformUserId) {
extraConfigPatch.platformUserId = resolvedPlatformUserId;
try {
const created = await createManualAccount({
body,
site,
adapter,
credentialMode,
rawAccessToken: requestedTokens[0]!,
});
return {
...created.account,
tokenType: created.tokenType,
credentialMode: resolveStoredCredentialMode(created.account),
capabilities: buildCapabilitiesForAccount(created.account),
modelCount: created.modelCount,
apiTokenFound: created.apiTokenFound,
usernameDetected: created.usernameDetected,
queued: created.queued,
jobId: created.jobId,
message: created.message,
};
} catch (err: any) {
return reply.code(400).send({
success: false,
requiresVerification: err?.requiresVerification === true,
message: credentialMode !== 'apikey'
? appendSessionTokenRebindHint(err?.message || 'Token 验证失败')
: (err?.message || 'API Key 验证失败'),
});
}
if ((site.platform || '').toLowerCase() === 'sub2api') {
const managedRefreshToken = normalizeManagedRefreshToken(body.refreshToken);
const managedTokenExpiresAt = normalizeManagedTokenExpiresAt(body.tokenExpiresAt);
if (managedRefreshToken) {
extraConfigPatch.sub2apiAuth = managedTokenExpiresAt
? { refreshToken: managedRefreshToken, tokenExpiresAt: managedTokenExpiresAt }
: { refreshToken: managedRefreshToken };
}
}
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
const result = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: body.siteId,
username: username || undefined,
accessToken,
apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
},
insertErrorMessage: '创建账号失败',
loadErrorMessage: '创建账号失败',
});
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
let queuedTaskId: string | undefined;
let queuedMessage: string | undefined;
if (shouldQueueInitialization) {
const taskTitle = `初始化连接 #${result.id}`;
const { task } = startBackgroundTask(
{
type: 'account-init',
title: taskTitle,
dedupeKey: `account-init-${result.id}`,
notifyOnFailure: true,
successMessage: () => `${taskTitle}已完成`,
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
},
async () => initializeAccountInBackground({
accountId: result.id,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId: resolvedPlatformUserId,
skipModelFetch: body.skipModelFetch,
}),
);
queuedTaskId = task.id;
queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch);
}
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode;
const capabilities = account
? buildCapabilitiesForAccount(account)
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null);
return {
...account,
tokenType,
credentialMode: finalCredentialMode,
capabilities,
modelCount: verifiedModels.length,
apiTokenFound: !!apiToken,
usernameDetected: !!(!body.username && username),
queued: !!queuedTaskId,
jobId: queuedTaskId,
message: queuedMessage,
};
});
// Update an account
+91
View File
@@ -3246,6 +3246,97 @@ describe('oauth routes', { timeout: 15_000 }, () => {
}
});
it('restores oauth route unit route channels when delete rebuild keeps failing', async () => {
const site = await db.insert(schema.sites).values({
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
status: 'active',
}).returning().get();
const first = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'rollback-delete-double-fail-a@example.com',
accessToken: 'rollback-delete-double-fail-access-a',
status: 'active',
oauthProvider: 'codex',
oauthAccountKey: 'rollback-delete-double-fail-a',
extraConfig: JSON.stringify({
oauth: {
provider: 'codex',
accountId: 'rollback-delete-double-fail-a',
accountKey: 'rollback-delete-double-fail-a',
email: 'rollback-delete-double-fail-a@example.com',
refreshToken: 'rollback-delete-double-fail-refresh-a',
},
}),
}).returning().get();
const second = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'rollback-delete-double-fail-b@example.com',
accessToken: 'rollback-delete-double-fail-access-b',
status: 'active',
oauthProvider: 'codex',
oauthAccountKey: 'rollback-delete-double-fail-b',
extraConfig: JSON.stringify({
oauth: {
provider: 'codex',
accountId: 'rollback-delete-double-fail-b',
accountKey: 'rollback-delete-double-fail-b',
email: 'rollback-delete-double-fail-b@example.com',
refreshToken: 'rollback-delete-double-fail-refresh-b',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values([
{ accountId: first.id, modelName: 'gpt-5.4', available: true },
{ accountId: second.id, modelName: 'gpt-5.4', available: true },
]).run();
const routeRefreshWorkflow = await import('../../services/routeRefreshWorkflow.js');
await routeRefreshWorkflow.rebuildRoutesOnly();
const createResponse = await app.inject({
method: 'POST',
url: '/api/oauth/route-units',
payload: {
accountIds: [first.id, second.id],
name: 'Rollback Delete Double Fail Pool',
strategy: 'round_robin',
},
});
expect(createResponse.statusCode).toBe(200);
const routeUnitId = createResponse.json().routeUnit?.id as number;
const rebuildSpy = vi.spyOn(routeRefreshWorkflow, 'rebuildRoutesOnly');
rebuildSpy.mockImplementation(async () => {
throw new Error('route rebuild failed');
});
try {
const response = await app.inject({
method: 'DELETE',
url: `/api/oauth/route-units/${routeUnitId}`,
});
expect(response.statusCode).toBe(500);
expect(response.json()).toMatchObject({
message: 'route rebuild failed',
});
const routeUnits = await db.select().from(schema.oauthRouteUnits).all();
expect(routeUnits).toHaveLength(1);
const members = await db.select().from(schema.oauthRouteUnitMembers).all();
expect(members).toHaveLength(2);
const routeChannels = await db.select().from(schema.routeChannels).all();
expect(routeChannels).toHaveLength(1);
expect(routeChannels[0]?.oauthRouteUnitId).toBe(routeUnitId);
} finally {
rebuildSpy.mockRestore();
}
});
it('accepts normalized route unit strategy values from the HTTP payload', async () => {
const site = await db.insert(schema.sites).values({
name: 'ChatGPT Codex OAuth',
@@ -658,7 +658,7 @@ describe('PUT /api/routes/:id route rebuild', () => {
expect(updated?.tokenId).toBe(seeded.token.id);
});
it('prefers an exact route over a colliding explicit-group display name', async () => {
it('prefers an explicit-group display name over a colliding exact route', async () => {
const exactCandidate = await seedAccountWithToken('claude-opus-4-6');
const groupedCandidate = await seedAccountWithToken('claude-opus-4-5');
@@ -716,9 +716,8 @@ describe('PUT /api/routes/:id route rebuild', () => {
success: true,
decision: {
matched: true,
routeId: exactRoute.id,
modelPattern: 'claude-opus-4-6',
actualModel: 'claude-opus-4-6',
routeId: groupResponse.json().id,
actualModel: 'claude-opus-4-5',
},
});
});
+4
View File
@@ -0,0 +1,4 @@
export {
buildBatchApiKeyConnectionName,
parseBatchApiKeys,
} from './shared/apiKeyBatchCore.js';
@@ -0,0 +1,305 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { insertAndGetById } from '../db/insertHelpers.js';
import { startBackgroundTask } from './backgroundTaskService.js';
import { getAdapter } from './platforms/index.js';
import {
guessPlatformUserIdFromUsername,
mergeAccountExtraConfig,
type AccountCredentialMode,
} from './accountExtraConfig.js';
import { runWithSiteApiEndpointPool } from './siteApiEndpointService.js';
import { type AccountCreatePayload } from '../contracts/accountsRoutePayloads.js';
import { convergeAccountMutation } from './accountMutationWorkflow.js';
const ACCOUNT_VERIFY_TIMEOUT_MS = 10_000;
type AccountInitializationParams = {
accountId: number;
site: typeof schema.sites.$inferSelect;
adapter: NonNullable<ReturnType<typeof getAdapter>>;
tokenType: 'session' | 'apikey' | 'unknown';
accessToken: string;
apiToken: string;
platformUserId?: number;
skipModelFetch?: boolean;
};
export type CreateManualAccountParams = {
body: AccountCreatePayload;
site: typeof schema.sites.$inferSelect;
adapter: NonNullable<ReturnType<typeof getAdapter>>;
credentialMode: AccountCredentialMode;
rawAccessToken: string;
usernameOverride?: string;
};
export type CreateManualAccountResult = {
account: typeof schema.accounts.$inferSelect;
tokenType: 'session' | 'apikey' | 'unknown';
modelCount: number;
apiTokenFound: boolean;
usernameDetected: boolean;
queued: boolean;
jobId?: string;
message?: string;
};
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
fn(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function buildAccountVerifyTimeoutMessage(): string {
return `Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`;
}
async function getNextAccountSortOrder(): Promise<number> {
const rows = await db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
return max + 1;
}
async function getModelsWithSiteApiEndpointPool(
site: typeof schema.sites.$inferSelect,
adapter: NonNullable<ReturnType<typeof getAdapter>>,
accessToken: string,
platformUserId?: number,
): Promise<string[]> {
const timeoutMessage = buildAccountVerifyTimeoutMessage();
const deadline = Date.now() + ACCOUNT_VERIFY_TIMEOUT_MS;
return runWithSiteApiEndpointPool(site, (target) => {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
throw new Error(timeoutMessage);
}
return withTimeout(
() => adapter.getModels(target.baseUrl, accessToken, platformUserId),
remainingMs,
timeoutMessage,
);
});
}
async function initializeAccountInBackground({
accountId,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId,
skipModelFetch,
}: AccountInitializationParams) {
const summary = {
accountId,
syncedTokenCount: 0,
refreshedBalance: false,
refreshedModels: false,
rebuiltRoutes: false,
};
let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = [];
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : [];
} catch {}
}
const convergence = await convergeAccountMutation({
accountId,
preferredApiToken: tokenType === 'session' ? apiToken : null,
defaultTokenSource: 'manual',
ensurePreferredTokenBeforeSync: tokenType === 'session',
upstreamTokens: fetchedUpstreamTokens,
refreshBalance: tokenType === 'session',
refreshModels: skipModelFetch !== true,
rebuildRoutes: skipModelFetch !== true,
continueOnError: true,
});
summary.refreshedBalance = convergence.refreshedBalance;
summary.refreshedModels = convergence.refreshedModels;
summary.rebuiltRoutes = convergence.rebuiltRoutes;
return summary;
}
function buildQueuedAccountInitializationMessage(
tokenType: 'session' | 'apikey' | 'unknown',
skipModelFetch?: boolean,
) {
if (tokenType === 'session' && skipModelFetch === true) {
return '账号已添加,后台正在同步令牌和余额信息。';
}
if (tokenType === 'session') {
return '账号已添加,后台正在同步令牌、余额和模型信息。';
}
if (skipModelFetch === true) {
return '已添加为 API Key 账号(可用于代理转发)。';
}
return '已添加为 API Key 账号,后台正在同步模型和路由信息。';
}
export async function createManualAccount({
body,
site,
adapter,
credentialMode,
rawAccessToken,
usernameOverride,
}: CreateManualAccountParams): Promise<CreateManualAccountResult> {
let username = typeof usernameOverride === 'string'
? usernameOverride.trim()
: (body.username || '').trim();
let accessToken = rawAccessToken;
let apiToken = (body.apiToken || '').trim();
let tokenType: 'session' | 'apikey' | 'unknown' = 'unknown';
let verifiedModels: string[] = [];
if (credentialMode === 'apikey') {
if (body.skipModelFetch === true) {
tokenType = 'apikey';
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
} else {
const models = await getModelsWithSiteApiEndpointPool(
site,
adapter,
rawAccessToken,
body.platformUserId,
);
verifiedModels = Array.isArray(models)
? models.filter((item) => typeof item === 'string' && item.trim().length > 0)
: [];
if (verifiedModels.length === 0) {
const error = new Error('API Key 验证失败:未获取到可用模型');
(error as Error & { requiresVerification?: boolean }).requiresVerification = true;
throw error;
}
tokenType = 'apikey';
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
}
} else {
const verifyResult = await withTimeout(
() => adapter.verifyToken(site.url, rawAccessToken, body.platformUserId),
ACCOUNT_VERIFY_TIMEOUT_MS,
buildAccountVerifyTimeoutMessage(),
);
tokenType = verifyResult.tokenType;
if (tokenType === 'unknown') {
const error = new Error('Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号');
(error as Error & { requiresVerification?: boolean }).requiresVerification = true;
throw error;
}
if (credentialMode === 'session' && tokenType !== 'session') {
throw new Error('当前凭证是 API Key,请切换到 API Key 模式,或改用 Session Token');
}
if (tokenType === 'session') {
if (!username && verifyResult.userInfo?.username) username = String(verifyResult.userInfo.username).trim();
if (!apiToken && verifyResult.apiToken) apiToken = String(verifyResult.apiToken).trim();
} else if (tokenType === 'apikey') {
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
verifiedModels = Array.isArray(verifyResult.models)
? verifyResult.models.filter((item: unknown) => typeof item === 'string' && item.trim().length > 0)
: [];
}
}
const resolvedPlatformUserId =
body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined;
const resolvedCredentialMode: AccountCredentialMode = tokenType === 'apikey' ? 'apikey' : 'session';
const extraConfigPatch: Record<string, unknown> = { credentialMode: resolvedCredentialMode };
if (resolvedPlatformUserId) {
extraConfigPatch.platformUserId = resolvedPlatformUserId;
}
if ((site.platform || '').toLowerCase() === 'sub2api') {
const managedRefreshToken = typeof body.refreshToken === 'string' ? body.refreshToken.trim() : '';
const managedTokenExpiresAt = typeof body.tokenExpiresAt === 'number'
? Math.trunc(body.tokenExpiresAt)
: (typeof body.tokenExpiresAt === 'string' ? Number.parseInt(body.tokenExpiresAt.trim(), 10) : undefined);
if (managedRefreshToken) {
extraConfigPatch.sub2apiAuth = managedTokenExpiresAt && Number.isFinite(managedTokenExpiresAt) && managedTokenExpiresAt > 0
? { refreshToken: managedRefreshToken, tokenExpiresAt: managedTokenExpiresAt }
: { refreshToken: managedRefreshToken };
}
}
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
const result = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: body.siteId,
username: username || undefined,
accessToken,
apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
},
insertErrorMessage: '创建账号失败',
loadErrorMessage: '创建账号失败',
});
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
let queuedTaskId: string | undefined;
let queuedMessage: string | undefined;
if (shouldQueueInitialization) {
const taskTitle = `初始化连接 #${result.id}`;
const { task } = startBackgroundTask(
{
type: 'account-init',
title: taskTitle,
dedupeKey: `account-init-${result.id}`,
notifyOnFailure: true,
successMessage: () => `${taskTitle}已完成`,
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
},
async () => initializeAccountInBackground({
accountId: result.id,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId: resolvedPlatformUserId,
skipModelFetch: body.skipModelFetch,
}),
);
queuedTaskId = task.id;
queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch);
}
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
if (!account) {
throw new Error('创建账号失败');
}
return {
account,
tokenType,
modelCount: verifiedModels.length,
apiTokenFound: !!apiToken,
usernameDetected: !!(!body.username && username),
queued: !!queuedTaskId,
jobId: queuedTaskId,
message: queuedMessage,
};
}
@@ -153,6 +153,7 @@ async function rollbackCreatedOauthRouteUnit(routeUnitId: number): Promise<void>
async function restoreDeletedOauthRouteUnit(snapshot: {
unit: typeof schema.oauthRouteUnits.$inferSelect;
members: Array<typeof schema.oauthRouteUnitMembers.$inferSelect>;
channels: Array<typeof schema.routeChannels.$inferSelect>;
}): Promise<void> {
await db.transaction(async (tx) => {
await tx.insert(schema.oauthRouteUnits).values({
@@ -176,6 +177,31 @@ async function restoreDeletedOauthRouteUnit(snapshot: {
updatedAt: member.updatedAt,
}))).run();
}
if (snapshot.channels.length > 0) {
await tx.insert(schema.routeChannels).values(snapshot.channels.map((channel) => ({
id: channel.id,
routeId: channel.routeId,
accountId: channel.accountId,
tokenId: channel.tokenId,
oauthRouteUnitId: channel.oauthRouteUnitId,
sourceModel: channel.sourceModel,
priority: channel.priority,
weight: channel.weight,
enabled: channel.enabled,
manualOverride: channel.manualOverride,
successCount: channel.successCount,
failCount: channel.failCount,
totalLatencyMs: channel.totalLatencyMs,
totalCost: channel.totalCost,
lastUsedAt: channel.lastUsedAt,
lastSelectedAt: channel.lastSelectedAt,
lastFailAt: channel.lastFailAt,
consecutiveFailCount: channel.consecutiveFailCount,
cooldownLevel: channel.cooldownLevel,
cooldownUntil: channel.cooldownUntil,
}))).run();
}
});
}
@@ -373,6 +399,9 @@ export async function deleteOauthRouteUnit(routeUnitId: number) {
const existingMembers = await db.select().from(schema.oauthRouteUnitMembers)
.where(eq(schema.oauthRouteUnitMembers.unitId, routeUnitId))
.all();
const existingChannels = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.oauthRouteUnitId, routeUnitId))
.all();
await db.delete(schema.routeChannels)
.where(eq(schema.routeChannels.oauthRouteUnitId, routeUnitId))
@@ -390,6 +419,7 @@ export async function deleteOauthRouteUnit(routeUnitId: number) {
await restoreDeletedOauthRouteUnit({
unit: existing,
members: existingMembers,
channels: existingChannels,
});
try {
await routeRefreshWorkflow.rebuildRoutesOnly();
@@ -0,0 +1,26 @@
export function parseBatchApiKeys(input: unknown): string[] {
if (Array.isArray(input)) {
return Array.from(new Set(
input
.map((item) => String(item || '').trim())
.filter((item) => item.length > 0),
));
}
const raw = String(input || '').trim();
if (!raw) return [];
return Array.from(new Set(
raw
.split(/[\r\n,;\s]+/)
.map((item) => item.trim())
.filter((item) => item.length > 0),
));
}
export function buildBatchApiKeyConnectionName(baseName: string | null | undefined, index: number, total: number): string {
const normalized = String(baseName || '').trim();
if (!normalized) return '';
if (total <= 1) return normalized;
return `${normalized} #${index + 1}`;
}
@@ -189,12 +189,11 @@ describe('TokenRouter patterns and model mapping', () => {
expect(exposedModels).toContain('claude-opus-4-6');
});
it('prefers an exact route over a colliding group display-name alias', async () => {
await createRouteWithSingleChannel(
're:^claude-(opus|sonnet)-4-5$',
it('prefers a group display-name alias over a colliding exact route', async () => {
const source = await createRouteWithSingleChannel(
'claude-opus-4-5',
undefined,
{
displayName: 'claude-opus-4-6',
sourceModel: 'claude-opus-4-5',
},
);
@@ -205,15 +204,18 @@ describe('TokenRouter patterns and model mapping', () => {
sourceModel: 'claude-opus-4-6',
},
);
const grouped = await createExplicitGroupRoute('claude-opus-4-6', [source.route.id]);
const router = new TokenRouter();
const selected = await router.selectChannel('claude-opus-4-6');
const decision = await router.explainSelection('claude-opus-4-6');
expect(selected).toBeTruthy();
expect(selected?.channel.id).toBe(exact.channel.id);
expect(selected?.actualModel).toBe('claude-opus-4-6');
expect(decision.actualModel).toBe('claude-opus-4-6');
expect(selected?.channel.routeId).toBe(source.route.id);
expect(selected?.channel.id).not.toBe(exact.channel.id);
expect(selected?.actualModel).toBe('claude-opus-4-5');
expect(decision.routeId).toBe(grouped.id);
expect(decision.actualModel).toBe('claude-opus-4-5');
});
it('falls back to the source exact-route model when explicit-group channels omit sourceModel', async () => {
+6 -6
View File
@@ -3041,12 +3041,12 @@ export class TokenRouter {
routes = routes.filter((route) => allowSet.has(route.id));
}
const matchedRoute = routes.find((route) => (
!isExplicitGroupRoute(route)
&& isExactRouteModelPattern(route.modelPattern)
&& (route.modelPattern || '').trim() === model
))
|| routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
const matchedRoute = routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => (
!isExplicitGroupRoute(route)
&& isExactRouteModelPattern(route.modelPattern)
&& (route.modelPattern || '').trim() === model
))
|| routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern));
+4
View File
@@ -0,0 +1,4 @@
export {
buildBatchApiKeyConnectionName,
parseBatchApiKeys,
} from '../server/services/shared/apiKeyBatchCore.js';
+49 -7
View File
@@ -29,6 +29,7 @@ import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from '.
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { SITE_DOCS_URL } from '../docsLink.js';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
import { parseBatchApiKeys } from '../../shared/apiKeyBatch.js';
type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
@@ -177,6 +178,11 @@ export default function Accounts() {
() => sites.find((item) => item.id === tokenForm.siteId) || null,
[sites, tokenForm.siteId],
);
const parsedApiKeys = useMemo(
() => activeSegment === 'apikey' ? parseBatchApiKeys(tokenForm.accessToken) : [],
[activeSegment, tokenForm.accessToken],
);
const isBatchApiKeyInput = activeSegment === 'apikey' && parsedApiKeys.length > 1;
const siteSelectOptions = useMemo(
() => [
{ value: '0', label: '选择站点' },
@@ -322,6 +328,10 @@ export default function Accounts() {
const handleVerifyToken = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return;
if (isBatchApiKeyInput) {
toast.info(`检测到 ${parsedApiKeys.length} 个 API Key,批量模式会在添加时逐条校验`);
return;
}
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
setVerifying(true);
setVerifyResult(null);
@@ -352,7 +362,7 @@ export default function Accounts() {
const handleTokenAdd = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return;
if (!verifyResult?.success && !tokenForm.skipModelFetch) {
if (!isBatchApiKeyInput && !verifyResult?.success && !tokenForm.skipModelFetch) {
toast.error('请先验证 Token 成功后再添加账号');
return;
}
@@ -364,6 +374,7 @@ export default function Accounts() {
siteId: tokenForm.siteId,
username: tokenForm.username.trim() || undefined,
accessToken: tokenForm.accessToken,
accessTokens: isBatchApiKeyInput ? parsedApiKeys : undefined,
platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined,
refreshToken: isSub2ApiSelected && tokenForm.refreshToken.trim()
? tokenForm.refreshToken.trim()
@@ -374,6 +385,23 @@ export default function Accounts() {
credentialMode,
skipModelFetch: tokenForm.skipModelFetch,
});
if (result?.batch) {
closeAddPanel();
const createdCount = Number(result.createdCount) || 0;
const failedCount = Number(result.failedCount) || 0;
if (createdCount > 0) {
toast.success(`批量添加完成:成功 ${createdCount},失败 ${failedCount}`);
}
const failedItems = Array.isArray(result.items)
? result.items.filter((item: any) => item?.status === 'failed')
: [];
if (failedItems.length > 0) {
const firstMessage = failedItems[0]?.message || '创建失败';
toast.error(`失败 ${failedItems.length} 条:${firstMessage}`);
}
load();
return;
}
let seededRecommendedModels = false;
const recommendedModels = initializationPreset?.recommendedModels || [];
const createdAccountId = Number(result?.id) || 0;
@@ -951,6 +979,9 @@ export default function Accounts() {
|| (activeSegment === 'session' && verifyResult.tokenType === 'session')
),
);
const canSubmitApiKeyConnection = activeSegment === 'apikey'
? (isBatchApiKeyInput || canAddVerifiedConnection || !!tokenForm.skipModelFetch)
: canAddVerifiedConnection;
return (
<div className="animate-fade-in">
@@ -1421,9 +1452,18 @@ export default function Accounts() {
<textarea
placeholder="粘贴 API Key"
value={tokenForm.accessToken}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value.trim(), credentialMode: 'apikey' })); setVerifyResult(null); }}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value, credentialMode: 'apikey' })); setVerifyResult(null); }}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }}
/>
{parsedApiKeys.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{parsedApiKeys.length} API Key
{isBatchApiKeyInput ? ',添加时会逐条创建同站点连接并参与轮询' : ''}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
API Key
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="用户 ID(可选)"
@@ -1479,23 +1519,25 @@ export default function Accounts() {
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken || isBatchApiKeyInput}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{verifying ? <><span className="spinner spinner-sm" />...</> : '验证 API Key'}
{verifying ? <><span className="spinner spinner-sm" />...</> : (isBatchApiKeyInput ? '批量添加时校验' : '验证 API Key')}
</button>
<button
onClick={handleTokenAdd}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || (!canAddVerifiedConnection && !tokenForm.skipModelFetch)}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !canSubmitApiKeyConnection}
className="btn btn-success"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />...</> : '添加连接'}
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />...</> : (isBatchApiKeyInput ? '批量添加连接' : '添加连接')}
</button>
</div>
{!verifyResult?.success && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{addAccountPrereqHint}
{isBatchApiKeyInput
? '批量模式下无需先点验证,提交后会逐条校验并创建。'
: addAccountPrereqHint}
</div>
)}
</div>