Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,3 +25,4 @@ docker/.env
|
||||
# Local planning docs
|
||||
docs/plan/
|
||||
docs/plans/
|
||||
.ace-tool/
|
||||
|
||||
Generated
+97
-131
@@ -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
@@ -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",
|
||||
|
||||
Generated
+201
-218
File diff suppressed because it is too large
Load Diff
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
buildBatchApiKeyConnectionName,
|
||||
parseBatchApiKeys,
|
||||
} from '../server/services/shared/apiKeyBatchCore.js';
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user