mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-12 20:48:31 +08:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7faca5512a | ||
|
|
ad84272084 | ||
|
|
09e0f594ff | ||
|
|
dd2fbf4424 | ||
|
|
99b12a49c6 | ||
|
|
ea35efe440 | ||
|
|
bf09e740e9 | ||
|
|
60c77cec56 | ||
|
|
0e4a1dddb5 | ||
|
|
1cf18b6e12 | ||
|
|
f9a8be898a | ||
|
|
1521ce5a96 | ||
|
|
f2e62dd197 | ||
|
|
d378630b38 | ||
|
|
d9e6346911 | ||
|
|
238788e0e9 | ||
|
|
68ff828505 | ||
|
|
59447fc12b | ||
|
|
c8033fb6ab | ||
|
|
e33d5b952c | ||
|
|
4345ac2ba2 | ||
|
|
a12b43ce5c | ||
|
|
6885cf1f6d | ||
|
|
00f6fafcfc | ||
|
|
42dc64246c | ||
|
|
fbe303a3cd | ||
|
|
373845450b | ||
|
|
084bbc0bef | ||
|
|
0061fc04b7 | ||
|
|
f6a6410626 | ||
|
|
835be3d329 | ||
|
|
2395093394 | ||
|
|
28209e1c2a | ||
|
|
00562dd1d4 | ||
|
|
0f78d5cbf3 | ||
|
|
431c6de8d2 | ||
|
|
142e15bbcc | ||
|
|
31acc5c607 | ||
|
|
bfa0a26d41 | ||
|
|
93ab9b6a5e | ||
|
|
35e29d46bd | ||
|
|
465da6f818 | ||
|
|
e5f12fddd9 | ||
|
|
4fa9a1303a | ||
|
|
43f349d415 | ||
|
|
02069954de | ||
|
|
2e15875fed | ||
|
|
b34cfb676d | ||
|
|
3064497636 | ||
|
|
dec681fea0 | ||
|
|
523e27ba9a | ||
|
|
e7db76e581 | ||
|
|
689339117a | ||
|
|
b202765be4 | ||
|
|
3bbf3073df | ||
|
|
f46aaa2182 | ||
|
|
a2f33a6c35 | ||
|
|
b6bd6357ed | ||
|
|
c3a5878b1b | ||
|
|
3e4309eba3 | ||
|
|
414f45aa71 | ||
|
|
ebdc76346f | ||
|
|
64bfa955f4 | ||
|
|
612992fa1f | ||
|
|
c02ac56da8 | ||
|
|
9bfb295238 |
23
.github/workflows/docker-publish.yml
vendored
23
.github/workflows/docker-publish.yml
vendored
@@ -146,10 +146,33 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=sha,prefix=
|
type=sha,prefix=
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
# 从 tag 提取版本号,如 v0.2.5 -> 0.2.5
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
|
if [ "$VERSION" = "$GITHUB_REF" ]; then
|
||||||
|
# 不是 tag 触发,使用 git describe
|
||||||
|
VERSION=$(git describe --tags --always | sed 's/^v//')
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Extracted version: $VERSION"
|
||||||
|
|
||||||
- name: Update Dockerfile.app to use registry base image
|
- name: Update Dockerfile.app to use registry base image
|
||||||
run: |
|
run: |
|
||||||
sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app
|
sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app
|
||||||
|
|
||||||
|
- name: Generate version file
|
||||||
|
run: |
|
||||||
|
# 生成 _version.py 文件
|
||||||
|
cat > src/_version.py << EOF
|
||||||
|
# Auto-generated by CI
|
||||||
|
__version__ = '${{ steps.version.outputs.version }}'
|
||||||
|
__version_tuple__ = tuple(int(x) for x in '${{ steps.version.outputs.version }}'.split('.') if x.isdigit())
|
||||||
|
version = __version__
|
||||||
|
version_tuple = __version_tuple__
|
||||||
|
EOF
|
||||||
|
|
||||||
- name: Build and push app image
|
- name: Build and push app image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -224,3 +224,6 @@ extracted_*.ts
|
|||||||
.deps-hash
|
.deps-hash
|
||||||
.code-hash
|
.code-hash
|
||||||
.migration-hash
|
.migration-hash
|
||||||
|
|
||||||
|
# Version file (auto-generated by hatch-vcs)
|
||||||
|
src/_version.py
|
||||||
|
|||||||
@@ -39,7 +39,18 @@ COPY alembic.ini ./
|
|||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
|
|
||||||
# Nginx 配置模板
|
# Nginx 配置模板
|
||||||
|
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
|
||||||
RUN printf '%s\n' \
|
RUN printf '%s\n' \
|
||||||
|
'map $http_x_real_ip $real_ip {' \
|
||||||
|
' default $http_x_real_ip;' \
|
||||||
|
' "" $remote_addr;' \
|
||||||
|
'}' \
|
||||||
|
'' \
|
||||||
|
'map $http_x_forwarded_for $forwarded_for {' \
|
||||||
|
' default $http_x_forwarded_for;' \
|
||||||
|
' "" $remote_addr;' \
|
||||||
|
'}' \
|
||||||
|
'' \
|
||||||
'server {' \
|
'server {' \
|
||||||
' listen 80;' \
|
' listen 80;' \
|
||||||
' server_name _;' \
|
' server_name _;' \
|
||||||
@@ -47,6 +58,15 @@ RUN printf '%s\n' \
|
|||||||
' index index.html;' \
|
' index index.html;' \
|
||||||
' client_max_body_size 100M;' \
|
' client_max_body_size 100M;' \
|
||||||
'' \
|
'' \
|
||||||
|
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
|
||||||
|
' gzip on;' \
|
||||||
|
' gzip_min_length 256;' \
|
||||||
|
' gzip_comp_level 5;' \
|
||||||
|
' gzip_vary on;' \
|
||||||
|
' gzip_proxied any;' \
|
||||||
|
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
|
||||||
|
' gzip_disable "msie6";' \
|
||||||
|
'' \
|
||||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||||
' expires 1y;' \
|
' expires 1y;' \
|
||||||
' add_header Cache-Control "public, no-transform";' \
|
' add_header Cache-Control "public, no-transform";' \
|
||||||
@@ -62,6 +82,15 @@ RUN printf '%s\n' \
|
|||||||
' try_files $uri $uri/ /index.html;' \
|
' try_files $uri $uri/ /index.html;' \
|
||||||
' }' \
|
' }' \
|
||||||
'' \
|
'' \
|
||||||
|
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
|
||||||
|
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||||
|
' proxy_http_version 1.1;' \
|
||||||
|
' proxy_set_header Host $host;' \
|
||||||
|
' proxy_set_header X-Real-IP $real_ip;' \
|
||||||
|
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||||
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
' location / {' \
|
' location / {' \
|
||||||
' try_files $uri $uri/ @backend;' \
|
' try_files $uri $uri/ @backend;' \
|
||||||
' }' \
|
' }' \
|
||||||
@@ -70,8 +99,8 @@ RUN printf '%s\n' \
|
|||||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||||
' proxy_http_version 1.1;' \
|
' proxy_http_version 1.1;' \
|
||||||
' proxy_set_header Host $host;' \
|
' proxy_set_header Host $host;' \
|
||||||
' proxy_set_header X-Real-IP $remote_addr;' \
|
' proxy_set_header X-Real-IP $real_ip;' \
|
||||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
' proxy_set_header Connection "";' \
|
' proxy_set_header Connection "";' \
|
||||||
' proxy_set_header Accept $http_accept;' \
|
' proxy_set_header Accept $http_accept;' \
|
||||||
@@ -98,14 +127,14 @@ RUN printf '%s\n' \
|
|||||||
'pidfile=/var/run/supervisord.pid' \
|
'pidfile=/var/run/supervisord.pid' \
|
||||||
'' \
|
'' \
|
||||||
'[program:nginx]' \
|
'[program:nginx]' \
|
||||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/8084/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||||
'autostart=true' \
|
'autostart=true' \
|
||||||
'autorestart=true' \
|
'autorestart=true' \
|
||||||
'stdout_logfile=/var/log/nginx/access.log' \
|
'stdout_logfile=/var/log/nginx/access.log' \
|
||||||
'stderr_logfile=/var/log/nginx/error.log' \
|
'stderr_logfile=/var/log/nginx/error.log' \
|
||||||
'' \
|
'' \
|
||||||
'[program:app]' \
|
'[program:app]' \
|
||||||
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:8084 --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
||||||
'directory=/app' \
|
'directory=/app' \
|
||||||
'autostart=true' \
|
'autostart=true' \
|
||||||
'autorestart=true' \
|
'autorestart=true' \
|
||||||
@@ -118,17 +147,23 @@ RUN printf '%s\n' \
|
|||||||
# 创建目录
|
# 创建目录
|
||||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||||
|
|
||||||
|
# 入口脚本(启动前执行迁移)
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# 环境变量
|
# 环境变量
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONIOENCODING=utf-8 \
|
PYTHONIOENCODING=utf-8 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
LC_ALL=C.UTF-8 \
|
LC_ALL=C.UTF-8 \
|
||||||
PORT=8084
|
PORT=8084 \
|
||||||
|
GUNICORN_WORKERS=4
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD curl -f http://localhost/health || exit 1
|
CMD curl -f http://localhost/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
@@ -40,7 +40,18 @@ COPY alembic.ini ./
|
|||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
|
|
||||||
# Nginx 配置模板
|
# Nginx 配置模板
|
||||||
|
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
|
||||||
RUN printf '%s\n' \
|
RUN printf '%s\n' \
|
||||||
|
'map $http_x_real_ip $real_ip {' \
|
||||||
|
' default $http_x_real_ip;' \
|
||||||
|
' "" $remote_addr;' \
|
||||||
|
'}' \
|
||||||
|
'' \
|
||||||
|
'map $http_x_forwarded_for $forwarded_for {' \
|
||||||
|
' default $http_x_forwarded_for;' \
|
||||||
|
' "" $remote_addr;' \
|
||||||
|
'}' \
|
||||||
|
'' \
|
||||||
'server {' \
|
'server {' \
|
||||||
' listen 80;' \
|
' listen 80;' \
|
||||||
' server_name _;' \
|
' server_name _;' \
|
||||||
@@ -48,6 +59,15 @@ RUN printf '%s\n' \
|
|||||||
' index index.html;' \
|
' index index.html;' \
|
||||||
' client_max_body_size 100M;' \
|
' client_max_body_size 100M;' \
|
||||||
'' \
|
'' \
|
||||||
|
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
|
||||||
|
' gzip on;' \
|
||||||
|
' gzip_min_length 256;' \
|
||||||
|
' gzip_comp_level 5;' \
|
||||||
|
' gzip_vary on;' \
|
||||||
|
' gzip_proxied any;' \
|
||||||
|
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
|
||||||
|
' gzip_disable "msie6";' \
|
||||||
|
'' \
|
||||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||||
' expires 1y;' \
|
' expires 1y;' \
|
||||||
' add_header Cache-Control "public, no-transform";' \
|
' add_header Cache-Control "public, no-transform";' \
|
||||||
@@ -71,8 +91,8 @@ RUN printf '%s\n' \
|
|||||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||||
' proxy_http_version 1.1;' \
|
' proxy_http_version 1.1;' \
|
||||||
' proxy_set_header Host $host;' \
|
' proxy_set_header Host $host;' \
|
||||||
' proxy_set_header X-Real-IP $remote_addr;' \
|
' proxy_set_header X-Real-IP $real_ip;' \
|
||||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
' proxy_set_header Connection "";' \
|
' proxy_set_header Connection "";' \
|
||||||
' proxy_set_header Accept $http_accept;' \
|
' proxy_set_header Accept $http_accept;' \
|
||||||
@@ -119,6 +139,10 @@ RUN printf '%s\n' \
|
|||||||
# 创建目录
|
# 创建目录
|
||||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||||
|
|
||||||
|
# 入口脚本(启动前执行迁移)
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# 环境变量
|
# 环境变量
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
@@ -132,4 +156,5 @@ EXPOSE 80
|
|||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD curl -f http://localhost/health || exit 1
|
CMD curl -f http://localhost/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -51,20 +51,14 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
|
|||||||
```bash
|
```bash
|
||||||
# 1. 克隆代码
|
# 1. 克隆代码
|
||||||
git clone https://github.com/fawney19/Aether.git
|
git clone https://github.com/fawney19/Aether.git
|
||||||
cd aether
|
cd Aether
|
||||||
|
|
||||||
# 2. 配置环境变量
|
# 2. 配置环境变量
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||||
|
|
||||||
# 3. 部署
|
# 3. 部署 / 更新(自动执行数据库迁移)
|
||||||
docker-compose up -d
|
docker compose pull && docker compose up -d
|
||||||
|
|
||||||
# 4. 首次部署时, 初始化数据库
|
|
||||||
./migrate.sh
|
|
||||||
|
|
||||||
# 5. 更新
|
|
||||||
docker-compose pull && docker-compose up -d && ./migrate.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Compose(本地构建镜像)
|
### Docker Compose(本地构建镜像)
|
||||||
@@ -72,7 +66,7 @@ docker-compose pull && docker-compose up -d && ./migrate.sh
|
|||||||
```bash
|
```bash
|
||||||
# 1. 克隆代码
|
# 1. 克隆代码
|
||||||
git clone https://github.com/fawney19/Aether.git
|
git clone https://github.com/fawney19/Aether.git
|
||||||
cd aether
|
cd Aether
|
||||||
|
|
||||||
# 2. 配置环境变量
|
# 2. 配置环境变量
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -86,7 +80,7 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动依赖
|
# 启动依赖
|
||||||
docker-compose -f docker-compose.build.yml up -d postgres redis
|
docker compose -f docker-compose.build.yml up -d postgres redis
|
||||||
|
|
||||||
# 后端
|
# 后端
|
||||||
uv sync
|
uv sync
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from src.models.database import Base
|
|||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
# 从环境变量获取数据库 URL
|
# 从环境变量获取数据库 URL
|
||||||
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker-compose 保持一致)
|
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker compose 保持一致)
|
||||||
database_url = os.getenv("DATABASE_URL")
|
database_url = os.getenv("DATABASE_URL")
|
||||||
if not database_url:
|
if not database_url:
|
||||||
db_password = os.getenv("DB_PASSWORD", "")
|
db_password = os.getenv("DB_PASSWORD", "")
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""add ldap authentication support
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6g7h8
|
||||||
|
Revises: b2c3d4e5f6g7
|
||||||
|
Create Date: 2026-01-01 14:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c3d4e5f6g7h8'
|
||||||
|
down_revision = 'b2c3d4e5f6g7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _type_exists(conn, type_name: str) -> bool:
|
||||||
|
"""检查 PostgreSQL 类型是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_type WHERE typname = :name"),
|
||||||
|
{"name": type_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = :table AND column_name = :column
|
||||||
|
"""),
|
||||||
|
{"table": table_name, "column": column_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(conn, index_name: str) -> bool:
|
||||||
|
"""检查索引是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||||
|
{"name": index_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table_name: str) -> bool:
|
||||||
|
"""检查表是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = :name AND table_schema = 'public'
|
||||||
|
"""),
|
||||||
|
{"name": table_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""添加 LDAP 认证支持
|
||||||
|
|
||||||
|
1. 创建 authsource 枚举类型
|
||||||
|
2. 在 users 表添加 auth_source 字段和 LDAP 标识字段
|
||||||
|
3. 创建 ldap_configs 表
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1. 创建 authsource 枚举类型(幂等)
|
||||||
|
if not _type_exists(conn, 'authsource'):
|
||||||
|
conn.execute(text("CREATE TYPE authsource AS ENUM ('local', 'ldap')"))
|
||||||
|
|
||||||
|
# 2. 在 users 表添加字段(幂等)
|
||||||
|
if not _column_exists(conn, 'users', 'auth_source'):
|
||||||
|
op.add_column('users', sa.Column(
|
||||||
|
'auth_source',
|
||||||
|
sa.Enum('local', 'ldap', name='authsource', create_type=False),
|
||||||
|
nullable=False,
|
||||||
|
server_default='local'
|
||||||
|
))
|
||||||
|
|
||||||
|
if not _column_exists(conn, 'users', 'ldap_dn'):
|
||||||
|
op.add_column('users', sa.Column('ldap_dn', sa.String(length=512), nullable=True))
|
||||||
|
|
||||||
|
if not _column_exists(conn, 'users', 'ldap_username'):
|
||||||
|
op.add_column('users', sa.Column('ldap_username', sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
# 创建索引(幂等)
|
||||||
|
if not _index_exists(conn, 'ix_users_ldap_dn'):
|
||||||
|
op.create_index('ix_users_ldap_dn', 'users', ['ldap_dn'])
|
||||||
|
|
||||||
|
if not _index_exists(conn, 'ix_users_ldap_username'):
|
||||||
|
op.create_index('ix_users_ldap_username', 'users', ['ldap_username'])
|
||||||
|
|
||||||
|
# 3. 创建 ldap_configs 表(幂等)
|
||||||
|
if not _table_exists(conn, 'ldap_configs'):
|
||||||
|
op.create_table(
|
||||||
|
'ldap_configs',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('server_url', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('bind_dn', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('bind_password_encrypted', sa.Text(), nullable=True),
|
||||||
|
sa.Column('base_dn', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('user_search_filter', sa.String(length=500), nullable=False, server_default='(uid={username})'),
|
||||||
|
sa.Column('username_attr', sa.String(length=50), nullable=False, server_default='uid'),
|
||||||
|
sa.Column('email_attr', sa.String(length=50), nullable=False, server_default='mail'),
|
||||||
|
sa.Column('display_name_attr', sa.String(length=50), nullable=False, server_default='cn'),
|
||||||
|
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('is_exclusive', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('use_starttls', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('connect_timeout', sa.Integer(), nullable=False, server_default='10'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚 LDAP 认证支持
|
||||||
|
|
||||||
|
警告:回滚前请确保:
|
||||||
|
1. 已备份数据库
|
||||||
|
2. 没有 LDAP 用户需要保留
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 检查是否存在 LDAP 用户,防止数据丢失
|
||||||
|
if _column_exists(conn, 'users', 'auth_source'):
|
||||||
|
result = conn.execute(text("SELECT COUNT(*) FROM users WHERE auth_source = 'ldap'"))
|
||||||
|
ldap_user_count = result.scalar()
|
||||||
|
if ldap_user_count and ldap_user_count > 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"无法回滚:存在 {ldap_user_count} 个 LDAP 用户。"
|
||||||
|
f"请先删除或转换这些用户,或使用 --force 参数强制回滚(将丢失数据)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 删除 ldap_configs 表(幂等)
|
||||||
|
if _table_exists(conn, 'ldap_configs'):
|
||||||
|
op.drop_table('ldap_configs')
|
||||||
|
|
||||||
|
# 2. 删除 users 表的 LDAP 相关字段(幂等)
|
||||||
|
if _index_exists(conn, 'ix_users_ldap_username'):
|
||||||
|
op.drop_index('ix_users_ldap_username', table_name='users')
|
||||||
|
|
||||||
|
if _index_exists(conn, 'ix_users_ldap_dn'):
|
||||||
|
op.drop_index('ix_users_ldap_dn', table_name='users')
|
||||||
|
|
||||||
|
if _column_exists(conn, 'users', 'ldap_username'):
|
||||||
|
op.drop_column('users', 'ldap_username')
|
||||||
|
|
||||||
|
if _column_exists(conn, 'users', 'ldap_dn'):
|
||||||
|
op.drop_column('users', 'ldap_dn')
|
||||||
|
|
||||||
|
if _column_exists(conn, 'users', 'auth_source'):
|
||||||
|
op.drop_column('users', 'auth_source')
|
||||||
|
|
||||||
|
# 3. 删除 authsource 枚举类型(幂等)
|
||||||
|
# 注意:不使用 CASCADE,因为此时所有依赖应该已被删除
|
||||||
|
if _type_exists(conn, 'authsource'):
|
||||||
|
conn.execute(text("DROP TYPE authsource"))
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""add_management_tokens_table
|
||||||
|
|
||||||
|
Revision ID: ad55f1d008b7
|
||||||
|
Revises: c3d4e5f6g7h8
|
||||||
|
Create Date: 2026-01-06 15:24:10.660394+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'ad55f1d008b7'
|
||||||
|
down_revision = 'c3d4e5f6g7h8'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
"""检查表是否存在"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
"""检查索引是否存在"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
try:
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx["name"] == index_name for idx in indexes)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||||
|
"""检查约束是否存在"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
try:
|
||||||
|
constraints = inspector.get_unique_constraints(table_name)
|
||||||
|
if any(c["name"] == constraint_name for c in constraints):
|
||||||
|
return True
|
||||||
|
# 也检查 check 约束
|
||||||
|
check_constraints = inspector.get_check_constraints(table_name)
|
||||||
|
if any(c["name"] == constraint_name for c in check_constraints):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移:创建 management_tokens 表"""
|
||||||
|
# 幂等性检查
|
||||||
|
if table_exists("management_tokens"):
|
||||||
|
# 表已存在,检查是否需要添加约束
|
||||||
|
if not constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_management_tokens_user_name",
|
||||||
|
"management_tokens",
|
||||||
|
["user_id", "name"],
|
||||||
|
)
|
||||||
|
# 添加 IP 白名单非空检查约束
|
||||||
|
if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||||
|
op.create_check_constraint(
|
||||||
|
"check_allowed_ips_not_empty",
|
||||||
|
"management_tokens",
|
||||||
|
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table('management_tokens',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('token_prefix', sa.String(length=12), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('allowed_ips', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_used_ip', sa.String(length=45), nullable=True),
|
||||||
|
sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False)
|
||||||
|
op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True)
|
||||||
|
# 添加用户名称唯一约束
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_management_tokens_user_name",
|
||||||
|
"management_tokens",
|
||||||
|
["user_id", "name"],
|
||||||
|
)
|
||||||
|
# 添加 IP 白名单非空检查约束
|
||||||
|
# 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
|
||||||
|
op.create_check_constraint(
|
||||||
|
"check_allowed_ips_not_empty",
|
||||||
|
"management_tokens",
|
||||||
|
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移:删除 management_tokens 表"""
|
||||||
|
# 幂等性检查
|
||||||
|
if not table_exists("management_tokens"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 删除约束
|
||||||
|
if constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||||
|
op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check")
|
||||||
|
if constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||||
|
op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique")
|
||||||
|
|
||||||
|
# 删除索引
|
||||||
|
if index_exists("management_tokens", "ix_management_tokens_token_hash"):
|
||||||
|
op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens')
|
||||||
|
if index_exists("management_tokens", "idx_management_tokens_user_id"):
|
||||||
|
op.drop_index('idx_management_tokens_user_id', table_name='management_tokens')
|
||||||
|
if index_exists("management_tokens", "idx_management_tokens_is_active"):
|
||||||
|
op.drop_index('idx_management_tokens_is_active', table_name='management_tokens')
|
||||||
|
|
||||||
|
# 删除表
|
||||||
|
op.drop_table('management_tokens')
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""cleanup ambiguous database fields
|
||||||
|
|
||||||
|
Revision ID: 02a45b66b7c4
|
||||||
|
Revises: ad55f1d008b7
|
||||||
|
Create Date: 2026-01-07 11:20:12.684426+00:00
|
||||||
|
|
||||||
|
变更内容:
|
||||||
|
1. users 表:重命名 allowed_endpoints 为 allowed_api_formats(修正历史命名错误)
|
||||||
|
2. api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
|
||||||
|
3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复,且未使用)
|
||||||
|
4. usage 表:重命名 provider 为 provider_name(避免与 provider_id 外键混淆)
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '02a45b66b7c4'
|
||||||
|
down_revision = 'ad55f1d008b7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""
|
||||||
|
1. users.allowed_endpoints -> allowed_api_formats(重命名)
|
||||||
|
2. api_keys.allowed_endpoints 删除
|
||||||
|
3. providers.rate_limit 删除(与 rpm_limit 重复)
|
||||||
|
4. usage.provider -> provider_name(重命名)
|
||||||
|
"""
|
||||||
|
# 1. users 表:重命名 allowed_endpoints 为 allowed_api_formats
|
||||||
|
if _column_exists('users', 'allowed_endpoints'):
|
||||||
|
op.alter_column('users', 'allowed_endpoints', new_column_name='allowed_api_formats')
|
||||||
|
|
||||||
|
# 2. api_keys 表:删除 allowed_endpoints 字段
|
||||||
|
if _column_exists('api_keys', 'allowed_endpoints'):
|
||||||
|
op.drop_column('api_keys', 'allowed_endpoints')
|
||||||
|
|
||||||
|
# 3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复)
|
||||||
|
if _column_exists('providers', 'rate_limit'):
|
||||||
|
op.drop_column('providers', 'rate_limit')
|
||||||
|
|
||||||
|
# 4. usage 表:重命名 provider 为 provider_name
|
||||||
|
if _column_exists('usage', 'provider'):
|
||||||
|
op.alter_column('usage', 'provider', new_column_name='provider_name')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚:恢复原字段"""
|
||||||
|
# 4. usage 表:将 provider_name 改回 provider
|
||||||
|
if _column_exists('usage', 'provider_name'):
|
||||||
|
op.alter_column('usage', 'provider_name', new_column_name='provider')
|
||||||
|
|
||||||
|
# 3. providers 表:恢复 rate_limit 字段
|
||||||
|
if not _column_exists('providers', 'rate_limit'):
|
||||||
|
op.add_column('providers', sa.Column('rate_limit', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# 2. api_keys 表:恢复 allowed_endpoints 字段
|
||||||
|
if not _column_exists('api_keys', 'allowed_endpoints'):
|
||||||
|
op.add_column('api_keys', sa.Column('allowed_endpoints', sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
# 1. users 表:将 allowed_api_formats 改回 allowed_endpoints
|
||||||
|
if _column_exists('users', 'allowed_api_formats'):
|
||||||
|
op.alter_column('users', 'allowed_api_formats', new_column_name='allowed_endpoints')
|
||||||
530
alembic/versions/20260110_2000_consolidated_schema_updates.py
Normal file
530
alembic/versions/20260110_2000_consolidated_schema_updates.py
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
"""consolidated schema updates
|
||||||
|
|
||||||
|
Revision ID: m4n5o6p7q8r9
|
||||||
|
Revises: 02a45b66b7c4
|
||||||
|
Create Date: 2026-01-10 20:00:00.000000
|
||||||
|
|
||||||
|
This migration consolidates all schema changes from 2026-01-08 to 2026-01-10:
|
||||||
|
|
||||||
|
1. provider_api_keys: Key 直接关联 Provider (provider_id, api_formats)
|
||||||
|
2. provider_api_keys: 添加 rate_multipliers JSON 字段(按格式费率)
|
||||||
|
3. models: global_model_id 改为可空(支持独立 ProviderModel)
|
||||||
|
4. providers: 添加 timeout, max_retries, proxy(从 endpoint 迁移)
|
||||||
|
5. providers: display_name 重命名为 name,删除原 name
|
||||||
|
6. provider_api_keys: max_concurrent -> rpm_limit(并发改 RPM)
|
||||||
|
7. provider_api_keys: 健康度改为按格式存储(health_by_format, circuit_breaker_by_format)
|
||||||
|
8. provider_endpoints: 删除废弃的 rate_limit 列
|
||||||
|
9. usage: 添加 client_response_headers 字段
|
||||||
|
10. provider_api_keys: 删除 endpoint_id(Key 不再与 Endpoint 绑定)
|
||||||
|
11. provider_endpoints: 删除废弃的 max_concurrent 列
|
||||||
|
12. providers: 删除废弃的 rpm_limit, rpm_used, rpm_reset_at 列
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
alembic_logger = logging.getLogger("alembic.runtime.migration")
|
||||||
|
|
||||||
|
revision = "m4n5o6p7q8r9"
|
||||||
|
down_revision = "02a45b66b7c4"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
"""Check if a column exists in the table"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||||
|
"""Check if a constraint exists"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
fks = inspector.get_foreign_keys(table_name)
|
||||||
|
return any(fk.get("name") == constraint_name for fk in fks)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
"""Check if an index exists"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx.get("name") == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Apply all consolidated schema changes"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# ========== 1. provider_api_keys: 添加 provider_id 和 api_formats ==========
|
||||||
|
if not _column_exists("provider_api_keys", "provider_id"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("provider_id", sa.String(36), nullable=True))
|
||||||
|
|
||||||
|
# 数据迁移:从 endpoint 获取 provider_id
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys k
|
||||||
|
SET provider_id = e.provider_id
|
||||||
|
FROM provider_endpoints e
|
||||||
|
WHERE k.endpoint_id = e.id AND k.provider_id IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 检查无法关联的孤儿 Key
|
||||||
|
result = bind.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM provider_api_keys WHERE provider_id IS NULL"
|
||||||
|
))
|
||||||
|
orphan_count = result.scalar() or 0
|
||||||
|
if orphan_count > 0:
|
||||||
|
# 使用 logger 记录更明显的告警
|
||||||
|
alembic_logger.warning("=" * 60)
|
||||||
|
alembic_logger.warning(f"[MIGRATION WARNING] 发现 {orphan_count} 个无法关联 Provider 的孤儿 Key")
|
||||||
|
alembic_logger.warning("=" * 60)
|
||||||
|
alembic_logger.info("正在备份孤儿 Key 到 _orphan_api_keys_backup 表...")
|
||||||
|
|
||||||
|
# 先备份孤儿数据到临时表,避免数据丢失
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS _orphan_api_keys_backup AS
|
||||||
|
SELECT *, NOW() as backup_at
|
||||||
|
FROM provider_api_keys
|
||||||
|
WHERE provider_id IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 记录备份的 Key ID
|
||||||
|
orphan_ids = bind.execute(sa.text(
|
||||||
|
"SELECT id, name FROM provider_api_keys WHERE provider_id IS NULL"
|
||||||
|
)).fetchall()
|
||||||
|
alembic_logger.info("备份的孤儿 Key 列表:")
|
||||||
|
for key_id, key_name in orphan_ids:
|
||||||
|
alembic_logger.info(f" - Key: {key_name} (ID: {key_id})")
|
||||||
|
|
||||||
|
# 删除孤儿数据
|
||||||
|
op.execute("DELETE FROM provider_api_keys WHERE provider_id IS NULL")
|
||||||
|
alembic_logger.info(f"已备份并删除 {orphan_count} 个孤儿 Key")
|
||||||
|
|
||||||
|
# 提供恢复指南
|
||||||
|
alembic_logger.warning("-" * 60)
|
||||||
|
alembic_logger.warning("[恢复指南] 如需恢复孤儿 Key:")
|
||||||
|
alembic_logger.warning(" 1. 查询备份表: SELECT * FROM _orphan_api_keys_backup;")
|
||||||
|
alembic_logger.warning(" 2. 确定正确的 provider_id")
|
||||||
|
alembic_logger.warning(" 3. 执行恢复:")
|
||||||
|
alembic_logger.warning(" INSERT INTO provider_api_keys (...)")
|
||||||
|
alembic_logger.warning(" SELECT ... FROM _orphan_api_keys_backup WHERE ...;")
|
||||||
|
alembic_logger.warning("-" * 60)
|
||||||
|
|
||||||
|
# 设置 NOT NULL 并创建外键
|
||||||
|
op.alter_column("provider_api_keys", "provider_id", nullable=False)
|
||||||
|
|
||||||
|
if not _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"):
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_provider_api_keys_provider",
|
||||||
|
"provider_api_keys",
|
||||||
|
"providers",
|
||||||
|
["provider_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"):
|
||||||
|
op.create_index("idx_provider_api_keys_provider_id", "provider_api_keys", ["provider_id"])
|
||||||
|
|
||||||
|
if not _column_exists("provider_api_keys", "api_formats"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("api_formats", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
# 数据迁移:从 endpoint 获取 api_format
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys k
|
||||||
|
SET api_formats = json_build_array(e.api_format)
|
||||||
|
FROM provider_endpoints e
|
||||||
|
WHERE k.endpoint_id = e.id AND k.api_formats IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.alter_column("provider_api_keys", "api_formats", nullable=False, server_default="[]")
|
||||||
|
|
||||||
|
# 修改 endpoint_id 为可空,外键改为 SET NULL
|
||||||
|
if _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"):
|
||||||
|
op.drop_constraint("provider_api_keys_endpoint_id_fkey", "provider_api_keys", type_="foreignkey")
|
||||||
|
op.alter_column("provider_api_keys", "endpoint_id", nullable=True)
|
||||||
|
# 不再重建外键,因为后面会删除这个字段
|
||||||
|
|
||||||
|
# ========== 2. provider_api_keys: 添加 rate_multipliers ==========
|
||||||
|
if not _column_exists("provider_api_keys", "rate_multipliers"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("rate_multipliers", postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 数据迁移:将 rate_multiplier 按 api_formats 转换
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET rate_multipliers = (
|
||||||
|
SELECT jsonb_object_agg(elem, rate_multiplier)
|
||||||
|
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
|
||||||
|
)
|
||||||
|
WHERE api_formats IS NOT NULL
|
||||||
|
AND api_formats::text != '[]'
|
||||||
|
AND api_formats::text != 'null'
|
||||||
|
AND rate_multipliers IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ========== 3. models: global_model_id 改为可空 ==========
|
||||||
|
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=True)
|
||||||
|
|
||||||
|
# ========== 4. providers: 添加 timeout, max_retries, proxy ==========
|
||||||
|
if not _column_exists("providers", "timeout"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("timeout", sa.Integer(), nullable=True, comment="请求超时(秒)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists("providers", "max_retries"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("max_retries", sa.Integer(), nullable=True, comment="最大重试次数"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists("providers", "proxy"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("proxy", postgresql.JSONB(), nullable=True, comment="代理配置"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 从端点迁移数据到 provider
|
||||||
|
op.execute("""
|
||||||
|
UPDATE providers p
|
||||||
|
SET
|
||||||
|
timeout = COALESCE(
|
||||||
|
p.timeout,
|
||||||
|
(SELECT MAX(e.timeout) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.timeout IS NOT NULL),
|
||||||
|
300
|
||||||
|
),
|
||||||
|
max_retries = COALESCE(
|
||||||
|
p.max_retries,
|
||||||
|
(SELECT MAX(e.max_retries) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.max_retries IS NOT NULL),
|
||||||
|
2
|
||||||
|
),
|
||||||
|
proxy = COALESCE(
|
||||||
|
p.proxy,
|
||||||
|
(SELECT e.proxy FROM provider_endpoints e WHERE e.provider_id = p.id AND e.proxy IS NOT NULL ORDER BY e.created_at LIMIT 1)
|
||||||
|
)
|
||||||
|
WHERE p.timeout IS NULL OR p.max_retries IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ========== 5. providers: display_name -> name ==========
|
||||||
|
# 注意:这里假设 display_name 已经被重命名为 name
|
||||||
|
# 如果 display_name 仍然存在,则需要执行重命名
|
||||||
|
if _column_exists("providers", "display_name"):
|
||||||
|
# 删除旧的 name 索引
|
||||||
|
if _index_exists("providers", "ix_providers_name"):
|
||||||
|
op.drop_index("ix_providers_name", table_name="providers")
|
||||||
|
|
||||||
|
# 如果存在旧的 name 列,先删除
|
||||||
|
if _column_exists("providers", "name"):
|
||||||
|
op.drop_column("providers", "name")
|
||||||
|
|
||||||
|
# 重命名 display_name 为 name
|
||||||
|
op.alter_column("providers", "display_name", new_column_name="name")
|
||||||
|
|
||||||
|
# 创建新索引
|
||||||
|
op.create_index("ix_providers_name", "providers", ["name"], unique=True)
|
||||||
|
|
||||||
|
# ========== 6. provider_api_keys: max_concurrent -> rpm_limit ==========
|
||||||
|
if _column_exists("provider_api_keys", "max_concurrent"):
|
||||||
|
op.alter_column("provider_api_keys", "max_concurrent", new_column_name="rpm_limit")
|
||||||
|
|
||||||
|
if _column_exists("provider_api_keys", "learned_max_concurrent"):
|
||||||
|
op.alter_column("provider_api_keys", "learned_max_concurrent", new_column_name="learned_rpm_limit")
|
||||||
|
|
||||||
|
if _column_exists("provider_api_keys", "last_concurrent_peak"):
|
||||||
|
op.alter_column("provider_api_keys", "last_concurrent_peak", new_column_name="last_rpm_peak")
|
||||||
|
|
||||||
|
# 删除废弃字段
|
||||||
|
for col in ["rate_limit", "daily_limit", "monthly_limit"]:
|
||||||
|
if _column_exists("provider_api_keys", col):
|
||||||
|
op.drop_column("provider_api_keys", col)
|
||||||
|
|
||||||
|
# ========== 7. provider_api_keys: 健康度改为按格式存储 ==========
|
||||||
|
if not _column_exists("provider_api_keys", "health_by_format"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column(
|
||||||
|
"health_by_format",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
comment="按API格式存储的健康度数据",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists("provider_api_keys", "circuit_breaker_by_format"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column(
|
||||||
|
"circuit_breaker_by_format",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
comment="按API格式存储的熔断器状态",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 数据迁移:如果存在旧字段,迁移数据到新结构
|
||||||
|
if _column_exists("provider_api_keys", "health_score"):
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET health_by_format = (
|
||||||
|
SELECT jsonb_object_agg(
|
||||||
|
elem,
|
||||||
|
jsonb_build_object(
|
||||||
|
'health_score', COALESCE(health_score, 1.0),
|
||||||
|
'consecutive_failures', COALESCE(consecutive_failures, 0),
|
||||||
|
'last_failure_at', last_failure_at,
|
||||||
|
'request_results_window', COALESCE(request_results_window::jsonb, '[]'::jsonb)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
|
||||||
|
)
|
||||||
|
WHERE api_formats IS NOT NULL
|
||||||
|
AND api_formats::text != '[]'
|
||||||
|
AND health_by_format IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Circuit Breaker 迁移策略:
|
||||||
|
# 不复制旧的 circuit_breaker_open 状态到所有 format,而是全部重置为 closed
|
||||||
|
# 原因:旧的单一 circuit breaker 状态可能因某一个 format 失败而打开,
|
||||||
|
# 如果复制到所有 format,会导致其他正常工作的 format 被错误标记为不可用
|
||||||
|
if _column_exists("provider_api_keys", "circuit_breaker_open"):
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET circuit_breaker_by_format = (
|
||||||
|
SELECT jsonb_object_agg(
|
||||||
|
elem,
|
||||||
|
jsonb_build_object(
|
||||||
|
'open', false,
|
||||||
|
'open_at', NULL,
|
||||||
|
'next_probe_at', NULL,
|
||||||
|
'half_open_until', NULL,
|
||||||
|
'half_open_successes', 0,
|
||||||
|
'half_open_failures', 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
|
||||||
|
)
|
||||||
|
WHERE api_formats IS NOT NULL
|
||||||
|
AND api_formats::text != '[]'
|
||||||
|
AND circuit_breaker_by_format IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 设置默认空对象
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET health_by_format = '{}'::jsonb
|
||||||
|
WHERE health_by_format IS NULL
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET circuit_breaker_by_format = '{}'::jsonb
|
||||||
|
WHERE circuit_breaker_by_format IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 创建 GIN 索引
|
||||||
|
if not _index_exists("provider_api_keys", "ix_provider_api_keys_health_by_format"):
|
||||||
|
op.create_index(
|
||||||
|
"ix_provider_api_keys_health_by_format",
|
||||||
|
"provider_api_keys",
|
||||||
|
["health_by_format"],
|
||||||
|
postgresql_using="gin",
|
||||||
|
)
|
||||||
|
if not _index_exists("provider_api_keys", "ix_provider_api_keys_circuit_breaker_by_format"):
|
||||||
|
op.create_index(
|
||||||
|
"ix_provider_api_keys_circuit_breaker_by_format",
|
||||||
|
"provider_api_keys",
|
||||||
|
["circuit_breaker_by_format"],
|
||||||
|
postgresql_using="gin",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除旧字段
|
||||||
|
old_health_columns = [
|
||||||
|
"health_score",
|
||||||
|
"consecutive_failures",
|
||||||
|
"last_failure_at",
|
||||||
|
"request_results_window",
|
||||||
|
"circuit_breaker_open",
|
||||||
|
"circuit_breaker_open_at",
|
||||||
|
"next_probe_at",
|
||||||
|
"half_open_until",
|
||||||
|
"half_open_successes",
|
||||||
|
"half_open_failures",
|
||||||
|
]
|
||||||
|
for col in old_health_columns:
|
||||||
|
if _column_exists("provider_api_keys", col):
|
||||||
|
op.drop_column("provider_api_keys", col)
|
||||||
|
|
||||||
|
# ========== 8. provider_endpoints: 删除废弃的 rate_limit 列 ==========
|
||||||
|
if _column_exists("provider_endpoints", "rate_limit"):
|
||||||
|
op.drop_column("provider_endpoints", "rate_limit")
|
||||||
|
|
||||||
|
# ========== 9. usage: 添加 client_response_headers ==========
|
||||||
|
if not _column_exists("usage", "client_response_headers"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column("client_response_headers", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== 10. provider_api_keys: 删除 endpoint_id ==========
|
||||||
|
# Key 不再与 Endpoint 绑定,通过 provider_id + api_formats 关联
|
||||||
|
if _column_exists("provider_api_keys", "endpoint_id"):
|
||||||
|
# 确保外键已删除(前面可能已经删除)
|
||||||
|
try:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
for fk in inspector.get_foreign_keys("provider_api_keys"):
|
||||||
|
constrained = fk.get("constrained_columns") or []
|
||||||
|
if "endpoint_id" in constrained:
|
||||||
|
name = fk.get("name")
|
||||||
|
if name:
|
||||||
|
op.drop_constraint(name, "provider_api_keys", type_="foreignkey")
|
||||||
|
except Exception:
|
||||||
|
pass # 外键可能已经不存在
|
||||||
|
op.drop_column("provider_api_keys", "endpoint_id")
|
||||||
|
|
||||||
|
# ========== 11. provider_endpoints: 删除废弃的 max_concurrent 列 ==========
|
||||||
|
if _column_exists("provider_endpoints", "max_concurrent"):
|
||||||
|
op.drop_column("provider_endpoints", "max_concurrent")
|
||||||
|
|
||||||
|
# ========== 12. providers: 删除废弃的 RPM 相关字段 ==========
|
||||||
|
if _column_exists("providers", "rpm_limit"):
|
||||||
|
op.drop_column("providers", "rpm_limit")
|
||||||
|
if _column_exists("providers", "rpm_used"):
|
||||||
|
op.drop_column("providers", "rpm_used")
|
||||||
|
if _column_exists("providers", "rpm_reset_at"):
|
||||||
|
op.drop_column("providers", "rpm_reset_at")
|
||||||
|
|
||||||
|
alembic_logger.info("[OK] Consolidated migration completed successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""
|
||||||
|
Downgrade is complex due to data migrations.
|
||||||
|
For safety, this only removes new columns without restoring old structure.
|
||||||
|
Manual intervention may be required for full rollback.
|
||||||
|
"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# 12. 恢复 providers RPM 相关字段
|
||||||
|
if not _column_exists("providers", "rpm_limit"):
|
||||||
|
op.add_column("providers", sa.Column("rpm_limit", sa.Integer(), nullable=True))
|
||||||
|
if not _column_exists("providers", "rpm_used"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("rpm_used", sa.Integer(), server_default="0", nullable=True),
|
||||||
|
)
|
||||||
|
if not _column_exists("providers", "rpm_reset_at"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 11. 恢复 provider_endpoints.max_concurrent
|
||||||
|
if not _column_exists("provider_endpoints", "max_concurrent"):
|
||||||
|
op.add_column("provider_endpoints", sa.Column("max_concurrent", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# 10. 恢复 endpoint_id
|
||||||
|
if not _column_exists("provider_api_keys", "endpoint_id"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("endpoint_id", sa.String(36), nullable=True))
|
||||||
|
|
||||||
|
# 9. 删除 client_response_headers
|
||||||
|
if _column_exists("usage", "client_response_headers"):
|
||||||
|
op.drop_column("usage", "client_response_headers")
|
||||||
|
|
||||||
|
# 8. 恢复 provider_endpoints.rate_limit(如果需要)
|
||||||
|
if not _column_exists("provider_endpoints", "rate_limit"):
|
||||||
|
op.add_column("provider_endpoints", sa.Column("rate_limit", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# 7. 删除健康度 JSON 字段
|
||||||
|
bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_health_by_format"))
|
||||||
|
bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_circuit_breaker_by_format"))
|
||||||
|
if _column_exists("provider_api_keys", "health_by_format"):
|
||||||
|
op.drop_column("provider_api_keys", "health_by_format")
|
||||||
|
if _column_exists("provider_api_keys", "circuit_breaker_by_format"):
|
||||||
|
op.drop_column("provider_api_keys", "circuit_breaker_by_format")
|
||||||
|
|
||||||
|
# 6. rpm_limit -> max_concurrent(简化版:仅重命名)
|
||||||
|
if _column_exists("provider_api_keys", "rpm_limit"):
|
||||||
|
op.alter_column("provider_api_keys", "rpm_limit", new_column_name="max_concurrent")
|
||||||
|
if _column_exists("provider_api_keys", "learned_rpm_limit"):
|
||||||
|
op.alter_column("provider_api_keys", "learned_rpm_limit", new_column_name="learned_max_concurrent")
|
||||||
|
if _column_exists("provider_api_keys", "last_rpm_peak"):
|
||||||
|
op.alter_column("provider_api_keys", "last_rpm_peak", new_column_name="last_concurrent_peak")
|
||||||
|
|
||||||
|
# 恢复已删除的字段
|
||||||
|
if not _column_exists("provider_api_keys", "rate_limit"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("rate_limit", sa.Integer(), nullable=True))
|
||||||
|
if not _column_exists("provider_api_keys", "daily_limit"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("daily_limit", sa.Integer(), nullable=True))
|
||||||
|
if not _column_exists("provider_api_keys", "monthly_limit"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("monthly_limit", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# 5. name -> display_name (需要先删除索引)
|
||||||
|
if _index_exists("providers", "ix_providers_name"):
|
||||||
|
op.drop_index("ix_providers_name", table_name="providers")
|
||||||
|
op.alter_column("providers", "name", new_column_name="display_name")
|
||||||
|
# 重新添加原 name 字段
|
||||||
|
op.add_column("providers", sa.Column("name", sa.String(100), nullable=True))
|
||||||
|
op.execute("""
|
||||||
|
UPDATE providers
|
||||||
|
SET name = LOWER(REPLACE(REPLACE(display_name, ' ', '_'), '-', '_'))
|
||||||
|
""")
|
||||||
|
op.alter_column("providers", "name", nullable=False)
|
||||||
|
op.create_index("ix_providers_name", "providers", ["name"], unique=True)
|
||||||
|
|
||||||
|
# 4. 删除 providers 的 timeout, max_retries, proxy
|
||||||
|
if _column_exists("providers", "proxy"):
|
||||||
|
op.drop_column("providers", "proxy")
|
||||||
|
if _column_exists("providers", "max_retries"):
|
||||||
|
op.drop_column("providers", "max_retries")
|
||||||
|
if _column_exists("providers", "timeout"):
|
||||||
|
op.drop_column("providers", "timeout")
|
||||||
|
|
||||||
|
# 3. models: global_model_id 改回 NOT NULL
|
||||||
|
result = bind.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE global_model_id IS NULL"
|
||||||
|
))
|
||||||
|
orphan_model_count = result.scalar() or 0
|
||||||
|
if orphan_model_count > 0:
|
||||||
|
alembic_logger.warning(f"[WARN] 发现 {orphan_model_count} 个无 global_model_id 的独立模型,将被删除")
|
||||||
|
op.execute("DELETE FROM models WHERE global_model_id IS NULL")
|
||||||
|
alembic_logger.info(f"已删除 {orphan_model_count} 个独立模型")
|
||||||
|
op.alter_column("models", "global_model_id", nullable=False)
|
||||||
|
|
||||||
|
# 2. 删除 rate_multipliers
|
||||||
|
if _column_exists("provider_api_keys", "rate_multipliers"):
|
||||||
|
op.drop_column("provider_api_keys", "rate_multipliers")
|
||||||
|
|
||||||
|
# 1. 删除 provider_id 和 api_formats
|
||||||
|
if _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"):
|
||||||
|
op.drop_index("idx_provider_api_keys_provider_id", table_name="provider_api_keys")
|
||||||
|
if _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"):
|
||||||
|
op.drop_constraint("fk_provider_api_keys_provider", "provider_api_keys", type_="foreignkey")
|
||||||
|
if _column_exists("provider_api_keys", "api_formats"):
|
||||||
|
op.drop_column("provider_api_keys", "api_formats")
|
||||||
|
if _column_exists("provider_api_keys", "provider_id"):
|
||||||
|
op.drop_column("provider_api_keys", "provider_id")
|
||||||
|
|
||||||
|
# 恢复 endpoint_id 外键(简化版:仅创建外键,不强制 NOT NULL)
|
||||||
|
if _column_exists("provider_api_keys", "endpoint_id"):
|
||||||
|
if not _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"):
|
||||||
|
op.create_foreign_key(
|
||||||
|
"provider_api_keys_endpoint_id_fkey",
|
||||||
|
"provider_api_keys",
|
||||||
|
"provider_endpoints",
|
||||||
|
["endpoint_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
alembic_logger.info("[OK] Downgrade completed (simplified version)")
|
||||||
19
deploy.sh
19
deploy.sh
@@ -88,9 +88,28 @@ build_base() {
|
|||||||
save_deps_hash
|
save_deps_hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 生成版本文件
|
||||||
|
generate_version_file() {
|
||||||
|
# 从 git 获取版本号
|
||||||
|
local version
|
||||||
|
version=$(git describe --tags --always 2>/dev/null | sed 's/^v//')
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
version="unknown"
|
||||||
|
fi
|
||||||
|
echo ">>> Generating version file: $version"
|
||||||
|
cat > src/_version.py << EOF
|
||||||
|
# Auto-generated by deploy.sh - do not edit
|
||||||
|
__version__ = '$version'
|
||||||
|
__version_tuple__ = tuple(int(x) for x in '$version'.split('-')[0].split('.') if x.isdigit())
|
||||||
|
version = __version__
|
||||||
|
version_tuple = __version_tuple__
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
# 构建应用镜像
|
# 构建应用镜像
|
||||||
build_app() {
|
build_app() {
|
||||||
echo ">>> Building app image (code only)..."
|
echo ">>> Building app image (code only)..."
|
||||||
|
generate_version_file
|
||||||
docker build -f Dockerfile.app.local -t aether-app:latest .
|
docker build -f Dockerfile.app.local -t aether-app:latest .
|
||||||
save_code_hash
|
save_code_hash
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Aether 部署配置 - 本地构建
|
# Aether 部署配置 - 本地构建
|
||||||
# 使用方法:
|
# 使用方法:
|
||||||
# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest .
|
# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest .
|
||||||
# 启动服务: docker-compose -f docker-compose.build.yml up -d --build
|
# 启动服务: docker compose -f docker-compose.build.yml up -d --build
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -32,7 +32,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -44,20 +44,15 @@ services:
|
|||||||
dockerfile: Dockerfile.app.local
|
dockerfile: Dockerfile.app.local
|
||||||
image: aether-app:latest
|
image: aether-app:latest
|
||||||
container_name: aether-app
|
container_name: aether-app
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
# 需要组合的变量
|
||||||
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
||||||
PORT: 8084
|
# Supervisor 需要的变量
|
||||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
|
||||||
JWT_ALGORITHM: HS256
|
|
||||||
JWT_EXPIRATION_DELTA: 86400
|
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
|
||||||
API_KEY_PREFIX: ${API_KEY_PREFIX:-sk}
|
|
||||||
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
|
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
|
||||||
|
# 容器级别设置
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
PYTHONIOENCODING: utf-8
|
PYTHONIOENCODING: utf-8
|
||||||
LANG: C.UTF-8
|
LANG: C.UTF-8
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Aether 部署配置 - 使用预构建镜像
|
# Aether 部署配置 - 使用预构建镜像
|
||||||
# 使用方法: docker-compose up -d
|
# 使用方法: docker compose up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -35,20 +35,15 @@ services:
|
|||||||
app:
|
app:
|
||||||
image: ghcr.io/fawney19/aether:latest
|
image: ghcr.io/fawney19/aether:latest
|
||||||
container_name: aether-app
|
container_name: aether-app
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
# 需要组合的变量
|
||||||
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
||||||
PORT: 8084
|
# Supervisor 需要的变量
|
||||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
|
||||||
JWT_ALGORITHM: HS256
|
|
||||||
JWT_EXPIRATION_DELTA: 86400
|
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
|
||||||
API_KEY_PREFIX: ${API_KEY_PREFIX:-sk}
|
|
||||||
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
|
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
|
||||||
|
# 容器级别设置
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
PYTHONIOENCODING: utf-8
|
PYTHONIOENCODING: utf-8
|
||||||
LANG: C.UTF-8
|
LANG: C.UTF-8
|
||||||
|
|||||||
8
entrypoint.sh
Normal file
8
entrypoint.sh
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
echo "Starting application..."
|
||||||
|
exec "$@"
|
||||||
@@ -13,6 +13,7 @@ export interface UsersExportData {
|
|||||||
version: string
|
version: string
|
||||||
exported_at: string
|
exported_at: string
|
||||||
users: UserExport[]
|
users: UserExport[]
|
||||||
|
standalone_keys?: StandaloneKeyExport[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserExport {
|
export interface UserExport {
|
||||||
@@ -21,7 +22,7 @@ export interface UserExport {
|
|||||||
password_hash: string
|
password_hash: string
|
||||||
role: string
|
role: string
|
||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_endpoints?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
model_capability_settings?: any
|
model_capability_settings?: any
|
||||||
quota_usd?: number | null
|
quota_usd?: number | null
|
||||||
@@ -39,18 +40,21 @@ export interface UserApiKeyExport {
|
|||||||
balance_used_usd?: number
|
balance_used_usd?: number
|
||||||
current_balance_usd?: number | null
|
current_balance_usd?: number | null
|
||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_endpoints?: string[] | null
|
|
||||||
allowed_api_formats?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
rate_limit?: number
|
rate_limit?: number | null // null = 无限制
|
||||||
concurrent_limit?: number | null
|
concurrent_limit?: number | null
|
||||||
force_capabilities?: any
|
force_capabilities?: any
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
expires_at?: string | null
|
||||||
auto_delete_on_expiry?: boolean
|
auto_delete_on_expiry?: boolean
|
||||||
total_requests?: number
|
total_requests?: number
|
||||||
total_cost_usd?: number
|
total_cost_usd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立余额 Key 导出结构(与 UserApiKeyExport 相同,但不包含 is_standalone)
|
||||||
|
export type StandaloneKeyExport = Omit<UserApiKeyExport, 'is_standalone'>
|
||||||
|
|
||||||
export interface GlobalModelExport {
|
export interface GlobalModelExport {
|
||||||
name: string
|
name: string
|
||||||
display_name: string
|
display_name: string
|
||||||
@@ -63,7 +67,6 @@ export interface GlobalModelExport {
|
|||||||
|
|
||||||
export interface ProviderExport {
|
export interface ProviderExport {
|
||||||
name: string
|
name: string
|
||||||
display_name: string
|
|
||||||
description?: string | null
|
description?: string | null
|
||||||
website?: string | null
|
website?: string | null
|
||||||
billing_type?: string | null
|
billing_type?: string | null
|
||||||
@@ -72,10 +75,13 @@ export interface ProviderExport {
|
|||||||
rpm_limit?: number | null
|
rpm_limit?: number | null
|
||||||
provider_priority?: number
|
provider_priority?: number
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
rate_limit?: number | null
|
|
||||||
concurrent_limit?: number | null
|
concurrent_limit?: number | null
|
||||||
|
timeout?: number | null
|
||||||
|
max_retries?: number | null
|
||||||
|
proxy?: any
|
||||||
config?: any
|
config?: any
|
||||||
endpoints: EndpointExport[]
|
endpoints: EndpointExport[]
|
||||||
|
api_keys: ProviderKeyExport[]
|
||||||
models: ModelExport[]
|
models: ModelExport[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,27 +91,26 @@ export interface EndpointExport {
|
|||||||
headers?: any
|
headers?: any
|
||||||
timeout?: number
|
timeout?: number
|
||||||
max_retries?: number
|
max_retries?: number
|
||||||
max_concurrent?: number | null
|
|
||||||
rate_limit?: number | null
|
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
custom_path?: string | null
|
custom_path?: string | null
|
||||||
config?: any
|
config?: any
|
||||||
keys: KeyExport[]
|
proxy?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyExport {
|
export interface ProviderKeyExport {
|
||||||
api_key: string
|
api_key: string
|
||||||
name?: string | null
|
name?: string | null
|
||||||
note?: string | null
|
note?: string | null
|
||||||
|
api_formats: string[]
|
||||||
rate_multiplier?: number
|
rate_multiplier?: number
|
||||||
|
rate_multipliers?: Record<string, number> | null
|
||||||
internal_priority?: number
|
internal_priority?: number
|
||||||
global_priority?: number | null
|
global_priority?: number | null
|
||||||
max_concurrent?: number | null
|
rpm_limit?: number | null
|
||||||
rate_limit?: number | null
|
allowed_models?: any
|
||||||
daily_limit?: number | null
|
|
||||||
monthly_limit?: number | null
|
|
||||||
allowed_models?: string[] | null
|
|
||||||
capabilities?: any
|
capabilities?: any
|
||||||
|
cache_ttl_minutes?: number
|
||||||
|
max_probe_interval_minutes?: number
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +160,53 @@ export interface EmailTemplateResetResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查更新响应
|
||||||
|
export interface CheckUpdateResponse {
|
||||||
|
current_version: string
|
||||||
|
latest_version: string | null
|
||||||
|
has_update: boolean
|
||||||
|
release_url: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP 配置响应
|
||||||
|
export interface LdapConfigResponse {
|
||||||
|
server_url: string | null
|
||||||
|
bind_dn: string | null
|
||||||
|
base_dn: string | null
|
||||||
|
has_bind_password: boolean
|
||||||
|
user_search_filter: string
|
||||||
|
username_attr: string
|
||||||
|
email_attr: string
|
||||||
|
display_name_attr: string
|
||||||
|
is_enabled: boolean
|
||||||
|
is_exclusive: boolean
|
||||||
|
use_starttls: boolean
|
||||||
|
connect_timeout: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP 配置更新请求
|
||||||
|
export interface LdapConfigUpdateRequest {
|
||||||
|
server_url: string
|
||||||
|
bind_dn: string
|
||||||
|
bind_password?: string
|
||||||
|
base_dn: string
|
||||||
|
user_search_filter?: string
|
||||||
|
username_attr?: string
|
||||||
|
email_attr?: string
|
||||||
|
display_name_attr?: string
|
||||||
|
is_enabled?: boolean
|
||||||
|
is_exclusive?: boolean
|
||||||
|
use_starttls?: boolean
|
||||||
|
connect_timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP 连接测试响应
|
||||||
|
export interface LdapTestResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
// Provider 模型查询响应
|
// Provider 模型查询响应
|
||||||
export interface ProviderModelsQueryResponse {
|
export interface ProviderModelsQueryResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -189,6 +241,7 @@ export interface UsersImportResponse {
|
|||||||
stats: {
|
stats: {
|
||||||
users: { created: number; updated: number; skipped: number }
|
users: { created: number; updated: number; skipped: number }
|
||||||
api_keys: { created: number; skipped: number }
|
api_keys: { created: number; skipped: number }
|
||||||
|
standalone_keys?: { created: number; skipped: number }
|
||||||
errors: string[]
|
errors: string[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +273,7 @@ export interface AdminApiKey {
|
|||||||
total_requests?: number
|
total_requests?: number
|
||||||
total_tokens?: number
|
total_tokens?: number
|
||||||
total_cost_usd?: number
|
total_cost_usd?: number
|
||||||
rate_limit?: number
|
rate_limit?: number | null // null = 无限制
|
||||||
allowed_providers?: string[] | null // 允许的提供商列表
|
allowed_providers?: string[] | null // 允许的提供商列表
|
||||||
allowed_api_formats?: string[] | null // 允许的 API 格式列表
|
allowed_api_formats?: string[] | null // 允许的 API 格式列表
|
||||||
allowed_models?: string[] | null // 允许的模型列表
|
allowed_models?: string[] | null // 允许的模型列表
|
||||||
@@ -236,8 +289,8 @@ export interface CreateStandaloneApiKeyRequest {
|
|||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_api_formats?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
rate_limit?: number
|
rate_limit?: number | null // null = 无限制
|
||||||
expire_days?: number | null // null = 永不过期
|
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期
|
||||||
initial_balance_usd: number // 初始余额,必须设置
|
initial_balance_usd: number // 初始余额,必须设置
|
||||||
auto_delete_on_expiry?: boolean // 过期后是否自动删除
|
auto_delete_on_expiry?: boolean // 过期后是否自动删除
|
||||||
}
|
}
|
||||||
@@ -473,5 +526,43 @@ export const adminApi = {
|
|||||||
`/api/admin/system/email/templates/${templateType}/reset`
|
`/api/admin/system/email/templates/${templateType}/reset`
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取系统版本信息
|
||||||
|
async getSystemVersion(): Promise<{ version: string }> {
|
||||||
|
const response = await apiClient.get<{ version: string }>(
|
||||||
|
'/api/admin/system/version'
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查系统更新
|
||||||
|
async checkUpdate(): Promise<CheckUpdateResponse> {
|
||||||
|
const response = await apiClient.get<CheckUpdateResponse>(
|
||||||
|
'/api/admin/system/check-update'
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// LDAP 配置相关
|
||||||
|
// 获取 LDAP 配置
|
||||||
|
async getLdapConfig(): Promise<LdapConfigResponse> {
|
||||||
|
const response = await apiClient.get<LdapConfigResponse>('/api/admin/ldap/config')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新 LDAP 配置
|
||||||
|
async updateLdapConfig(config: LdapConfigUpdateRequest): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.put<{ message: string }>(
|
||||||
|
'/api/admin/ldap/config',
|
||||||
|
config
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 测试 LDAP 连接
|
||||||
|
async testLdapConnection(config: LdapConfigUpdateRequest): Promise<LdapTestResponse> {
|
||||||
|
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { log } from '@/utils/logger'
|
|||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
|
auth_type?: 'local' | 'ldap'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
@@ -81,6 +82,12 @@ export interface RegistrationSettingsResponse {
|
|||||||
require_email_verification: boolean
|
require_email_verification: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthSettingsResponse {
|
||||||
|
local_enabled: boolean
|
||||||
|
ldap_enabled: boolean
|
||||||
|
ldap_exclusive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
username: string
|
username: string
|
||||||
@@ -91,7 +98,7 @@ export interface User {
|
|||||||
used_usd?: number
|
used_usd?: number
|
||||||
total_usd?: number
|
total_usd?: number
|
||||||
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
|
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
|
||||||
allowed_endpoints?: string[] | null // 允许使用的端点 ID 列表
|
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
|
||||||
allowed_models?: string[] | null // 允许使用的模型名称列表
|
allowed_models?: string[] | null // 允许使用的模型名称列表
|
||||||
created_at: string
|
created_at: string
|
||||||
last_login_at?: string
|
last_login_at?: string
|
||||||
@@ -173,5 +180,10 @@ export const authApi = {
|
|||||||
{ email }
|
{ email }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAuthSettings(): Promise<AuthSettingsResponse> {
|
||||||
|
const response = await apiClient.get<AuthSettingsResponse>('/api/auth/settings')
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export interface RequestDetail {
|
|||||||
request_body?: Record<string, any>
|
request_body?: Record<string, any>
|
||||||
provider_request_headers?: Record<string, any>
|
provider_request_headers?: Record<string, any>
|
||||||
response_headers?: Record<string, any>
|
response_headers?: Record<string, any>
|
||||||
|
client_response_headers?: Record<string, any>
|
||||||
response_body?: Record<string, any>
|
response_body?: Record<string, any>
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, any>
|
||||||
// 阶梯计费信息
|
// 阶梯计费信息
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export async function toggleAdaptiveMode(
|
|||||||
message: string
|
message: string
|
||||||
key_id: string
|
key_id: string
|
||||||
is_adaptive: boolean
|
is_adaptive: boolean
|
||||||
max_concurrent: number | null
|
rpm_limit: number | null
|
||||||
effective_limit: number | null
|
effective_limit: number | null
|
||||||
}> {
|
}> {
|
||||||
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/mode`, data)
|
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/mode`, data)
|
||||||
@@ -22,16 +22,16 @@ export async function toggleAdaptiveMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置 Key 的固定并发限制
|
* 设置 Key 的固定 RPM 限制
|
||||||
*/
|
*/
|
||||||
export async function setConcurrentLimit(
|
export async function setRpmLimit(
|
||||||
keyId: string,
|
keyId: string,
|
||||||
limit: number
|
limit: number
|
||||||
): Promise<{
|
): Promise<{
|
||||||
message: string
|
message: string
|
||||||
key_id: string
|
key_id: string
|
||||||
is_adaptive: boolean
|
is_adaptive: boolean
|
||||||
max_concurrent: number
|
rpm_limit: number
|
||||||
previous_mode: string
|
previous_mode: string
|
||||||
}> {
|
}> {
|
||||||
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/limit`, null, {
|
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/limit`, null, {
|
||||||
|
|||||||
@@ -27,15 +27,9 @@ export async function createEndpoint(
|
|||||||
api_format: string
|
api_format: string
|
||||||
base_url: string
|
base_url: string
|
||||||
custom_path?: string
|
custom_path?: string
|
||||||
auth_type?: string
|
|
||||||
auth_header?: string
|
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
timeout?: number
|
timeout?: number
|
||||||
max_retries?: number
|
max_retries?: number
|
||||||
priority?: number
|
|
||||||
weight?: number
|
|
||||||
max_concurrent?: number
|
|
||||||
rate_limit?: number
|
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
config?: Record<string, any>
|
config?: Record<string, any>
|
||||||
proxy?: ProxyConfig | null
|
proxy?: ProxyConfig | null
|
||||||
@@ -52,16 +46,10 @@ export async function updateEndpoint(
|
|||||||
endpointId: string,
|
endpointId: string,
|
||||||
data: Partial<{
|
data: Partial<{
|
||||||
base_url: string
|
base_url: string
|
||||||
custom_path: string
|
custom_path: string | null
|
||||||
auth_type: string
|
|
||||||
auth_header: string
|
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
timeout: number
|
timeout: number
|
||||||
max_retries: number
|
max_retries: number
|
||||||
priority: number
|
|
||||||
weight: number
|
|
||||||
max_concurrent: number
|
|
||||||
rate_limit: number
|
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
config: Record<string, any>
|
config: Record<string, any>
|
||||||
proxy: ProxyConfig | null
|
proxy: ProxyConfig | null
|
||||||
@@ -74,7 +62,7 @@ export async function updateEndpoint(
|
|||||||
/**
|
/**
|
||||||
* 删除 Endpoint
|
* 删除 Endpoint
|
||||||
*/
|
*/
|
||||||
export async function deleteEndpoint(endpointId: string): Promise<{ message: string; deleted_keys_count: number }> {
|
export async function deleteEndpoint(endpointId: string): Promise<{ message: string; affected_keys_count: number }> {
|
||||||
const response = await client.delete(`/api/admin/endpoints/${endpointId}`)
|
const response = await client.delete(`/api/admin/endpoints/${endpointId}`)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,16 +32,21 @@ export async function getKeyHealth(keyId: string): Promise<HealthStatus> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 恢复Key健康状态(一键恢复:重置健康度 + 关闭熔断器 + 取消自动禁用)
|
* 恢复Key健康状态(一键恢复:重置健康度 + 关闭熔断器 + 取消自动禁用)
|
||||||
|
* @param keyId Key ID
|
||||||
|
* @param apiFormat 可选,指定 API 格式(如 CLAUDE、OPENAI),不指定则恢复所有格式
|
||||||
*/
|
*/
|
||||||
export async function recoverKeyHealth(keyId: string): Promise<{
|
export async function recoverKeyHealth(keyId: string, apiFormat?: string): Promise<{
|
||||||
message: string
|
message: string
|
||||||
details: {
|
details: {
|
||||||
|
api_format?: string
|
||||||
health_score: number
|
health_score: number
|
||||||
circuit_breaker_open: boolean
|
circuit_breaker_open: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
}
|
}
|
||||||
}> {
|
}> {
|
||||||
const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`)
|
const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`, null, {
|
||||||
|
params: apiFormat ? { api_format: apiFormat } : undefined
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import type { EndpointAPIKey } from './types'
|
import type { EndpointAPIKey, AllowedModels } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 能力定义类型
|
* 能力定义类型
|
||||||
@@ -49,67 +49,6 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 Endpoint 的所有 Keys
|
|
||||||
*/
|
|
||||||
export async function getEndpointKeys(endpointId: string): Promise<EndpointAPIKey[]> {
|
|
||||||
const response = await client.get(`/api/admin/endpoints/${endpointId}/keys`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 为 Endpoint 添加 Key
|
|
||||||
*/
|
|
||||||
export async function addEndpointKey(
|
|
||||||
endpointId: string,
|
|
||||||
data: {
|
|
||||||
endpoint_id: string
|
|
||||||
api_key: string
|
|
||||||
name: string // 密钥名称(必填)
|
|
||||||
rate_multiplier?: number // 成本倍率(默认 1.0)
|
|
||||||
internal_priority?: number // Endpoint 内部优先级(数字越小越优先)
|
|
||||||
max_concurrent?: number // 最大并发数(留空=自适应模式)
|
|
||||||
rate_limit?: number
|
|
||||||
daily_limit?: number
|
|
||||||
monthly_limit?: number
|
|
||||||
cache_ttl_minutes?: number // 缓存 TTL(分钟),0=禁用
|
|
||||||
max_probe_interval_minutes?: number // 熔断探测间隔(分钟)
|
|
||||||
allowed_models?: string[] // 允许使用的模型列表
|
|
||||||
capabilities?: Record<string, boolean> // 能力标签配置
|
|
||||||
note?: string // 备注说明(可选)
|
|
||||||
}
|
|
||||||
): Promise<EndpointAPIKey> {
|
|
||||||
const response = await client.post(`/api/admin/endpoints/${endpointId}/keys`, data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 Endpoint Key
|
|
||||||
*/
|
|
||||||
export async function updateEndpointKey(
|
|
||||||
keyId: string,
|
|
||||||
data: Partial<{
|
|
||||||
api_key: string
|
|
||||||
name: string // 密钥名称
|
|
||||||
rate_multiplier: number // 成本倍率
|
|
||||||
internal_priority: number // Endpoint 内部优先级(提供商优先模式,数字越小越优先)
|
|
||||||
global_priority: number // 全局 Key 优先级(全局 Key 优先模式,数字越小越优先)
|
|
||||||
max_concurrent: number // 最大并发数(留空=自适应模式)
|
|
||||||
rate_limit: number
|
|
||||||
daily_limit: number
|
|
||||||
monthly_limit: number
|
|
||||||
cache_ttl_minutes: number // 缓存 TTL(分钟),0=禁用
|
|
||||||
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
|
|
||||||
allowed_models: string[] | null // 允许使用的模型列表,null 表示允许所有
|
|
||||||
capabilities: Record<string, boolean> | null // 能力标签配置
|
|
||||||
is_active: boolean
|
|
||||||
note: string // 备注说明
|
|
||||||
}>
|
|
||||||
): Promise<EndpointAPIKey> {
|
|
||||||
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取完整的 API Key(用于查看和复制)
|
* 获取完整的 API Key(用于查看和复制)
|
||||||
*/
|
*/
|
||||||
@@ -119,22 +58,71 @@ export async function revealEndpointKey(keyId: string): Promise<{ api_key: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除 Endpoint Key
|
* 删除 Key
|
||||||
*/
|
*/
|
||||||
export async function deleteEndpointKey(keyId: string): Promise<{ message: string }> {
|
export async function deleteEndpointKey(keyId: string): Promise<{ message: string }> {
|
||||||
const response = await client.delete(`/api/admin/endpoints/keys/${keyId}`)
|
const response = await client.delete(`/api/admin/endpoints/keys/${keyId}`)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========== Provider 级别的 Keys API ==========
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量更新 Endpoint Keys 的优先级(用于拖动排序)
|
* 获取 Provider 的所有 Keys
|
||||||
*/
|
*/
|
||||||
export async function batchUpdateKeyPriority(
|
export async function getProviderKeys(providerId: string): Promise<EndpointAPIKey[]> {
|
||||||
endpointId: string,
|
const response = await client.get(`/api/admin/endpoints/providers/${providerId}/keys`)
|
||||||
priorities: Array<{ key_id: string; internal_priority: number }>
|
return response.data
|
||||||
): Promise<{ message: string; updated_count: number }> {
|
}
|
||||||
const response = await client.put(`/api/admin/endpoints/${endpointId}/keys/batch-priority`, {
|
|
||||||
priorities
|
/**
|
||||||
})
|
* 为 Provider 添加 Key
|
||||||
|
*/
|
||||||
|
export async function addProviderKey(
|
||||||
|
providerId: string,
|
||||||
|
data: {
|
||||||
|
api_formats: string[] // 支持的 API 格式列表(必填)
|
||||||
|
api_key: string
|
||||||
|
name: string
|
||||||
|
rate_multiplier?: number // 默认成本倍率
|
||||||
|
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
|
||||||
|
internal_priority?: number
|
||||||
|
rpm_limit?: number | null // RPM 限制(留空=自适应模式)
|
||||||
|
cache_ttl_minutes?: number
|
||||||
|
max_probe_interval_minutes?: number
|
||||||
|
allowed_models?: AllowedModels
|
||||||
|
capabilities?: Record<string, boolean>
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
): Promise<EndpointAPIKey> {
|
||||||
|
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 Key
|
||||||
|
*/
|
||||||
|
export async function updateProviderKey(
|
||||||
|
keyId: string,
|
||||||
|
data: Partial<{
|
||||||
|
api_formats: string[] // 支持的 API 格式列表
|
||||||
|
api_key: string
|
||||||
|
name: string
|
||||||
|
rate_multiplier: number // 默认成本倍率
|
||||||
|
rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率
|
||||||
|
internal_priority: number
|
||||||
|
global_priority: number | null
|
||||||
|
rpm_limit: number | null // RPM 限制(留空=自适应模式)
|
||||||
|
cache_ttl_minutes: number
|
||||||
|
max_probe_interval_minutes: number
|
||||||
|
allowed_models: AllowedModels
|
||||||
|
capabilities: Record<string, boolean> | null
|
||||||
|
is_active: boolean
|
||||||
|
note: string
|
||||||
|
}>
|
||||||
|
): Promise<EndpointAPIKey> {
|
||||||
|
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,14 +147,26 @@ export async function queryProviderUpstreamModels(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从上游提供商导入模型
|
* 从上游提供商导入模型
|
||||||
|
* @param providerId 提供商 ID
|
||||||
|
* @param modelIds 模型 ID 列表
|
||||||
|
* @param options 可选配置
|
||||||
|
* @param options.tiered_pricing 阶梯计费配置
|
||||||
|
* @param options.price_per_request 按次计费价格
|
||||||
*/
|
*/
|
||||||
export async function importModelsFromUpstream(
|
export async function importModelsFromUpstream(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelIds: string[]
|
modelIds: string[],
|
||||||
|
options?: {
|
||||||
|
tiered_pricing?: object
|
||||||
|
price_per_request?: number
|
||||||
|
}
|
||||||
): Promise<ImportFromUpstreamResponse> {
|
): Promise<ImportFromUpstreamResponse> {
|
||||||
const response = await client.post(
|
const response = await client.post(
|
||||||
`/api/admin/providers/${providerId}/import-from-upstream`,
|
`/api/admin/providers/${providerId}/import-from-upstream`,
|
||||||
{ model_ids: modelIds }
|
{
|
||||||
|
model_ids: modelIds,
|
||||||
|
...options
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import type { ProviderWithEndpointsSummary } from './types'
|
import type { ProviderWithEndpointsSummary, ProxyConfig } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Providers 摘要(包含 Endpoints 统计)
|
* 获取 Providers 摘要(包含 Endpoints 统计)
|
||||||
@@ -23,7 +23,7 @@ export async function getProvider(providerId: string): Promise<ProviderWithEndpo
|
|||||||
export async function updateProvider(
|
export async function updateProvider(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
data: Partial<{
|
data: Partial<{
|
||||||
display_name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
website: string
|
website: string
|
||||||
provider_priority: number
|
provider_priority: number
|
||||||
@@ -33,6 +33,10 @@ export async function updateProvider(
|
|||||||
quota_last_reset_at: string // 周期开始时间
|
quota_last_reset_at: string // 周期开始时间
|
||||||
quota_expires_at: string
|
quota_expires_at: string
|
||||||
rpm_limit: number | null
|
rpm_limit: number | null
|
||||||
|
// 请求配置(从 Endpoint 迁移)
|
||||||
|
timeout: number
|
||||||
|
max_retries: number
|
||||||
|
proxy: ProxyConfig | null
|
||||||
cache_ttl_minutes: number // 0表示不支持缓存,>0表示支持缓存并设置TTL(分钟)
|
cache_ttl_minutes: number // 0表示不支持缓存,>0表示支持缓存并设置TTL(分钟)
|
||||||
max_probe_interval_minutes: number
|
max_probe_interval_minutes: number
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
@@ -83,7 +87,6 @@ export interface TestModelResponse {
|
|||||||
provider?: {
|
provider?: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
display_name: string
|
|
||||||
}
|
}
|
||||||
model?: string
|
model?: string
|
||||||
}
|
}
|
||||||
@@ -92,4 +95,3 @@ export async function testModel(data: TestModelRequest): Promise<TestModelRespon
|
|||||||
const response = await client.post('/api/admin/provider-query/test-model', data)
|
const response = await client.post('/api/admin/provider-query/test-model', data)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,38 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
|||||||
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API 格式缩写映射(用于空间紧凑的显示场景)
|
||||||
|
export const API_FORMAT_SHORT: Record<string, string> = {
|
||||||
|
[API_FORMATS.OPENAI]: 'O',
|
||||||
|
[API_FORMATS.OPENAI_CLI]: 'OC',
|
||||||
|
[API_FORMATS.CLAUDE]: 'C',
|
||||||
|
[API_FORMATS.CLAUDE_CLI]: 'CC',
|
||||||
|
[API_FORMATS.GEMINI]: 'G',
|
||||||
|
[API_FORMATS.GEMINI_CLI]: 'GC',
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 格式排序顺序(统一的显示顺序)
|
||||||
|
export const API_FORMAT_ORDER: string[] = [
|
||||||
|
API_FORMATS.OPENAI,
|
||||||
|
API_FORMATS.OPENAI_CLI,
|
||||||
|
API_FORMATS.CLAUDE,
|
||||||
|
API_FORMATS.CLAUDE_CLI,
|
||||||
|
API_FORMATS.GEMINI,
|
||||||
|
API_FORMATS.GEMINI_CLI,
|
||||||
|
]
|
||||||
|
|
||||||
|
// 工具函数:按标准顺序排序 API 格式数组
|
||||||
|
export function sortApiFormats(formats: string[]): string[] {
|
||||||
|
return [...formats].sort((a, b) => {
|
||||||
|
const aIdx = API_FORMAT_ORDER.indexOf(a)
|
||||||
|
const bIdx = API_FORMAT_ORDER.indexOf(b)
|
||||||
|
if (aIdx === -1 && bIdx === -1) return 0
|
||||||
|
if (aIdx === -1) return 1
|
||||||
|
if (bIdx === -1) return -1
|
||||||
|
return aIdx - bIdx
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代理配置类型
|
* 代理配置类型
|
||||||
*/
|
*/
|
||||||
@@ -37,18 +69,9 @@ export interface ProviderEndpoint {
|
|||||||
api_format: string
|
api_format: string
|
||||||
base_url: string
|
base_url: string
|
||||||
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
|
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
|
||||||
auth_type: string
|
|
||||||
auth_header?: string
|
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
timeout: number
|
timeout: number
|
||||||
max_retries: number
|
max_retries: number
|
||||||
priority: number
|
|
||||||
weight: number
|
|
||||||
max_concurrent?: number
|
|
||||||
rate_limit?: number
|
|
||||||
health_score: number
|
|
||||||
consecutive_failures: number
|
|
||||||
last_failure_at?: string
|
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
config?: Record<string, any>
|
config?: Record<string, any>
|
||||||
proxy?: ProxyConfig | null
|
proxy?: ProxyConfig | null
|
||||||
@@ -58,25 +81,55 @@ export interface ProviderEndpoint {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型权限配置类型(支持简单列表和按格式字典两种模式)
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* 1. 不限制(允许所有模型): null
|
||||||
|
* 2. 简单列表模式(所有 API 格式共享同一个白名单): ["gpt-4", "claude-3-opus"]
|
||||||
|
* 3. 按格式字典模式(不同 API 格式使用不同的白名单):
|
||||||
|
* { "OPENAI": ["gpt-4"], "CLAUDE": ["claude-3-opus"] }
|
||||||
|
*/
|
||||||
|
export type AllowedModels = string[] | Record<string, string[]> | null
|
||||||
|
|
||||||
|
// AllowedModels 类型守卫函数
|
||||||
|
export function isAllowedModelsList(value: AllowedModels): value is string[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedModelsDict(value: AllowedModels): value is Record<string, string[]> {
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 验证所有值都是字符串数组
|
||||||
|
return Object.values(value).every(
|
||||||
|
(v) => Array.isArray(v) && v.every((item) => typeof item === 'string')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export interface EndpointAPIKey {
|
export interface EndpointAPIKey {
|
||||||
id: string
|
id: string
|
||||||
endpoint_id: string
|
provider_id: string
|
||||||
|
api_formats: string[] // 支持的 API 格式列表
|
||||||
api_key_masked: string
|
api_key_masked: string
|
||||||
api_key_plain?: string | null
|
api_key_plain?: string | null
|
||||||
name: string // 密钥名称(必填,用于识别)
|
name: string // 密钥名称(必填,用于识别)
|
||||||
rate_multiplier: number // 成本倍率(真实成本 = 表面成本 × 倍率)
|
rate_multiplier: number // 默认成本倍率(真实成本 = 表面成本 × 倍率)
|
||||||
internal_priority: number // Endpoint 内部优先级
|
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率,如 {"CLAUDE": 1.0, "OPENAI": 0.8}
|
||||||
|
internal_priority: number // Key 内部优先级
|
||||||
global_priority?: number | null // 全局 Key 优先级
|
global_priority?: number | null // 全局 Key 优先级
|
||||||
max_concurrent?: number
|
rpm_limit?: number | null // RPM 速率限制 (1-10000),null 表示自适应模式
|
||||||
rate_limit?: number
|
allowed_models?: AllowedModels // 允许使用的模型列表(null=不限制,列表=简单白名单,字典=按格式区分)
|
||||||
daily_limit?: number
|
|
||||||
monthly_limit?: number
|
|
||||||
allowed_models?: string[] | null // 允许使用的模型列表(null = 支持所有模型)
|
|
||||||
capabilities?: Record<string, boolean> | null // 能力标签配置(如 cache_1h, context_1m)
|
capabilities?: Record<string, boolean> | null // 能力标签配置(如 cache_1h, context_1m)
|
||||||
// 缓存与熔断配置
|
// 缓存与熔断配置
|
||||||
cache_ttl_minutes: number // 缓存 TTL(分钟),0=禁用
|
cache_ttl_minutes: number // 缓存 TTL(分钟),0=禁用
|
||||||
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
|
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
|
||||||
|
// 按格式的健康度数据
|
||||||
|
health_by_format?: Record<string, FormatHealthData>
|
||||||
|
circuit_breaker_by_format?: Record<string, FormatCircuitBreakerData>
|
||||||
|
// 聚合字段(从 health_by_format 计算,用于列表显示)
|
||||||
health_score: number
|
health_score: number
|
||||||
|
circuit_breaker_open?: boolean
|
||||||
consecutive_failures: number
|
consecutive_failures: number
|
||||||
last_failure_at?: string
|
last_failure_at?: string
|
||||||
request_count: number
|
request_count: number
|
||||||
@@ -89,10 +142,10 @@ export interface EndpointAPIKey {
|
|||||||
last_used_at?: string
|
last_used_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
// 自适应并发字段
|
// 自适应 RPM 字段
|
||||||
is_adaptive?: boolean // 是否为自适应模式(max_concurrent=NULL)
|
is_adaptive?: boolean // 是否为自适应模式(rpm_limit=NULL)
|
||||||
effective_limit?: number // 当前有效限制(自适应使用学习值,固定使用配置值)
|
effective_limit?: number // 当前有效 RPM 限制(自适应使用学习值,固定使用配置值)
|
||||||
learned_max_concurrent?: number
|
learned_rpm_limit?: number // 学习到的 RPM 限制
|
||||||
// 滑动窗口利用率采样
|
// 滑动窗口利用率采样
|
||||||
utilization_samples?: Array<{ ts: number; util: number }> // 利用率采样窗口
|
utilization_samples?: Array<{ ts: number; util: number }> // 利用率采样窗口
|
||||||
last_probe_increase_at?: string // 上次探测性扩容时间
|
last_probe_increase_at?: string // 上次探测性扩容时间
|
||||||
@@ -100,8 +153,7 @@ export interface EndpointAPIKey {
|
|||||||
rpm_429_count?: number
|
rpm_429_count?: number
|
||||||
last_429_at?: string
|
last_429_at?: string
|
||||||
last_429_type?: string
|
last_429_type?: string
|
||||||
// 熔断器字段(滑动窗口 + 半开模式)
|
// 单格式场景的熔断器字段
|
||||||
circuit_breaker_open?: boolean
|
|
||||||
circuit_breaker_open_at?: string
|
circuit_breaker_open_at?: string
|
||||||
next_probe_at?: string
|
next_probe_at?: string
|
||||||
half_open_until?: string
|
half_open_until?: string
|
||||||
@@ -110,17 +162,36 @@ export interface EndpointAPIKey {
|
|||||||
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
|
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按格式的健康度数据
|
||||||
|
export interface FormatHealthData {
|
||||||
|
health_score: number
|
||||||
|
error_rate: number
|
||||||
|
window_size: number
|
||||||
|
consecutive_failures: number
|
||||||
|
last_failure_at?: string | null
|
||||||
|
circuit_breaker: FormatCircuitBreakerData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按格式的熔断器数据
|
||||||
|
export interface FormatCircuitBreakerData {
|
||||||
|
open: boolean
|
||||||
|
open_at?: string | null
|
||||||
|
next_probe_at?: string | null
|
||||||
|
half_open_until?: string | null
|
||||||
|
half_open_successes: number
|
||||||
|
half_open_failures: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface EndpointAPIKeyUpdate {
|
export interface EndpointAPIKeyUpdate {
|
||||||
|
api_formats?: string[] // 支持的 API 格式列表
|
||||||
name?: string
|
name?: string
|
||||||
api_key?: string // 仅在需要更新时提供
|
api_key?: string // 仅在需要更新时提供
|
||||||
rate_multiplier?: number
|
rate_multiplier?: number // 默认成本倍率
|
||||||
|
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
|
||||||
internal_priority?: number
|
internal_priority?: number
|
||||||
global_priority?: number | null
|
global_priority?: number | null
|
||||||
max_concurrent?: number | null // null 表示切换为自适应模式
|
rpm_limit?: number | null // RPM 速率限制 (1-10000),null 表示切换为自适应模式
|
||||||
rate_limit?: number
|
allowed_models?: AllowedModels
|
||||||
daily_limit?: number
|
|
||||||
monthly_limit?: number
|
|
||||||
allowed_models?: string[] | null
|
|
||||||
capabilities?: Record<string, boolean> | null
|
capabilities?: Record<string, boolean> | null
|
||||||
cache_ttl_minutes?: number
|
cache_ttl_minutes?: number
|
||||||
max_probe_interval_minutes?: number
|
max_probe_interval_minutes?: number
|
||||||
@@ -198,7 +269,6 @@ export interface PublicEndpointStatusMonitorResponse {
|
|||||||
export interface ProviderWithEndpointsSummary {
|
export interface ProviderWithEndpointsSummary {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
display_name: string
|
|
||||||
description?: string
|
description?: string
|
||||||
website?: string
|
website?: string
|
||||||
provider_priority: number
|
provider_priority: number
|
||||||
@@ -208,9 +278,10 @@ export interface ProviderWithEndpointsSummary {
|
|||||||
quota_reset_day?: number
|
quota_reset_day?: number
|
||||||
quota_last_reset_at?: string // 当前周期开始时间
|
quota_last_reset_at?: string // 当前周期开始时间
|
||||||
quota_expires_at?: string
|
quota_expires_at?: string
|
||||||
rpm_limit?: number | null
|
// 请求配置(从 Endpoint 迁移)
|
||||||
rpm_used?: number
|
timeout?: number // 请求超时(秒)
|
||||||
rpm_reset_at?: string
|
max_retries?: number // 最大重试次数
|
||||||
|
proxy?: ProxyConfig | null // 代理配置
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
total_endpoints: number
|
total_endpoints: number
|
||||||
active_endpoints: number
|
active_endpoints: number
|
||||||
@@ -253,13 +324,10 @@ export interface HealthSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConcurrencyStatus {
|
export interface KeyRpmStatus {
|
||||||
endpoint_id?: string
|
key_id: string
|
||||||
endpoint_current_concurrency: number
|
current_rpm: number
|
||||||
endpoint_max_concurrent?: number
|
rpm_limit?: number
|
||||||
key_id?: string
|
|
||||||
key_current_concurrency: number
|
|
||||||
key_max_concurrent?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderModelMapping {
|
export interface ProviderModelMapping {
|
||||||
@@ -361,7 +429,6 @@ export interface ModelPriceRange {
|
|||||||
export interface ModelCatalogProviderDetail {
|
export interface ModelCatalogProviderDetail {
|
||||||
provider_id: string
|
provider_id: string
|
||||||
provider_name: string
|
provider_name: string
|
||||||
provider_display_name?: string | null
|
|
||||||
model_id?: string | null
|
model_id?: string | null
|
||||||
target_model: string
|
target_model: string
|
||||||
input_price_per_1m?: number | null
|
input_price_per_1m?: number | null
|
||||||
@@ -534,10 +601,10 @@ export interface UpstreamModel {
|
|||||||
*/
|
*/
|
||||||
export interface ImportFromUpstreamSuccessItem {
|
export interface ImportFromUpstreamSuccessItem {
|
||||||
model_id: string
|
model_id: string
|
||||||
global_model_id: string
|
|
||||||
global_model_name: string
|
|
||||||
provider_model_id: string
|
provider_model_id: string
|
||||||
created_global_model: boolean
|
global_model_id?: string // 可选,未关联时为空字符串
|
||||||
|
global_model_name?: string // 可选,未关联时为空字符串
|
||||||
|
created_global_model: boolean // 始终为 false(不再自动创建 GlobalModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
203
frontend/src/api/management-tokens.ts
Normal file
203
frontend/src/api/management-tokens.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Management Token API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
// ============== 类型定义 ==============
|
||||||
|
|
||||||
|
export interface ManagementToken {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
token_display: string
|
||||||
|
allowed_ips?: string[] | null
|
||||||
|
expires_at?: string | null
|
||||||
|
last_used_at?: string | null
|
||||||
|
last_used_ip?: string | null
|
||||||
|
usage_count: number
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
user?: {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateManagementTokenRequest {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
allowed_ips?: string[]
|
||||||
|
expires_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateManagementTokenResponse {
|
||||||
|
message: string
|
||||||
|
token: string
|
||||||
|
data: ManagementToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateManagementTokenRequest {
|
||||||
|
name?: string
|
||||||
|
description?: string | null
|
||||||
|
allowed_ips?: string[] | null
|
||||||
|
expires_at?: string | null
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagementTokenListResponse {
|
||||||
|
items: ManagementToken[]
|
||||||
|
total: number
|
||||||
|
skip: number
|
||||||
|
limit: number
|
||||||
|
quota?: {
|
||||||
|
used: number
|
||||||
|
max: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== 用户自助管理 API ==============
|
||||||
|
|
||||||
|
export const managementTokenApi = {
|
||||||
|
/**
|
||||||
|
* 列出当前用户的 Management Tokens
|
||||||
|
*/
|
||||||
|
async listTokens(params?: {
|
||||||
|
is_active?: boolean
|
||||||
|
skip?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<ManagementTokenListResponse> {
|
||||||
|
const response = await apiClient.get<ManagementTokenListResponse>(
|
||||||
|
'/api/me/management-tokens',
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Management Token
|
||||||
|
*/
|
||||||
|
async createToken(
|
||||||
|
data: CreateManagementTokenRequest
|
||||||
|
): Promise<CreateManagementTokenResponse> {
|
||||||
|
const response = await apiClient.post<CreateManagementTokenResponse>(
|
||||||
|
'/api/me/management-tokens',
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Token 详情
|
||||||
|
*/
|
||||||
|
async getToken(tokenId: string): Promise<ManagementToken> {
|
||||||
|
const response = await apiClient.get<ManagementToken>(
|
||||||
|
`/api/me/management-tokens/${tokenId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 Token
|
||||||
|
*/
|
||||||
|
async updateToken(
|
||||||
|
tokenId: string,
|
||||||
|
data: UpdateManagementTokenRequest
|
||||||
|
): Promise<{ message: string; data: ManagementToken }> {
|
||||||
|
const response = await apiClient.put<{ message: string; data: ManagementToken }>(
|
||||||
|
`/api/me/management-tokens/${tokenId}`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 Token
|
||||||
|
*/
|
||||||
|
async deleteToken(tokenId: string): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.delete<{ message: string }>(
|
||||||
|
`/api/me/management-tokens/${tokenId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换 Token 状态
|
||||||
|
*/
|
||||||
|
async toggleToken(
|
||||||
|
tokenId: string
|
||||||
|
): Promise<{ message: string; data: ManagementToken }> {
|
||||||
|
const response = await apiClient.patch<{ message: string; data: ManagementToken }>(
|
||||||
|
`/api/me/management-tokens/${tokenId}/status`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新生成 Token
|
||||||
|
*/
|
||||||
|
async regenerateToken(
|
||||||
|
tokenId: string
|
||||||
|
): Promise<{ token: string; data: ManagementToken }> {
|
||||||
|
const response = await apiClient.post<{ token: string; data: ManagementToken }>(
|
||||||
|
`/api/me/management-tokens/${tokenId}/regenerate`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== 管理员 API ==============
|
||||||
|
|
||||||
|
export const adminManagementTokenApi = {
|
||||||
|
/**
|
||||||
|
* 列出所有 Management Tokens(管理员)
|
||||||
|
*/
|
||||||
|
async listAllTokens(params?: {
|
||||||
|
user_id?: string
|
||||||
|
is_active?: boolean
|
||||||
|
skip?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<ManagementTokenListResponse> {
|
||||||
|
const response = await apiClient.get<ManagementTokenListResponse>(
|
||||||
|
'/api/admin/management-tokens',
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Token 详情(管理员)
|
||||||
|
*/
|
||||||
|
async getToken(tokenId: string): Promise<ManagementToken> {
|
||||||
|
const response = await apiClient.get<ManagementToken>(
|
||||||
|
`/api/admin/management-tokens/${tokenId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除任意 Token(管理员)
|
||||||
|
*/
|
||||||
|
async deleteToken(tokenId: string): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.delete<{ message: string }>(
|
||||||
|
`/api/admin/management-tokens/${tokenId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换任意 Token 状态(管理员)
|
||||||
|
*/
|
||||||
|
async toggleToken(
|
||||||
|
tokenId: string
|
||||||
|
): Promise<{ message: string; data: ManagementToken }> {
|
||||||
|
const response = await apiClient.patch<{ message: string; data: ManagementToken }>(
|
||||||
|
`/api/admin/management-tokens/${tokenId}/status`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,11 @@ export interface UsageRecordDetail {
|
|||||||
cache_creation_price_per_1m?: number
|
cache_creation_price_per_1m?: number
|
||||||
cache_read_price_per_1m?: number
|
cache_read_price_per_1m?: number
|
||||||
price_per_request?: number // 按次计费价格
|
price_per_request?: number // 按次计费价格
|
||||||
|
api_key?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型统计接口
|
// 模型统计接口
|
||||||
@@ -75,6 +80,16 @@ export interface ModelSummary {
|
|||||||
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提供商统计接口
|
||||||
|
export interface ProviderSummary {
|
||||||
|
provider: string
|
||||||
|
requests: number
|
||||||
|
total_tokens: number
|
||||||
|
total_cost_usd: number
|
||||||
|
success_rate: number | null
|
||||||
|
avg_response_time_ms: number | null
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统计响应接口
|
// 使用统计响应接口
|
||||||
export interface UsageResponse {
|
export interface UsageResponse {
|
||||||
total_requests: number
|
total_requests: number
|
||||||
@@ -87,6 +102,13 @@ export interface UsageResponse {
|
|||||||
quota_usd: number | null
|
quota_usd: number | null
|
||||||
used_usd: number
|
used_usd: number
|
||||||
summary_by_model: ModelSummary[]
|
summary_by_model: ModelSummary[]
|
||||||
|
summary_by_provider?: ProviderSummary[]
|
||||||
|
pagination?: {
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
has_more: boolean
|
||||||
|
}
|
||||||
records: UsageRecordDetail[]
|
records: UsageRecordDetail[]
|
||||||
activity_heatmap?: ActivityHeatmap | null
|
activity_heatmap?: ActivityHeatmap | null
|
||||||
}
|
}
|
||||||
@@ -175,6 +197,9 @@ export const meApi = {
|
|||||||
async getUsage(params?: {
|
async getUsage(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
search?: string // 通用搜索:密钥名、模型名
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
}): Promise<UsageResponse> {
|
}): Promise<UsageResponse> {
|
||||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
||||||
return response.data
|
return response.data
|
||||||
@@ -184,11 +209,12 @@ export const meApi = {
|
|||||||
async getActiveRequests(ids?: string): Promise<{
|
async getActiveRequests(ids?: string): Promise<{
|
||||||
requests: Array<{
|
requests: Array<{
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: 'pending' | 'streaming' | 'completed' | 'failed'
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
cost: number
|
cost: number
|
||||||
response_time_ms: number | null
|
response_time_ms: number | null
|
||||||
|
first_byte_time_ms: number | null
|
||||||
}>
|
}>
|
||||||
}> {
|
}> {
|
||||||
const params = ids ? { ids } : {}
|
const params = ids ? { ids } : {}
|
||||||
@@ -267,5 +293,14 @@ export const meApi = {
|
|||||||
}> {
|
}> {
|
||||||
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取活跃度热力图数据(用户)
|
||||||
|
* 后端已缓存5分钟
|
||||||
|
*/
|
||||||
|
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||||
|
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,10 +192,17 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按 provider 名称和模型名称排序
|
// 按 provider 名称排序,provider 中的模型按 release_date 从近到远排序
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
const providerCompare = a.providerName.localeCompare(b.providerName)
|
const providerCompare = a.providerName.localeCompare(b.providerName)
|
||||||
if (providerCompare !== 0) return providerCompare
|
if (providerCompare !== 0) return providerCompare
|
||||||
|
|
||||||
|
// 模型按 release_date 从近到远排序(没有日期的排到最后)
|
||||||
|
const aDate = a.releaseDate ? new Date(a.releaseDate).getTime() : 0
|
||||||
|
const bDate = b.releaseDate ? new Date(b.releaseDate).getTime() : 0
|
||||||
|
if (aDate !== bDate) return bDate - aDate // 降序:新的在前
|
||||||
|
|
||||||
|
// 日期相同或都没有日期时,按模型名称排序
|
||||||
return a.modelName.localeCompare(b.modelName)
|
return a.modelName.localeCompare(b.modelName)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export const usageApi = {
|
|||||||
async getAllUsageRecords(params?: {
|
async getAllUsageRecords(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
user_id?: string // UUID
|
user_id?: string // UUID
|
||||||
username?: string
|
username?: string
|
||||||
model?: string
|
model?: string
|
||||||
@@ -193,10 +194,22 @@ export const usageApi = {
|
|||||||
output_tokens: number
|
output_tokens: number
|
||||||
cost: number
|
cost: number
|
||||||
response_time_ms: number | null
|
response_time_ms: number | null
|
||||||
|
first_byte_time_ms: number | null
|
||||||
|
provider?: string | null
|
||||||
|
api_key_name?: string | null
|
||||||
}>
|
}>
|
||||||
}> {
|
}> {
|
||||||
const params = ids?.length ? { ids: ids.join(',') } : {}
|
const params = ids?.length ? { ids: ids.join(',') } : {}
|
||||||
const response = await apiClient.get('/api/admin/usage/active', { params })
|
const response = await apiClient.get('/api/admin/usage/active', { params })
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取活跃度热力图数据(管理员)
|
||||||
|
* 后端已缓存5分钟
|
||||||
|
*/
|
||||||
|
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||||
|
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface User {
|
|||||||
used_usd: number
|
used_usd: number
|
||||||
total_usd: number
|
total_usd: number
|
||||||
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
||||||
allowed_endpoints: string[] | null // 允许使用的端点 ID 列表
|
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
|
||||||
allowed_models: string[] | null // 允许使用的模型名称列表
|
allowed_models: string[] | null // 允许使用的模型名称列表
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
@@ -23,7 +23,7 @@ export interface CreateUserRequest {
|
|||||||
role?: 'admin' | 'user'
|
role?: 'admin' | 'user'
|
||||||
quota_usd?: number | null
|
quota_usd?: number | null
|
||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_endpoints?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export interface UpdateUserRequest {
|
|||||||
quota_usd?: number | null
|
quota_usd?: number | null
|
||||||
password?: string
|
password?: string
|
||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_endpoints?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
frontend/src/components/common/ModelMultiSelect.vue
Normal file
117
frontend/src/components/common/ModelMultiSelect.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">允许的模型</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||||
|
@click="isOpen = !isOpen"
|
||||||
|
>
|
||||||
|
<span :class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||||
|
{{ modelValue.length ? `已选择 ${modelValue.length} 个` : '全部可用' }}
|
||||||
|
<span
|
||||||
|
v-if="invalidModels.length"
|
||||||
|
class="text-destructive"
|
||||||
|
>({{ invalidModels.length }} 个已失效)</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||||
|
:class="isOpen ? 'rotate-180' : ''"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="fixed inset-0 z-[80]"
|
||||||
|
@click.stop="isOpen = false"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<!-- 失效模型(置顶显示,只能取消选择) -->
|
||||||
|
<div
|
||||||
|
v-for="modelName in invalidModels"
|
||||||
|
:key="modelName"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer bg-destructive/5"
|
||||||
|
@click="removeModel(modelName)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="true"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||||
|
@click.stop
|
||||||
|
@change="removeModel(modelName)"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-destructive">{{ modelName }}</span>
|
||||||
|
<span class="text-xs text-destructive/70">(已失效)</span>
|
||||||
|
</div>
|
||||||
|
<!-- 有效模型 -->
|
||||||
|
<div
|
||||||
|
v-for="model in models"
|
||||||
|
:key="model.name"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||||
|
@click="toggleModel(model.name)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="modelValue.includes(model.name)"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||||
|
@click.stop
|
||||||
|
@change="toggleModel(model.name)"
|
||||||
|
>
|
||||||
|
<span class="text-sm">{{ model.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="models.length === 0 && invalidModels.length === 0"
|
||||||
|
class="px-3 py-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无可用模型
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Label } from '@/components/ui'
|
||||||
|
import { ChevronDown } from 'lucide-vue-next'
|
||||||
|
import { useInvalidModels } from '@/composables/useInvalidModels'
|
||||||
|
|
||||||
|
export interface ModelWithName {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
models: ModelWithName[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
// 检测失效模型
|
||||||
|
const { invalidModels } = useInvalidModels(
|
||||||
|
computed(() => props.modelValue),
|
||||||
|
computed(() => props.models)
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleModel(name: string) {
|
||||||
|
const newValue = [...props.modelValue]
|
||||||
|
const index = newValue.indexOf(name)
|
||||||
|
if (index === -1) {
|
||||||
|
newValue.push(name)
|
||||||
|
} else {
|
||||||
|
newValue.splice(index, 1)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeModel(name: string) {
|
||||||
|
const newValue = props.modelValue.filter(m => m !== name)
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
112
frontend/src/components/common/UpdateDialog.vue
Normal file
112
frontend/src/components/common/UpdateDialog.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="isOpen"
|
||||||
|
size="md"
|
||||||
|
title=""
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center text-center py-2">
|
||||||
|
<!-- Logo -->
|
||||||
|
<HeaderLogo
|
||||||
|
size="h-16 w-16"
|
||||||
|
class-name="text-primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
|
||||||
|
发现新版本
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Version Info -->
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<span class="px-3 py-1.5 rounded-lg bg-muted text-sm font-mono text-muted-foreground">
|
||||||
|
v{{ currentVersion }}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="px-3 py-1.5 rounded-lg bg-primary/10 text-sm font-mono font-medium text-primary">
|
||||||
|
v{{ latestVersion }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-sm text-muted-foreground max-w-xs">
|
||||||
|
新版本已发布,建议更新以获得最新功能和安全修复
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex w-full gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="flex-1"
|
||||||
|
@click="handleLater"
|
||||||
|
>
|
||||||
|
稍后提醒
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="flex-1"
|
||||||
|
@click="handleViewRelease"
|
||||||
|
>
|
||||||
|
查看更新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Dialog } from '@/components/ui'
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
currentVersion: string
|
||||||
|
latestVersion: string
|
||||||
|
releaseUrl: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = ref(props.modelValue)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
isOpen.value = val
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isOpen, (val) => {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleLater() {
|
||||||
|
// 记录忽略的版本,24小时内不再提醒
|
||||||
|
const ignoreKey = 'aether_update_ignore'
|
||||||
|
const ignoreData = {
|
||||||
|
version: props.latestVersion,
|
||||||
|
until: Date.now() + 24 * 60 * 60 * 1000 // 24小时
|
||||||
|
}
|
||||||
|
localStorage.setItem(ignoreKey, JSON.stringify(ignoreData))
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewRelease() {
|
||||||
|
if (props.releaseUrl) {
|
||||||
|
window.open(props.releaseUrl, '_blank')
|
||||||
|
}
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -7,3 +7,6 @@
|
|||||||
export { default as EmptyState } from './EmptyState.vue'
|
export { default as EmptyState } from './EmptyState.vue'
|
||||||
export { default as AlertDialog } from './AlertDialog.vue'
|
export { default as AlertDialog } from './AlertDialog.vue'
|
||||||
export { default as LoadingState } from './LoadingState.vue'
|
export { default as LoadingState } from './LoadingState.vue'
|
||||||
|
|
||||||
|
// 表单组件
|
||||||
|
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
|
||||||
|
|||||||
13
frontend/src/components/icons/GithubIcon.vue
Normal file
13
frontend/src/components/icons/GithubIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
|
||||||
|
<path d="M9 18c-4.51 2-5-2-7-2" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
15
frontend/src/components/ui/collapsible-content.vue
Normal file
15
frontend/src/components/ui/collapsible-content.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CollapsibleContent, type CollapsibleContentProps } from 'radix-vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<CollapsibleContentProps & { class?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CollapsibleContent
|
||||||
|
v-bind="props"
|
||||||
|
:class="cn('overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</CollapsibleContent>
|
||||||
|
</template>
|
||||||
11
frontend/src/components/ui/collapsible-trigger.vue
Normal file
11
frontend/src/components/ui/collapsible-trigger.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CollapsibleTrigger, type CollapsibleTriggerProps } from 'radix-vue'
|
||||||
|
|
||||||
|
const props = defineProps<CollapsibleTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CollapsibleTrigger v-bind="props" as-child>
|
||||||
|
<slot />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</template>
|
||||||
15
frontend/src/components/ui/collapsible.vue
Normal file
15
frontend/src/components/ui/collapsible.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CollapsibleRoot, type CollapsibleRootEmits, type CollapsibleRootProps } from 'radix-vue'
|
||||||
|
import { useForwardPropsEmits } from 'radix-vue'
|
||||||
|
|
||||||
|
const props = defineProps<CollapsibleRootProps>()
|
||||||
|
const emits = defineEmits<CollapsibleRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CollapsibleRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</CollapsibleRoot>
|
||||||
|
</template>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto"
|
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto"
|
||||||
:style="{ zIndex: backdropZIndex }"
|
:style="{ zIndex: backdropZIndex }"
|
||||||
@click="handleClose"
|
@click="handleBackdropClick"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
@@ -106,6 +106,7 @@ const props = defineProps<{
|
|||||||
iconClass?: string // Custom icon color class
|
iconClass?: string // Custom icon color class
|
||||||
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
||||||
noPadding?: boolean // Disable default content padding
|
noPadding?: boolean // Disable default content padding
|
||||||
|
persistent?: boolean // Prevent closing on backdrop click
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Emits 定义
|
// Emits 定义
|
||||||
@@ -138,6 +139,13 @@ function handleClose() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理背景点击
|
||||||
|
function handleBackdropClick() {
|
||||||
|
if (!props.persistent) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const maxWidthClass = computed(() => {
|
const maxWidthClass = computed(() => {
|
||||||
const sizeValue = props.maxWidth || props.size || 'md'
|
const sizeValue = props.maxWidth || props.size || 'md'
|
||||||
const sizes = {
|
const sizes = {
|
||||||
@@ -162,7 +170,7 @@ const contentZIndex = computed(() => (props.zIndex || 60) + 10)
|
|||||||
|
|
||||||
// 添加 ESC 键监听
|
// 添加 ESC 键监听
|
||||||
useEscapeKey(() => {
|
useEscapeKey(() => {
|
||||||
if (isOpen.value) {
|
if (isOpen.value && !props.persistent) {
|
||||||
handleClose()
|
handleClose()
|
||||||
return true // 阻止其他监听器(如父级抽屉的 ESC 监听器)
|
return true // 阻止其他监听器(如父级抽屉的 ESC 监听器)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,3 +65,8 @@ export { default as RefreshButton } from './refresh-button.vue'
|
|||||||
|
|
||||||
// Tooltip 提示系列
|
// Tooltip 提示系列
|
||||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'
|
||||||
|
|
||||||
|
// Collapsible 折叠系列
|
||||||
|
export { default as Collapsible } from './collapsible.vue'
|
||||||
|
export { default as CollapsibleTrigger } from './collapsible-trigger.vue'
|
||||||
|
export { default as CollapsibleContent } from './collapsible-content.vue'
|
||||||
|
|||||||
34
frontend/src/composables/useInvalidModels.ts
Normal file
34
frontend/src/composables/useInvalidModels.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { computed, type Ref, type ComputedRef } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测失效模型的 composable
|
||||||
|
*
|
||||||
|
* 用于检测 allowed_models 中已不存在于 globalModels 的模型名称,
|
||||||
|
* 这些模型可能已被删除但引用未清理。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const { invalidModels } = useInvalidModels(
|
||||||
|
* computed(() => form.value.allowed_models),
|
||||||
|
* globalModels
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface ModelWithName {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInvalidModels<T extends ModelWithName>(
|
||||||
|
allowedModels: Ref<string[]> | ComputedRef<string[]>,
|
||||||
|
globalModels: Ref<T[]>
|
||||||
|
): { invalidModels: ComputedRef<string[]> } {
|
||||||
|
const validModelNames = computed(() =>
|
||||||
|
new Set(globalModels.value.map(m => m.name))
|
||||||
|
)
|
||||||
|
|
||||||
|
const invalidModels = computed(() =>
|
||||||
|
allowedModels.value.filter(name => !validModelNames.value.has(name))
|
||||||
|
)
|
||||||
|
|
||||||
|
return { invalidModels }
|
||||||
|
}
|
||||||
@@ -79,45 +79,45 @@
|
|||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
for="form-expire-days"
|
for="form-expires-at"
|
||||||
class="text-sm font-medium"
|
class="text-sm font-medium"
|
||||||
>有效期设置</Label>
|
>有效期设置</Label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
id="form-expire-days"
|
id="form-expires-at"
|
||||||
:model-value="form.expire_days ?? ''"
|
:model-value="form.expires_at || ''"
|
||||||
type="number"
|
type="date"
|
||||||
min="1"
|
:min="minExpiryDate"
|
||||||
max="3650"
|
class="h-9 pr-8"
|
||||||
placeholder="天数"
|
:placeholder="form.expires_at ? '' : '永不过期'"
|
||||||
:class="form.never_expire ? 'flex-1 h-9 opacity-50' : 'flex-1 h-9'"
|
@update:model-value="(v) => form.expires_at = v || undefined"
|
||||||
:disabled="form.never_expire"
|
|
||||||
@update:model-value="(v) => form.expire_days = parseNumberInput(v, { min: 1, max: 3650 })"
|
|
||||||
/>
|
/>
|
||||||
<label class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap">
|
<button
|
||||||
<input
|
v-if="form.expires_at"
|
||||||
v-model="form.never_expire"
|
type="button"
|
||||||
type="checkbox"
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
|
title="清空(永不过期)"
|
||||||
@change="onNeverExpireChange"
|
@click="clearExpiryDate"
|
||||||
>
|
>
|
||||||
永不过期
|
<X class="h-4 w-4" />
|
||||||
</label>
|
</button>
|
||||||
|
</div>
|
||||||
<label
|
<label
|
||||||
class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap"
|
class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap"
|
||||||
:class="form.never_expire ? 'opacity-50' : ''"
|
:class="!form.expires_at ? 'opacity-50 cursor-not-allowed' : ''"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.auto_delete_on_expiry"
|
v-model="form.auto_delete_on_expiry"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
|
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
|
||||||
:disabled="form.never_expire"
|
:disabled="!form.expires_at"
|
||||||
>
|
>
|
||||||
到期删除
|
到期删除
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
不勾选"到期删除"则仅禁用
|
{{ form.expires_at ? '到期后' + (form.auto_delete_on_expiry ? '自动删除' : '仅禁用') + '(当天 23:59 失效)' : '留空表示永不过期' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
@click.stop
|
@click.stop
|
||||||
@change="toggleSelection('allowed_providers', provider.id)"
|
@change="toggleSelection('allowed_providers', provider.id)"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ provider.display_name || provider.name }}</span>
|
<span class="text-sm">{{ provider.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="providers.length === 0"
|
v-if="providers.length === 0"
|
||||||
@@ -244,55 +244,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型多选下拉框 -->
|
<!-- 模型多选下拉框 -->
|
||||||
<div class="space-y-2">
|
<ModelMultiSelect
|
||||||
<Label class="text-sm font-medium">允许的模型</Label>
|
v-model="form.allowed_models"
|
||||||
<div class="relative">
|
:models="globalModels"
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
|
||||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
|
||||||
>
|
|
||||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
|
||||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
|
||||||
:class="modelDropdownOpen ? 'rotate-180' : ''"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="modelDropdownOpen"
|
|
||||||
class="fixed inset-0 z-[80]"
|
|
||||||
@click.stop="modelDropdownOpen = false"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="modelDropdownOpen"
|
|
||||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="model in globalModels"
|
|
||||||
:key="model.name"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
|
||||||
@click="toggleSelection('allowed_models', model.name)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.allowed_models.includes(model.name)"
|
|
||||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
|
||||||
@click.stop
|
|
||||||
@change="toggleSelection('allowed_models', model.name)"
|
|
||||||
>
|
|
||||||
<span class="text-sm">{{ model.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="globalModels.length === 0"
|
|
||||||
class="px-3 py-2 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无可用模型
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -325,8 +280,9 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { Plus, SquarePen, Key, Shield, ChevronDown } from 'lucide-vue-next'
|
import { Plus, SquarePen, Key, Shield, ChevronDown, X } from 'lucide-vue-next'
|
||||||
import { useFormDialog } from '@/composables/useFormDialog'
|
import { useFormDialog } from '@/composables/useFormDialog'
|
||||||
|
import { ModelMultiSelect } from '@/components/common'
|
||||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||||
import { getGlobalModels } from '@/api/global-models'
|
import { getGlobalModels } from '@/api/global-models'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
@@ -338,8 +294,7 @@ export interface StandaloneKeyFormData {
|
|||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
initial_balance_usd?: number
|
initial_balance_usd?: number
|
||||||
expire_days?: number
|
expires_at?: string // ISO 日期字符串,如 "2025-12-31",undefined = 永不过期
|
||||||
never_expire: boolean
|
|
||||||
rate_limit?: number
|
rate_limit?: number
|
||||||
auto_delete_on_expiry: boolean
|
auto_delete_on_expiry: boolean
|
||||||
allowed_providers: string[]
|
allowed_providers: string[]
|
||||||
@@ -363,7 +318,6 @@ const saving = ref(false)
|
|||||||
// 下拉框状态
|
// 下拉框状态
|
||||||
const providerDropdownOpen = ref(false)
|
const providerDropdownOpen = ref(false)
|
||||||
const apiFormatDropdownOpen = ref(false)
|
const apiFormatDropdownOpen = ref(false)
|
||||||
const modelDropdownOpen = ref(false)
|
|
||||||
|
|
||||||
// 选项数据
|
// 选项数据
|
||||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||||
@@ -374,8 +328,7 @@ const allApiFormats = ref<string[]>([])
|
|||||||
const form = ref<StandaloneKeyFormData>({
|
const form = ref<StandaloneKeyFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
initial_balance_usd: 10,
|
initial_balance_usd: 10,
|
||||||
expire_days: undefined,
|
expires_at: undefined,
|
||||||
never_expire: true,
|
|
||||||
rate_limit: undefined,
|
rate_limit: undefined,
|
||||||
auto_delete_on_expiry: false,
|
auto_delete_on_expiry: false,
|
||||||
allowed_providers: [],
|
allowed_providers: [],
|
||||||
@@ -383,12 +336,18 @@ const form = ref<StandaloneKeyFormData>({
|
|||||||
allowed_models: []
|
allowed_models: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 计算最小可选日期(明天)
|
||||||
|
const minExpiryDate = computed(() => {
|
||||||
|
const tomorrow = new Date()
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
return tomorrow.toISOString().split('T')[0]
|
||||||
|
})
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.value = {
|
form.value = {
|
||||||
name: '',
|
name: '',
|
||||||
initial_balance_usd: 10,
|
initial_balance_usd: 10,
|
||||||
expire_days: undefined,
|
expires_at: undefined,
|
||||||
never_expire: true,
|
|
||||||
rate_limit: undefined,
|
rate_limit: undefined,
|
||||||
auto_delete_on_expiry: false,
|
auto_delete_on_expiry: false,
|
||||||
allowed_providers: [],
|
allowed_providers: [],
|
||||||
@@ -397,7 +356,6 @@ function resetForm() {
|
|||||||
}
|
}
|
||||||
providerDropdownOpen.value = false
|
providerDropdownOpen.value = false
|
||||||
apiFormatDropdownOpen.value = false
|
apiFormatDropdownOpen.value = false
|
||||||
modelDropdownOpen.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadKeyData() {
|
function loadKeyData() {
|
||||||
@@ -406,8 +364,7 @@ function loadKeyData() {
|
|||||||
id: props.apiKey.id,
|
id: props.apiKey.id,
|
||||||
name: props.apiKey.name || '',
|
name: props.apiKey.name || '',
|
||||||
initial_balance_usd: props.apiKey.initial_balance_usd,
|
initial_balance_usd: props.apiKey.initial_balance_usd,
|
||||||
expire_days: props.apiKey.expire_days,
|
expires_at: props.apiKey.expires_at,
|
||||||
never_expire: props.apiKey.never_expire,
|
|
||||||
rate_limit: props.apiKey.rate_limit,
|
rate_limit: props.apiKey.rate_limit,
|
||||||
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
||||||
allowed_providers: props.apiKey.allowed_providers || [],
|
allowed_providers: props.apiKey.allowed_providers || [],
|
||||||
@@ -452,12 +409,10 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 永不过期切换
|
// 清空过期日期(同时清空到期删除选项)
|
||||||
function onNeverExpireChange() {
|
function clearExpiryDate() {
|
||||||
if (form.value.never_expire) {
|
form.value.expires_at = undefined
|
||||||
form.value.expire_days = undefined
|
|
||||||
form.value.auto_delete_on_expiry = false
|
form.value.auto_delete_on_expiry = false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
|
|||||||
@@ -66,19 +66,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 认证方式切换 -->
|
||||||
|
<div
|
||||||
|
v-if="showAuthTypeTabs"
|
||||||
|
class="auth-type-tabs"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="auth-tab"
|
||||||
|
:class="[authType === 'local' && 'active']"
|
||||||
|
@click="authType = 'local'"
|
||||||
|
>
|
||||||
|
本地登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="auth-tab"
|
||||||
|
:class="[authType === 'ldap' && 'active']"
|
||||||
|
@click="authType = 'ldap'"
|
||||||
|
>
|
||||||
|
LDAP 登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- 登录表单 -->
|
<!-- 登录表单 -->
|
||||||
<form
|
<form
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
@submit.prevent="handleLogin"
|
@submit.prevent="handleLogin"
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="login-email">邮箱</Label>
|
<div class="flex items-center justify-between">
|
||||||
|
<Label for="login-email">{{ emailLabel }}</Label>
|
||||||
|
<button
|
||||||
|
v-if="ldapExclusive && authType === 'ldap'"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
|
@click="authType = 'local'"
|
||||||
|
>
|
||||||
|
管理员本地登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="ldapExclusive && authType === 'local'"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
|
@click="authType = 'ldap'"
|
||||||
|
>
|
||||||
|
返回 LDAP 登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="login-email"
|
id="login-email"
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
type="email"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="hello@example.com"
|
placeholder="username 或 email"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,6 +222,30 @@ const showRegisterDialog = ref(false)
|
|||||||
const requireEmailVerification = ref(false)
|
const requireEmailVerification = ref(false)
|
||||||
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||||
|
|
||||||
|
// LDAP authentication settings
|
||||||
|
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
|
||||||
|
function getStoredAuthType(): 'local' | 'ldap' {
|
||||||
|
const stored = localStorage.getItem(PREFERRED_AUTH_TYPE_KEY)
|
||||||
|
return (stored === 'ldap' || stored === 'local') ? stored : 'local'
|
||||||
|
}
|
||||||
|
const authType = ref<'local' | 'ldap'>(getStoredAuthType())
|
||||||
|
const localEnabled = ref(true)
|
||||||
|
const ldapEnabled = ref(false)
|
||||||
|
const ldapExclusive = ref(false)
|
||||||
|
|
||||||
|
// 保存用户的认证类型偏好
|
||||||
|
watch(authType, (newType) => {
|
||||||
|
localStorage.setItem(PREFERRED_AUTH_TYPE_KEY, newType)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showAuthTypeTabs = computed(() => {
|
||||||
|
return localEnabled.value && ldapEnabled.value && !ldapExclusive.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const emailLabel = computed(() => {
|
||||||
|
return '用户名/邮箱'
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.modelValue, (val) => {
|
watch(() => props.modelValue, (val) => {
|
||||||
isOpen.value = val
|
isOpen.value = val
|
||||||
// 打开对话框时重置表单
|
// 打开对话框时重置表单
|
||||||
@@ -212,7 +278,7 @@ async function handleLogin() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await authStore.login(form.value.email, form.value.password)
|
const success = await authStore.login(form.value.email, form.value.password, authType.value)
|
||||||
if (success) {
|
if (success) {
|
||||||
showSuccess('登录成功,正在跳转...')
|
showSuccess('登录成功,正在跳转...')
|
||||||
|
|
||||||
@@ -246,16 +312,84 @@ function handleSwitchToLogin() {
|
|||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load registration settings on mount
|
// Load authentication and registration settings on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await authApi.getRegistrationSettings()
|
// Load registration settings
|
||||||
allowRegistration.value = !!settings.enable_registration
|
const regSettings = await authApi.getRegistrationSettings()
|
||||||
requireEmailVerification.value = !!settings.require_email_verification
|
allowRegistration.value = !!regSettings.enable_registration
|
||||||
|
requireEmailVerification.value = !!regSettings.require_email_verification
|
||||||
|
|
||||||
|
// Load authentication settings
|
||||||
|
const authSettings = await authApi.getAuthSettings()
|
||||||
|
localEnabled.value = authSettings.local_enabled
|
||||||
|
ldapEnabled.value = authSettings.ldap_enabled
|
||||||
|
ldapExclusive.value = authSettings.ldap_exclusive
|
||||||
|
// 若仅允许 LDAP 登录,则禁用本地注册入口
|
||||||
|
if (ldapExclusive.value) {
|
||||||
|
allowRegistration.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default auth type based on settings
|
||||||
|
if (authSettings.ldap_exclusive) {
|
||||||
|
authType.value = 'ldap'
|
||||||
|
} else if (!authSettings.local_enabled && authSettings.ldap_enabled) {
|
||||||
|
authType.value = 'ldap'
|
||||||
|
} else {
|
||||||
|
authType.value = 'local'
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证
|
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证 & 使用本地认证
|
||||||
allowRegistration.value = false
|
allowRegistration.value = false
|
||||||
requireEmailVerification.value = false
|
requireEmailVerification.value = false
|
||||||
|
localEnabled.value = true
|
||||||
|
ldapEnabled.value = false
|
||||||
|
ldapExclusive.value = false
|
||||||
|
authType.value = 'local'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-type-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab:hover:not(.active) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active {
|
||||||
|
color: var(--book-cloth);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active::after {
|
||||||
|
background: var(--book-cloth);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -460,13 +460,13 @@
|
|||||||
<TableHead class="h-10 font-semibold">
|
<TableHead class="h-10 font-semibold">
|
||||||
Provider
|
Provider
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[120px] h-10 font-semibold">
|
<TableHead class="w-[100px] h-10 font-semibold">
|
||||||
能力
|
能力
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[180px] h-10 font-semibold">
|
<TableHead class="w-[200px] h-10 font-semibold">
|
||||||
价格 ($/M)
|
价格 ($/M)
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[80px] h-10 font-semibold text-center">
|
<TableHead class="w-[100px] h-10 font-semibold text-center">
|
||||||
操作
|
操作
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||||
:title="provider.is_active ? '活跃' : '停用'"
|
:title="provider.is_active ? '活跃' : '停用'"
|
||||||
/>
|
/>
|
||||||
<span class="font-medium truncate">{{ provider.display_name }}</span>
|
<span class="font-medium truncate">{{ provider.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-3">
|
<TableCell class="py-3">
|
||||||
@@ -595,7 +595,7 @@
|
|||||||
class="w-2 h-2 rounded-full shrink-0"
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||||
/>
|
/>
|
||||||
<span class="font-medium truncate">{{ provider.display_name }}</span>
|
<span class="font-medium truncate">{{ provider.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -531,20 +531,23 @@ watch(() => props.open, async (isOpen) => {
|
|||||||
// 加载数据
|
// 加载数据
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
await Promise.all([loadGlobalModels(), loadExistingModels()])
|
await Promise.all([loadGlobalModels(), loadExistingModels()])
|
||||||
// 默认折叠全局模型组
|
|
||||||
collapsedGroups.value = new Set(['global'])
|
|
||||||
|
|
||||||
// 检查缓存,如果有缓存数据则直接使用
|
// 检查缓存,如果有缓存数据则直接使用
|
||||||
const cachedModels = getCachedModels(props.providerId)
|
const cachedModels = getCachedModels(props.providerId)
|
||||||
if (cachedModels) {
|
if (cachedModels && cachedModels.length > 0) {
|
||||||
upstreamModels.value = cachedModels
|
upstreamModels.value = cachedModels
|
||||||
upstreamModelsLoaded.value = true
|
upstreamModelsLoaded.value = true
|
||||||
// 折叠所有上游模型组
|
// 有多个分组时全部折叠
|
||||||
|
const allGroups = new Set(['global'])
|
||||||
for (const model of cachedModels) {
|
for (const model of cachedModels) {
|
||||||
if (model.api_format) {
|
if (model.api_format) {
|
||||||
collapsedGroups.value.add(model.api_format)
|
allGroups.add(model.api_format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
collapsedGroups.value = allGroups
|
||||||
|
} else {
|
||||||
|
// 只有全局模型时展开
|
||||||
|
collapsedGroups.value = new Set()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,8 +588,8 @@ async function fetchUpstreamModels(forceRefresh = false) {
|
|||||||
} else {
|
} else {
|
||||||
upstreamModels.value = result.models
|
upstreamModels.value = result.models
|
||||||
upstreamModelsLoaded.value = true
|
upstreamModelsLoaded.value = true
|
||||||
// 折叠所有上游模型组
|
// 有多个分组时全部折叠
|
||||||
const allGroups = new Set(collapsedGroups.value)
|
const allGroups = new Set(['global'])
|
||||||
for (const model of result.models) {
|
for (const model of result.models) {
|
||||||
if (model.api_format) {
|
if (model.api_format) {
|
||||||
allGroups.add(model.api_format)
|
allGroups.add(model.api_format)
|
||||||
|
|||||||
@@ -1,52 +1,142 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
:model-value="internalOpen"
|
:model-value="internalOpen"
|
||||||
:title="isEditMode ? '编辑 API 端点' : '添加 API 端点'"
|
title="端点管理"
|
||||||
:description="isEditMode ? `修改 ${provider?.display_name} 的端点配置` : '为提供商添加新的 API 端点'"
|
:description="`管理 ${provider?.name} 的 API 端点`"
|
||||||
:icon="isEditMode ? SquarePen : Link"
|
:icon="Settings"
|
||||||
size="xl"
|
size="2xl"
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<form
|
|
||||||
class="space-y-6"
|
|
||||||
@submit.prevent="handleSubmit()"
|
|
||||||
>
|
|
||||||
<!-- API 配置 -->
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h3
|
<!-- 已有端点列表 -->
|
||||||
v-if="isEditMode"
|
<div
|
||||||
class="text-sm font-medium"
|
v-if="localEndpoints.length > 0"
|
||||||
|
class="space-y-2"
|
||||||
>
|
>
|
||||||
API 配置
|
<Label class="text-muted-foreground">已配置的端点</Label>
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<!-- API 格式 -->
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="api_format">API 格式 *</Label>
|
<div
|
||||||
<template v-if="isEditMode">
|
v-for="endpoint in localEndpoints"
|
||||||
<Input
|
:key="endpoint.id"
|
||||||
id="api_format"
|
class="rounded-md border px-3 py-2"
|
||||||
v-model="form.api_format"
|
:class="{ 'opacity-50': !endpoint.is_active }"
|
||||||
disabled
|
|
||||||
class="bg-muted"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
API 格式创建后不可修改
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<Select
|
|
||||||
v-model="form.api_format"
|
|
||||||
v-model:open="selectOpen"
|
|
||||||
required
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<!-- 编辑模式 -->
|
||||||
<SelectValue placeholder="请选择 API 格式" />
|
<template v-if="editingEndpointId === endpoint.id">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium w-24 shrink-0">{{ API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format }}</span>
|
||||||
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="保存"
|
||||||
|
:disabled="savingEndpointId === endpoint.id"
|
||||||
|
@click="saveEndpointUrl(endpoint)"
|
||||||
|
>
|
||||||
|
<Check class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="取消"
|
||||||
|
@click="cancelEdit"
|
||||||
|
>
|
||||||
|
<X class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label class="text-xs text-muted-foreground">Base URL</Label>
|
||||||
|
<Input
|
||||||
|
v-model="editingUrl"
|
||||||
|
class="h-8 text-sm"
|
||||||
|
placeholder="https://api.example.com"
|
||||||
|
@keyup.escape="cancelEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label class="text-xs text-muted-foreground">自定义路径 (可选)</Label>
|
||||||
|
<Input
|
||||||
|
v-model="editingPath"
|
||||||
|
class="h-8 text-sm"
|
||||||
|
:placeholder="editingDefaultPath || '留空使用默认路径'"
|
||||||
|
@keyup.escape="cancelEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 查看模式 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-24 shrink-0">
|
||||||
|
<span class="text-sm font-medium">{{ API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-sm text-muted-foreground truncate block">
|
||||||
|
{{ endpoint.base_url }}{{ endpoint.custom_path ? endpoint.custom_path : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="编辑"
|
||||||
|
@click="startEdit(endpoint)"
|
||||||
|
>
|
||||||
|
<Edit class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
:title="endpoint.is_active ? '停用' : '启用'"
|
||||||
|
:disabled="togglingEndpointId === endpoint.id"
|
||||||
|
@click="handleToggleEndpoint(endpoint)"
|
||||||
|
>
|
||||||
|
<Power class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
title="删除"
|
||||||
|
:disabled="deletingEndpointId === endpoint.id"
|
||||||
|
@click="handleDeleteEndpoint(endpoint)"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加新端点 -->
|
||||||
|
<div
|
||||||
|
v-if="availableFormats.length > 0"
|
||||||
|
class="space-y-3 pt-3 border-t"
|
||||||
|
>
|
||||||
|
<Label class="text-muted-foreground">添加新端点</Label>
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<div class="w-32 shrink-0 space-y-1.5">
|
||||||
|
<Label class="text-xs">API 格式</Label>
|
||||||
|
<Select
|
||||||
|
v-model="newEndpoint.api_format"
|
||||||
|
v-model:open="formatSelectOpen"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-9">
|
||||||
|
<SelectValue placeholder="选择格式" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-for="format in apiFormats"
|
v-for="format in availableFormats"
|
||||||
:key="format.value"
|
:key="format.value"
|
||||||
:value="format.value"
|
:value="format.value"
|
||||||
>
|
>
|
||||||
@@ -54,192 +144,57 @@
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-1 space-y-1.5">
|
||||||
<!-- API URL -->
|
<Label class="text-xs">Base URL</Label>
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="base_url">API URL *</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="base_url"
|
v-model="newEndpoint.base_url"
|
||||||
v-model="form.base_url"
|
|
||||||
placeholder="https://api.example.com"
|
placeholder="https://api.example.com"
|
||||||
required
|
class="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="w-40 shrink-0 space-y-1.5">
|
||||||
|
<Label class="text-xs">自定义路径</Label>
|
||||||
<!-- 自定义路径 -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="custom_path">自定义请求路径(可选)</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="custom_path"
|
v-model="newEndpoint.custom_path"
|
||||||
v-model="form.custom_path"
|
:placeholder="newEndpointDefaultPath || '可选'"
|
||||||
:placeholder="defaultPathPlaceholder"
|
class="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button
|
||||||
|
variant="outline"
|
||||||
<!-- 请求配置 -->
|
size="sm"
|
||||||
<div class="space-y-4">
|
class="h-9 shrink-0"
|
||||||
<h3 class="text-sm font-medium">
|
:disabled="!newEndpoint.api_format || !newEndpoint.base_url || addingEndpoint"
|
||||||
请求配置
|
@click="handleAddEndpoint"
|
||||||
</h3>
|
>
|
||||||
|
{{ addingEndpoint ? '添加中...' : '添加' }}
|
||||||
<div class="grid grid-cols-3 gap-4">
|
</Button>
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="timeout">超时(秒)</Label>
|
|
||||||
<Input
|
|
||||||
id="timeout"
|
|
||||||
v-model.number="form.timeout"
|
|
||||||
type="number"
|
|
||||||
placeholder="300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="max_retries">最大重试</Label>
|
|
||||||
<Input
|
|
||||||
id="max_retries"
|
|
||||||
v-model.number="form.max_retries"
|
|
||||||
type="number"
|
|
||||||
placeholder="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="max_concurrent">最大并发</Label>
|
|
||||||
<Input
|
|
||||||
id="max_concurrent"
|
|
||||||
:model-value="form.max_concurrent ?? ''"
|
|
||||||
type="number"
|
|
||||||
placeholder="无限制"
|
|
||||||
@update:model-value="(v) => form.max_concurrent = parseNumberInput(v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="rate_limit">速率限制(请求/分钟)</Label>
|
|
||||||
<Input
|
|
||||||
id="rate_limit"
|
|
||||||
:model-value="form.rate_limit ?? ''"
|
|
||||||
type="number"
|
|
||||||
placeholder="无限制"
|
|
||||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 代理配置 -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-medium">
|
|
||||||
代理配置
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Switch v-model="proxyEnabled" />
|
|
||||||
<span class="text-sm text-muted-foreground">启用代理</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
<div
|
<div
|
||||||
v-if="proxyEnabled"
|
v-if="localEndpoints.length === 0 && availableFormats.length === 0"
|
||||||
class="space-y-4 rounded-lg border p-4"
|
class="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<p>所有 API 格式都已配置</p>
|
||||||
<Label for="proxy_url">代理 URL *</Label>
|
|
||||||
<Input
|
|
||||||
id="proxy_url"
|
|
||||||
v-model="form.proxy_url"
|
|
||||||
placeholder="http://host:port 或 socks5://host:port"
|
|
||||||
required
|
|
||||||
:class="proxyUrlError ? 'border-red-500' : ''"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
v-if="proxyUrlError"
|
|
||||||
class="text-xs text-red-500"
|
|
||||||
>
|
|
||||||
{{ proxyUrlError }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-else
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
支持 HTTP、HTTPS、SOCKS5 代理
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="proxy_user">用户名(可选)</Label>
|
|
||||||
<Input
|
|
||||||
:id="`proxy_user_${formId}`"
|
|
||||||
v-model="form.proxy_username"
|
|
||||||
:name="`proxy_user_${formId}`"
|
|
||||||
placeholder="代理认证用户名"
|
|
||||||
autocomplete="off"
|
|
||||||
data-form-type="other"
|
|
||||||
data-lpignore="true"
|
|
||||||
data-1p-ignore="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label :for="`proxy_pass_${formId}`">密码(可选)</Label>
|
|
||||||
<Input
|
|
||||||
:id="`proxy_pass_${formId}`"
|
|
||||||
v-model="form.proxy_password"
|
|
||||||
:name="`proxy_pass_${formId}`"
|
|
||||||
type="text"
|
|
||||||
:placeholder="passwordPlaceholder"
|
|
||||||
autocomplete="off"
|
|
||||||
data-form-type="other"
|
|
||||||
data-lpignore="true"
|
|
||||||
data-1p-ignore="true"
|
|
||||||
:style="{ '-webkit-text-security': 'disc', 'text-security': 'disc' }"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="loading"
|
@click="handleClose"
|
||||||
@click="handleCancel"
|
|
||||||
>
|
>
|
||||||
取消
|
关闭
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
:disabled="loading || !form.base_url || (!isEditMode && !form.api_format)"
|
|
||||||
@click="handleSubmit()"
|
|
||||||
>
|
|
||||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : '创建') }}
|
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<!-- 确认清空凭据对话框 -->
|
|
||||||
<AlertDialog
|
|
||||||
v-model="showClearCredentialsDialog"
|
|
||||||
title="清空代理凭据"
|
|
||||||
description="代理 URL 为空,但用户名和密码仍有值。是否清空这些凭据并继续保存?"
|
|
||||||
type="warning"
|
|
||||||
confirm-text="清空并保存"
|
|
||||||
cancel-text="返回编辑"
|
|
||||||
@confirm="confirmClearCredentials"
|
|
||||||
@cancel="showClearCredentialsDialog = false"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
Button,
|
Button,
|
||||||
@@ -250,17 +205,15 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
Switch,
|
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import { Settings, Edit, Trash2, Check, X, Power } from 'lucide-vue-next'
|
||||||
import { Link, SquarePen } from 'lucide-vue-next'
|
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useFormDialog } from '@/composables/useFormDialog'
|
|
||||||
import { parseNumberInput } from '@/utils/form'
|
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import {
|
import {
|
||||||
createEndpoint,
|
createEndpoint,
|
||||||
updateEndpoint,
|
updateEndpoint,
|
||||||
|
deleteEndpoint,
|
||||||
|
API_FORMAT_LABELS,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
type ProviderWithEndpointsSummary
|
type ProviderWithEndpointsSummary
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
@@ -269,7 +222,7 @@ import { adminApi } from '@/api/admin'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
provider: ProviderWithEndpointsSummary | null
|
provider: ProviderWithEndpointsSummary | null
|
||||||
endpoint?: ProviderEndpoint | null // 编辑模式时传入
|
endpoints?: ProviderEndpoint[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -279,258 +232,184 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
const loading = ref(false)
|
|
||||||
const selectOpen = ref(false)
|
|
||||||
const proxyEnabled = ref(false)
|
|
||||||
const showClearCredentialsDialog = ref(false) // 确认清空凭据对话框
|
|
||||||
|
|
||||||
// 生成随机 ID 防止浏览器自动填充
|
// 状态
|
||||||
const formId = Math.random().toString(36).substring(2, 10)
|
const addingEndpoint = ref(false)
|
||||||
|
const editingEndpointId = ref<string | null>(null)
|
||||||
|
const editingUrl = ref('')
|
||||||
|
const editingPath = ref('')
|
||||||
|
const savingEndpointId = ref<string | null>(null)
|
||||||
|
const deletingEndpointId = ref<string | null>(null)
|
||||||
|
const togglingEndpointId = ref<string | null>(null)
|
||||||
|
const formatSelectOpen = ref(false)
|
||||||
|
|
||||||
// 内部状态
|
// 内部状态
|
||||||
const internalOpen = computed(() => props.modelValue)
|
const internalOpen = computed(() => props.modelValue)
|
||||||
|
|
||||||
// 表单数据
|
// 新端点表单
|
||||||
const form = ref({
|
const newEndpoint = ref({
|
||||||
api_format: '',
|
api_format: '',
|
||||||
base_url: '',
|
base_url: '',
|
||||||
custom_path: '',
|
custom_path: '',
|
||||||
timeout: 300,
|
|
||||||
max_retries: 3,
|
|
||||||
max_concurrent: undefined as number | undefined,
|
|
||||||
rate_limit: undefined as number | undefined,
|
|
||||||
is_active: true,
|
|
||||||
// 代理配置
|
|
||||||
proxy_url: '',
|
|
||||||
proxy_username: '',
|
|
||||||
proxy_password: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// API 格式列表
|
// API 格式列表
|
||||||
const apiFormats = ref<Array<{ value: string; label: string; default_path: string; aliases: string[] }>>([])
|
const apiFormats = ref<Array<{ value: string; label: string; default_path: string }>>([])
|
||||||
|
|
||||||
// 加载API格式列表
|
// 本地端点列表
|
||||||
|
const localEndpoints = ref<ProviderEndpoint[]>([])
|
||||||
|
|
||||||
|
// 可用的格式(未添加的)
|
||||||
|
const availableFormats = computed(() => {
|
||||||
|
const existingFormats = localEndpoints.value.map(e => e.api_format)
|
||||||
|
return apiFormats.value.filter(f => !existingFormats.includes(f.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取指定 API 格式的默认路径
|
||||||
|
function getDefaultPath(apiFormat: string): string {
|
||||||
|
const format = apiFormats.value.find(f => f.value === apiFormat)
|
||||||
|
return format?.default_path || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前编辑端点的默认路径
|
||||||
|
const editingDefaultPath = computed(() => {
|
||||||
|
const endpoint = localEndpoints.value.find(e => e.id === editingEndpointId.value)
|
||||||
|
return endpoint ? getDefaultPath(endpoint.api_format) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 新端点选择的格式的默认路径
|
||||||
|
const newEndpointDefaultPath = computed(() => {
|
||||||
|
return getDefaultPath(newEndpoint.value.api_format)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载 API 格式列表
|
||||||
const loadApiFormats = async () => {
|
const loadApiFormats = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.getApiFormats()
|
const response = await adminApi.getApiFormats()
|
||||||
apiFormats.value = response.formats
|
apiFormats.value = response.formats
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('加载API格式失败:', error)
|
log.error('加载API格式失败:', error)
|
||||||
if (!isEditMode.value) {
|
|
||||||
showError('加载API格式失败', '错误')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据选择的 API 格式计算默认路径
|
|
||||||
const defaultPath = computed(() => {
|
|
||||||
const format = apiFormats.value.find(f => f.value === form.value.api_format)
|
|
||||||
return format?.default_path || '/'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 动态 placeholder
|
|
||||||
const defaultPathPlaceholder = computed(() => {
|
|
||||||
return `留空使用默认路径:${defaultPath.value}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查是否有已保存的密码(后端返回 *** 表示有密码)
|
|
||||||
const hasExistingPassword = computed(() => {
|
|
||||||
if (!props.endpoint?.proxy) return false
|
|
||||||
const proxy = props.endpoint.proxy as { password?: string }
|
|
||||||
return proxy?.password === MASKED_PASSWORD
|
|
||||||
})
|
|
||||||
|
|
||||||
// 密码输入框的 placeholder
|
|
||||||
const passwordPlaceholder = computed(() => {
|
|
||||||
if (hasExistingPassword.value) {
|
|
||||||
return '已保存密码,留空保持不变'
|
|
||||||
}
|
|
||||||
return '代理认证密码'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 代理 URL 验证
|
|
||||||
const proxyUrlError = computed(() => {
|
|
||||||
// 只有启用代理且填写了 URL 时才验证
|
|
||||||
if (!proxyEnabled.value || !form.value.proxy_url) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const url = form.value.proxy_url.trim()
|
|
||||||
|
|
||||||
// 检查禁止的特殊字符
|
|
||||||
if (/[\n\r]/.test(url)) {
|
|
||||||
return '代理 URL 包含非法字符'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证协议(不支持 SOCKS4)
|
|
||||||
if (!/^(http|https|socks5):\/\//i.test(url)) {
|
|
||||||
return '代理 URL 必须以 http://, https:// 或 socks5:// 开头'
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url)
|
|
||||||
if (!parsed.host) {
|
|
||||||
return '代理 URL 必须包含有效的 host'
|
|
||||||
}
|
|
||||||
// 禁止 URL 中内嵌认证信息
|
|
||||||
if (parsed.username || parsed.password) {
|
|
||||||
return '请勿在 URL 中包含用户名和密码,请使用独立的认证字段'
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return '代理 URL 格式无效'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 组件挂载时加载API格式
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadApiFormats()
|
loadApiFormats()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 重置表单
|
// 监听 props 变化
|
||||||
function resetForm() {
|
watch(() => props.modelValue, (open) => {
|
||||||
form.value = {
|
if (open) {
|
||||||
api_format: '',
|
localEndpoints.value = [...(props.endpoints || [])]
|
||||||
base_url: '',
|
// 重置编辑状态
|
||||||
custom_path: '',
|
editingEndpointId.value = null
|
||||||
timeout: 300,
|
editingUrl.value = ''
|
||||||
max_retries: 3,
|
editingPath.value = ''
|
||||||
max_concurrent: undefined,
|
} else {
|
||||||
rate_limit: undefined,
|
// 关闭对话框时完全清空新端点表单
|
||||||
is_active: true,
|
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
|
||||||
proxy_url: '',
|
|
||||||
proxy_username: '',
|
|
||||||
proxy_password: '',
|
|
||||||
}
|
}
|
||||||
proxyEnabled.value = false
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(() => props.endpoints, (endpoints) => {
|
||||||
|
if (props.modelValue) {
|
||||||
|
localEndpoints.value = [...(endpoints || [])]
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 开始编辑
|
||||||
|
function startEdit(endpoint: ProviderEndpoint) {
|
||||||
|
editingEndpointId.value = endpoint.id
|
||||||
|
editingUrl.value = endpoint.base_url
|
||||||
|
editingPath.value = endpoint.custom_path || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原始密码占位符(后端返回的脱敏标记)
|
// 取消编辑
|
||||||
const MASKED_PASSWORD = '***'
|
function cancelEdit() {
|
||||||
|
editingEndpointId.value = null
|
||||||
// 加载端点数据(编辑模式)
|
editingUrl.value = ''
|
||||||
function loadEndpointData() {
|
editingPath.value = ''
|
||||||
if (!props.endpoint) return
|
|
||||||
|
|
||||||
const proxy = props.endpoint.proxy as { url?: string; username?: string; password?: string; enabled?: boolean } | null
|
|
||||||
|
|
||||||
form.value = {
|
|
||||||
api_format: props.endpoint.api_format,
|
|
||||||
base_url: props.endpoint.base_url,
|
|
||||||
custom_path: props.endpoint.custom_path || '',
|
|
||||||
timeout: props.endpoint.timeout,
|
|
||||||
max_retries: props.endpoint.max_retries,
|
|
||||||
max_concurrent: props.endpoint.max_concurrent || undefined,
|
|
||||||
rate_limit: props.endpoint.rate_limit || undefined,
|
|
||||||
is_active: props.endpoint.is_active,
|
|
||||||
proxy_url: proxy?.url || '',
|
|
||||||
proxy_username: proxy?.username || '',
|
|
||||||
// 如果密码是脱敏标记,显示为空(让用户知道有密码但看不到)
|
|
||||||
proxy_password: proxy?.password === MASKED_PASSWORD ? '' : (proxy?.password || ''),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据 enabled 字段或 url 存在判断是否启用代理
|
|
||||||
proxyEnabled.value = proxy?.enabled ?? !!proxy?.url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 useFormDialog 统一处理对话框逻辑
|
// 保存端点
|
||||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
async function saveEndpointUrl(endpoint: ProviderEndpoint) {
|
||||||
isOpen: () => props.modelValue,
|
if (!editingUrl.value) return
|
||||||
entity: () => props.endpoint,
|
|
||||||
isLoading: loading,
|
|
||||||
onClose: () => emit('update:modelValue', false),
|
|
||||||
loadData: loadEndpointData,
|
|
||||||
resetForm,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 构建代理配置
|
savingEndpointId.value = endpoint.id
|
||||||
// - 有 URL 时始终保存配置,通过 enabled 字段控制是否启用
|
|
||||||
// - 无 URL 时返回 null
|
|
||||||
function buildProxyConfig(): { url: string; username?: string; password?: string; enabled: boolean } | null {
|
|
||||||
if (!form.value.proxy_url) {
|
|
||||||
// 没填 URL,无代理配置
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
url: form.value.proxy_url,
|
|
||||||
username: form.value.proxy_username || undefined,
|
|
||||||
password: form.value.proxy_password || undefined,
|
|
||||||
enabled: proxyEnabled.value, // 开关状态决定是否启用
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const handleSubmit = async (skipCredentialCheck = false) => {
|
|
||||||
if (!props.provider && !props.endpoint) return
|
|
||||||
|
|
||||||
// 只在开关开启且填写了 URL 时验证
|
|
||||||
if (proxyEnabled.value && form.value.proxy_url && proxyUrlError.value) {
|
|
||||||
showError(proxyUrlError.value, '代理配置错误')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查:开关开启但没有 URL,却有用户名或密码
|
|
||||||
const hasOrphanedCredentials = proxyEnabled.value
|
|
||||||
&& !form.value.proxy_url
|
|
||||||
&& (form.value.proxy_username || form.value.proxy_password)
|
|
||||||
|
|
||||||
if (hasOrphanedCredentials && !skipCredentialCheck) {
|
|
||||||
// 弹出确认对话框
|
|
||||||
showClearCredentialsDialog.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const proxyConfig = buildProxyConfig()
|
await updateEndpoint(endpoint.id, {
|
||||||
|
base_url: editingUrl.value,
|
||||||
if (isEditMode.value && props.endpoint) {
|
custom_path: editingPath.value || null, // 空字符串时传 null 清空
|
||||||
// 更新端点
|
|
||||||
await updateEndpoint(props.endpoint.id, {
|
|
||||||
base_url: form.value.base_url,
|
|
||||||
custom_path: form.value.custom_path || undefined,
|
|
||||||
timeout: form.value.timeout,
|
|
||||||
max_retries: form.value.max_retries,
|
|
||||||
max_concurrent: form.value.max_concurrent,
|
|
||||||
rate_limit: form.value.rate_limit,
|
|
||||||
is_active: form.value.is_active,
|
|
||||||
proxy: proxyConfig,
|
|
||||||
})
|
})
|
||||||
|
success('端点已更新')
|
||||||
success('端点已更新', '保存成功')
|
|
||||||
emit('endpointUpdated')
|
emit('endpointUpdated')
|
||||||
} else if (props.provider) {
|
cancelEdit()
|
||||||
// 创建端点
|
} catch (error: any) {
|
||||||
|
showError(error.response?.data?.detail || '更新失败', '错误')
|
||||||
|
} finally {
|
||||||
|
savingEndpointId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加端点
|
||||||
|
async function handleAddEndpoint() {
|
||||||
|
if (!props.provider || !newEndpoint.value.api_format || !newEndpoint.value.base_url) return
|
||||||
|
|
||||||
|
addingEndpoint.value = true
|
||||||
|
try {
|
||||||
await createEndpoint(props.provider.id, {
|
await createEndpoint(props.provider.id, {
|
||||||
provider_id: props.provider.id,
|
provider_id: props.provider.id,
|
||||||
api_format: form.value.api_format,
|
api_format: newEndpoint.value.api_format,
|
||||||
base_url: form.value.base_url,
|
base_url: newEndpoint.value.base_url,
|
||||||
custom_path: form.value.custom_path || undefined,
|
custom_path: newEndpoint.value.custom_path || undefined,
|
||||||
timeout: form.value.timeout,
|
is_active: true,
|
||||||
max_retries: form.value.max_retries,
|
|
||||||
max_concurrent: form.value.max_concurrent,
|
|
||||||
rate_limit: form.value.rate_limit,
|
|
||||||
is_active: form.value.is_active,
|
|
||||||
proxy: proxyConfig,
|
|
||||||
})
|
})
|
||||||
|
success(`已添加 ${API_FORMAT_LABELS[newEndpoint.value.api_format] || newEndpoint.value.api_format} 端点`)
|
||||||
success('端点创建成功', '成功')
|
// 重置表单,保留 URL
|
||||||
|
const url = newEndpoint.value.base_url
|
||||||
|
newEndpoint.value = { api_format: '', base_url: url, custom_path: '' }
|
||||||
emit('endpointCreated')
|
emit('endpointCreated')
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const action = isEditMode.value ? '更新' : '创建'
|
showError(error.response?.data?.detail || '添加失败', '错误')
|
||||||
showError(error.response?.data?.detail || `${action}端点失败`, '错误')
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
addingEndpoint.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确认清空凭据并继续保存
|
// 切换端点启用状态
|
||||||
const confirmClearCredentials = () => {
|
async function handleToggleEndpoint(endpoint: ProviderEndpoint) {
|
||||||
form.value.proxy_username = ''
|
togglingEndpointId.value = endpoint.id
|
||||||
form.value.proxy_password = ''
|
try {
|
||||||
showClearCredentialsDialog.value = false
|
const newStatus = !endpoint.is_active
|
||||||
handleSubmit(true) // 跳过凭据检查,直接提交
|
await updateEndpoint(endpoint.id, { is_active: newStatus })
|
||||||
|
success(newStatus ? '端点已启用' : '端点已停用')
|
||||||
|
emit('endpointUpdated')
|
||||||
|
} catch (error: any) {
|
||||||
|
showError(error.response?.data?.detail || '操作失败', '错误')
|
||||||
|
} finally {
|
||||||
|
togglingEndpointId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除端点
|
||||||
|
async function handleDeleteEndpoint(endpoint: ProviderEndpoint) {
|
||||||
|
deletingEndpointId.value = endpoint.id
|
||||||
|
try {
|
||||||
|
await deleteEndpoint(endpoint.id)
|
||||||
|
success(`已删除 ${API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format} 端点`)
|
||||||
|
emit('endpointUpdated')
|
||||||
|
} catch (error: any) {
|
||||||
|
showError(error.response?.data?.detail || '删除失败', '错误')
|
||||||
|
} finally {
|
||||||
|
deletingEndpointId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
function handleDialogUpdate(value: boolean) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,147 +1,160 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
:model-value="isOpen"
|
:model-value="isOpen"
|
||||||
title="配置允许的模型"
|
title="获取上游模型"
|
||||||
description="选择该 API Key 允许访问的模型,留空则允许访问所有模型"
|
:description="`使用密钥 ${props.apiKey?.name || props.apiKey?.api_key_masked || ''} 从上游获取模型列表。导入的模型需要关联全局模型后才能参与路由。`"
|
||||||
:icon="Settings2"
|
:icon="Layers"
|
||||||
size="2xl"
|
size="2xl"
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<div class="space-y-4 py-2">
|
<div class="space-y-4 py-2">
|
||||||
<!-- 已选模型展示 -->
|
<!-- 操作区域 -->
|
||||||
<div
|
<div class="flex items-center justify-between">
|
||||||
v-if="selectedModels.length > 0"
|
<div class="text-sm text-muted-foreground">
|
||||||
class="space-y-2"
|
<span v-if="!hasQueried">点击获取按钮查询上游可用模型</span>
|
||||||
>
|
<span v-else-if="upstreamModels.length > 0">
|
||||||
<div class="flex items-center justify-between px-1">
|
共 {{ upstreamModels.length }} 个模型,已选 {{ selectedModels.length }} 个
|
||||||
<div class="text-xs font-medium text-muted-foreground">
|
</span>
|
||||||
已选模型 ({{ selectedModels.length }})
|
<span v-else>未找到可用模型</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-6 text-xs hover:text-destructive"
|
:disabled="loading"
|
||||||
@click="clearModels"
|
@click="fetchUpstreamModels"
|
||||||
>
|
>
|
||||||
清空
|
<RefreshCw
|
||||||
|
class="w-3.5 h-3.5 mr-1.5"
|
||||||
|
:class="{ 'animate-spin': loading }"
|
||||||
|
/>
|
||||||
|
{{ hasQueried ? '刷新' : '获取模型' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1.5 p-2 bg-muted/20 rounded-lg border border-border/40 min-h-[40px]">
|
|
||||||
<Badge
|
|
||||||
v-for="modelName in selectedModels"
|
|
||||||
:key="modelName"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-[11px] px-2 py-0.5 bg-background border-border/60 shadow-sm"
|
|
||||||
>
|
|
||||||
{{ getModelLabel(modelName) }}
|
|
||||||
<button
|
|
||||||
class="ml-0.5 hover:text-destructive focus:outline-none"
|
|
||||||
@click.stop="toggleModel(modelName, false)"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 模型列表区域 -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between px-1">
|
|
||||||
<div class="text-xs font-medium text-muted-foreground">
|
|
||||||
可选模型列表
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="!loadingModels && availableModels.length > 0"
|
|
||||||
class="text-[10px] text-muted-foreground/60"
|
|
||||||
>
|
|
||||||
共 {{ availableModels.length }} 个模型
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div
|
<div
|
||||||
v-if="loadingModels"
|
v-if="loading"
|
||||||
class="flex flex-col items-center justify-center py-12 space-y-3"
|
class="flex flex-col items-center justify-center py-12 space-y-3"
|
||||||
>
|
>
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-2 border-primary/20 border-t-primary" />
|
<div class="animate-spin rounded-full h-8 w-8 border-2 border-primary/20 border-t-primary" />
|
||||||
<span class="text-xs text-muted-foreground">正在加载模型列表...</span>
|
<span class="text-xs text-muted-foreground">正在从上游获取模型列表...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<div
|
||||||
|
v-else-if="errorMessage"
|
||||||
|
class="flex flex-col items-center justify-center py-12 text-destructive border border-dashed border-destructive/30 rounded-lg bg-destructive/5"
|
||||||
|
>
|
||||||
|
<AlertCircle class="w-10 h-10 mb-2 opacity-50" />
|
||||||
|
<span class="text-sm text-center px-4">{{ errorMessage }}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="mt-3"
|
||||||
|
@click="fetchUpstreamModels"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未查询状态 -->
|
||||||
|
<div
|
||||||
|
v-else-if="!hasQueried"
|
||||||
|
class="flex flex-col items-center justify-center py-12 text-muted-foreground border border-dashed rounded-lg bg-muted/10"
|
||||||
|
>
|
||||||
|
<Layers class="w-10 h-10 mb-2 opacity-20" />
|
||||||
|
<span class="text-sm">点击上方按钮获取模型列表</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 无模型 -->
|
<!-- 无模型 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="availableModels.length === 0"
|
v-else-if="upstreamModels.length === 0"
|
||||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground border border-dashed rounded-lg bg-muted/10"
|
class="flex flex-col items-center justify-center py-12 text-muted-foreground border border-dashed rounded-lg bg-muted/10"
|
||||||
>
|
>
|
||||||
<Box class="w-10 h-10 mb-2 opacity-20" />
|
<Box class="w-10 h-10 mb-2 opacity-20" />
|
||||||
<span class="text-sm">暂无可选模型</span>
|
<span class="text-sm">上游 API 未返回可用模型</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型列表 -->
|
<!-- 模型列表 -->
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<!-- 全选/取消 -->
|
||||||
|
<div class="flex items-center justify-between px-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
:checked="isAllSelected"
|
||||||
|
:indeterminate="isPartiallySelected"
|
||||||
|
@update:checked="toggleSelectAll"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{ newModelsCount }} 个新模型(不在本地)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[320px] overflow-y-auto pr-1 space-y-1 custom-scrollbar">
|
||||||
<div
|
<div
|
||||||
v-else
|
v-for="model in upstreamModels"
|
||||||
class="max-h-[320px] overflow-y-auto pr-1 space-y-1.5 custom-scrollbar"
|
:key="`${model.id}:${model.api_format || ''}`"
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="model in availableModels"
|
|
||||||
:key="model.global_model_name"
|
|
||||||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200 cursor-pointer select-none"
|
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200 cursor-pointer select-none"
|
||||||
:class="[
|
:class="[
|
||||||
selectedModels.includes(model.global_model_name)
|
selectedModels.includes(model.id)
|
||||||
? 'border-primary/40 bg-primary/5 shadow-sm'
|
? 'border-primary/40 bg-primary/5 shadow-sm'
|
||||||
: 'border-border/40 bg-background hover:border-primary/20 hover:bg-muted/30'
|
: 'border-border/40 bg-background hover:border-primary/20 hover:bg-muted/30'
|
||||||
]"
|
]"
|
||||||
@click="toggleModel(model.global_model_name, !selectedModels.includes(model.global_model_name))"
|
@click="toggleModel(model.id)"
|
||||||
>
|
>
|
||||||
<!-- Checkbox -->
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="selectedModels.includes(model.global_model_name)"
|
:checked="selectedModels.includes(model.id)"
|
||||||
class="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
class="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||||
@click.stop
|
@click.stop
|
||||||
@update:checked="checked => toggleModel(model.global_model_name, checked)"
|
@update:checked="checked => toggleModel(model.id, checked)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Info -->
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm font-medium truncate text-foreground/90">{{ model.display_name }}</span>
|
<span class="text-sm font-medium truncate text-foreground/90">
|
||||||
<span
|
{{ model.display_name || model.id }}
|
||||||
v-if="hasPricing(model)"
|
|
||||||
class="text-[10px] font-mono text-muted-foreground/80 bg-muted/30 px-1.5 py-0.5 rounded border border-border/30 shrink-0"
|
|
||||||
>
|
|
||||||
{{ formatPricingShort(model) }}
|
|
||||||
</span>
|
</span>
|
||||||
|
<Badge
|
||||||
|
v-if="model.api_format"
|
||||||
|
variant="outline"
|
||||||
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
|
>
|
||||||
|
{{ API_FORMAT_LABELS[model.api_format] || model.api_format }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="isModelExisting(model.id)"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
|
>
|
||||||
|
已存在
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[11px] text-muted-foreground/60 font-mono truncate mt-0.5">
|
<div class="text-[11px] text-muted-foreground/60 font-mono truncate mt-0.5">
|
||||||
{{ model.global_model_name }}
|
{{ model.id }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<!-- 测试按钮 -->
|
v-if="model.owned_by"
|
||||||
<Button
|
class="text-[10px] text-muted-foreground/50 shrink-0"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-7 w-7 shrink-0"
|
|
||||||
title="测试模型连接"
|
|
||||||
:disabled="testingModelName === model.global_model_name"
|
|
||||||
@click.stop="testModelConnection(model)"
|
|
||||||
>
|
>
|
||||||
<Loader2
|
{{ model.owned_by }}
|
||||||
v-if="testingModelName === model.global_model_name"
|
</div>
|
||||||
class="w-3.5 h-3.5 animate-spin"
|
|
||||||
/>
|
|
||||||
<Play
|
|
||||||
v-else
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex items-center justify-end gap-2 w-full pt-2">
|
<div class="flex items-center justify-between w-full pt-2">
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
<span v-if="selectedModels.length > 0 && newSelectedCount > 0">
|
||||||
|
将导入 {{ newSelectedCount }} 个新模型
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="h-9"
|
class="h-9"
|
||||||
@@ -150,36 +163,37 @@
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
:disabled="saving"
|
:disabled="importing || selectedModels.length === 0 || newSelectedCount === 0"
|
||||||
class="h-9 min-w-[80px]"
|
class="h-9 min-w-[100px]"
|
||||||
@click="handleSave"
|
@click="handleImport"
|
||||||
>
|
>
|
||||||
<Loader2
|
<Loader2
|
||||||
v-if="saving"
|
v-if="importing"
|
||||||
class="w-3.5 h-3.5 mr-1.5 animate-spin"
|
class="w-3.5 h-3.5 mr-1.5 animate-spin"
|
||||||
/>
|
/>
|
||||||
{{ saving ? '保存中' : '保存配置' }}
|
{{ importing ? '导入中' : `导入 ${newSelectedCount} 个模型` }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Box, Loader2, Settings2, Play } from 'lucide-vue-next'
|
import { Box, Layers, Loader2, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||||
import { Dialog } from '@/components/ui'
|
import { Dialog } from '@/components/ui'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Checkbox from '@/components/ui/checkbox.vue'
|
import Checkbox from '@/components/ui/checkbox.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
import { adminApi } from '@/api/admin'
|
||||||
import {
|
import {
|
||||||
updateEndpointKey,
|
importModelsFromUpstream,
|
||||||
getProviderAvailableSourceModels,
|
getProviderModels,
|
||||||
testModel,
|
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type ProviderAvailableSourceModel
|
type UpstreamModel,
|
||||||
|
API_FORMAT_LABELS,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -196,130 +210,116 @@ const emit = defineEmits<{
|
|||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
|
||||||
const isOpen = computed(() => props.open)
|
const isOpen = computed(() => props.open)
|
||||||
const saving = ref(false)
|
const loading = ref(false)
|
||||||
const loadingModels = ref(false)
|
const importing = ref(false)
|
||||||
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
const hasQueried = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const upstreamModels = ref<UpstreamModel[]>([])
|
||||||
const selectedModels = ref<string[]>([])
|
const selectedModels = ref<string[]>([])
|
||||||
const initialModels = ref<string[]>([])
|
const existingModelIds = ref<Set<string>>(new Set())
|
||||||
const testingModelName = ref<string | null>(null)
|
|
||||||
|
// 计算属性
|
||||||
|
const isAllSelected = computed(() =>
|
||||||
|
upstreamModels.value.length > 0 &&
|
||||||
|
selectedModels.value.length === upstreamModels.value.length
|
||||||
|
)
|
||||||
|
|
||||||
|
const isPartiallySelected = computed(() =>
|
||||||
|
selectedModels.value.length > 0 &&
|
||||||
|
selectedModels.value.length < upstreamModels.value.length
|
||||||
|
)
|
||||||
|
|
||||||
|
const newModelsCount = computed(() =>
|
||||||
|
upstreamModels.value.filter(m => !existingModelIds.value.has(m.id)).length
|
||||||
|
)
|
||||||
|
|
||||||
|
const newSelectedCount = computed(() =>
|
||||||
|
selectedModels.value.filter(id => !existingModelIds.value.has(id)).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 检查模型是否已存在
|
||||||
|
function isModelExisting(modelId: string): boolean {
|
||||||
|
return existingModelIds.value.has(modelId)
|
||||||
|
}
|
||||||
|
|
||||||
// 监听对话框打开
|
// 监听对话框打开
|
||||||
watch(() => props.open, (open) => {
|
watch(() => props.open, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
loadData()
|
resetState()
|
||||||
|
loadExistingModels()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadData() {
|
function resetState() {
|
||||||
// 初始化已选模型
|
hasQueried.value = false
|
||||||
if (props.apiKey?.allowed_models) {
|
errorMessage.value = ''
|
||||||
selectedModels.value = [...props.apiKey.allowed_models]
|
upstreamModels.value = []
|
||||||
initialModels.value = [...props.apiKey.allowed_models]
|
|
||||||
} else {
|
|
||||||
selectedModels.value = []
|
selectedModels.value = []
|
||||||
initialModels.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载可选模型
|
|
||||||
if (props.providerId) {
|
|
||||||
await loadAvailableModels()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAvailableModels() {
|
// 加载已存在的模型列表
|
||||||
|
async function loadExistingModels() {
|
||||||
if (!props.providerId) return
|
if (!props.providerId) return
|
||||||
try {
|
try {
|
||||||
loadingModels.value = true
|
const models = await getProviderModels(props.providerId)
|
||||||
const response = await getProviderAvailableSourceModels(props.providerId)
|
existingModelIds.value = new Set(
|
||||||
availableModels.value = response.models
|
models.map((m: { provider_model_name: string }) => m.provider_model_name)
|
||||||
} catch (err: any) {
|
)
|
||||||
const errorMessage = parseApiError(err, '加载模型列表失败')
|
} catch {
|
||||||
showError(errorMessage, '错误')
|
existingModelIds.value = new Set()
|
||||||
} finally {
|
|
||||||
loadingModels.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelLabelMap = computed(() => {
|
// 获取上游模型
|
||||||
const map = new Map<string, string>()
|
async function fetchUpstreamModels() {
|
||||||
availableModels.value.forEach(model => {
|
if (!props.providerId || !props.apiKey) return
|
||||||
map.set(model.global_model_name, model.display_name || model.global_model_name)
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
function getModelLabel(modelName: string): string {
|
loading.value = true
|
||||||
return modelLabelMap.value.get(modelName) ?? modelName
|
errorMessage.value = ''
|
||||||
}
|
|
||||||
|
|
||||||
function hasPricing(model: ProviderAvailableSourceModel): boolean {
|
|
||||||
const input = model.price.input_price_per_1m ?? 0
|
|
||||||
const output = model.price.output_price_per_1m ?? 0
|
|
||||||
return input > 0 || output > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPricingShort(model: ProviderAvailableSourceModel): string {
|
|
||||||
const input = model.price.input_price_per_1m ?? 0
|
|
||||||
const output = model.price.output_price_per_1m ?? 0
|
|
||||||
if (input > 0 || output > 0) {
|
|
||||||
return `$${formatPrice(input)}/$${formatPrice(output)}`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPrice(value?: number | null): string {
|
|
||||||
if (value === undefined || value === null || value === 0) return '0'
|
|
||||||
if (value >= 1) {
|
|
||||||
return value.toFixed(2)
|
|
||||||
}
|
|
||||||
return value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleModel(modelName: string, checked: boolean) {
|
|
||||||
if (checked) {
|
|
||||||
if (!selectedModels.value.includes(modelName)) {
|
|
||||||
selectedModels.value = [...selectedModels.value, modelName]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selectedModels.value = selectedModels.value.filter(name => name !== modelName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearModels() {
|
|
||||||
selectedModels.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试模型连接
|
|
||||||
async function testModelConnection(model: ProviderAvailableSourceModel) {
|
|
||||||
if (!props.providerId || !props.apiKey || testingModelName.value) return
|
|
||||||
|
|
||||||
testingModelName.value = model.global_model_name
|
|
||||||
try {
|
try {
|
||||||
const result = await testModel({
|
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
|
||||||
provider_id: props.providerId,
|
|
||||||
model_name: model.provider_model_name,
|
|
||||||
api_key_id: props.apiKey.id,
|
|
||||||
message: "hello"
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
if (response.success && response.data?.models) {
|
||||||
success(`模型 "${model.display_name}" 测试成功`)
|
upstreamModels.value = response.data.models
|
||||||
|
// 默认选中所有新模型
|
||||||
|
selectedModels.value = response.data.models
|
||||||
|
.filter((m: UpstreamModel) => !existingModelIds.value.has(m.id))
|
||||||
|
.map((m: UpstreamModel) => m.id)
|
||||||
|
hasQueried.value = true
|
||||||
|
// 如果有部分失败,显示警告提示
|
||||||
|
if (response.data.error) {
|
||||||
|
showError(`部分格式获取失败: ${response.data.error}`, '警告')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
errorMessage.value = response.data?.error || '获取上游模型失败'
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
errorMessage.value = err.response?.data?.detail || '获取上游模型失败'
|
||||||
showError(`模型测试失败: ${errorMsg}`)
|
|
||||||
} finally {
|
} finally {
|
||||||
testingModelName.value = null
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function areArraysEqual(a: string[], b: string[]): boolean {
|
// 切换模型选择
|
||||||
if (a.length !== b.length) return false
|
function toggleModel(modelId: string, checked?: boolean) {
|
||||||
const sortedA = [...a].sort()
|
const shouldSelect = checked !== undefined ? checked : !selectedModels.value.includes(modelId)
|
||||||
const sortedB = [...b].sort()
|
if (shouldSelect) {
|
||||||
return sortedA.every((value, index) => value === sortedB[index])
|
if (!selectedModels.value.includes(modelId)) {
|
||||||
|
selectedModels.value = [...selectedModels.value, modelId]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedModels.value = selectedModels.value.filter(id => id !== modelId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选/取消全选
|
||||||
|
function toggleSelectAll(checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
selectedModels.value = upstreamModels.value.map(m => m.id)
|
||||||
|
} else {
|
||||||
|
selectedModels.value = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDialogUpdate(value: boolean) {
|
function handleDialogUpdate(value: boolean) {
|
||||||
@@ -332,30 +332,44 @@ function handleCancel() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
// 导入选中的模型
|
||||||
if (!props.apiKey) return
|
async function handleImport() {
|
||||||
|
if (!props.providerId || selectedModels.value.length === 0) return
|
||||||
|
|
||||||
// 检查是否有变化
|
// 过滤出新模型(不在已存在列表中的)
|
||||||
const hasChanged = !areArraysEqual(selectedModels.value, initialModels.value)
|
const modelsToImport = selectedModels.value.filter(id => !existingModelIds.value.has(id))
|
||||||
if (!hasChanged) {
|
if (modelsToImport.length === 0) {
|
||||||
emit('close')
|
showError('所选模型都已存在', '提示')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true
|
importing.value = true
|
||||||
try {
|
try {
|
||||||
await updateEndpointKey(props.apiKey.id, {
|
const response = await importModelsFromUpstream(props.providerId, modelsToImport)
|
||||||
// 空数组时发送 null,表示允许所有模型
|
|
||||||
allowed_models: selectedModels.value.length > 0 ? [...selectedModels.value] : null
|
const successCount = response.success?.length || 0
|
||||||
})
|
const errorCount = response.errors?.length || 0
|
||||||
success('允许的模型已更新', '成功')
|
|
||||||
|
if (successCount > 0 && errorCount === 0) {
|
||||||
|
success(`成功导入 ${successCount} 个模型`, '导入成功')
|
||||||
emit('saved')
|
emit('saved')
|
||||||
emit('close')
|
emit('close')
|
||||||
|
} else if (successCount > 0 && errorCount > 0) {
|
||||||
|
success(`成功导入 ${successCount} 个模型,${errorCount} 个失败`, '部分成功')
|
||||||
|
emit('saved')
|
||||||
|
// 刷新列表以更新已存在状态
|
||||||
|
await loadExistingModels()
|
||||||
|
// 更新选中列表,移除已成功导入的
|
||||||
|
const successIds = new Set(response.success?.map((s: { model_id: string }) => s.model_id) || [])
|
||||||
|
selectedModels.value = selectedModels.value.filter(id => !successIds.has(id))
|
||||||
|
} else {
|
||||||
|
const errorMsg = response.errors?.[0]?.error || '导入失败'
|
||||||
|
showError(errorMsg, '导入失败')
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = parseApiError(err, '保存失败')
|
showError(err.response?.data?.detail || '导入失败', '错误')
|
||||||
showError(errorMessage, '错误')
|
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
importing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,696 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:model-value="isOpen"
|
||||||
|
title="模型权限"
|
||||||
|
:description="`管理密钥 ${props.apiKey?.name || ''} 可访问的模型,清空右侧列表表示允许全部`"
|
||||||
|
:icon="Shield"
|
||||||
|
size="4xl"
|
||||||
|
@update:model-value="handleDialogUpdate"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 字典模式警告 -->
|
||||||
|
<div
|
||||||
|
v-if="isDictMode"
|
||||||
|
class="rounded-lg border border-amber-500/50 bg-amber-50 dark:bg-amber-950/30 p-3"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
<strong>注意:</strong>此密钥使用按 API 格式区分的模型权限配置。
|
||||||
|
编辑后将转换为统一列表模式,原有的格式区分信息将丢失。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 密钥信息头部 -->
|
||||||
|
<div class="rounded-lg border bg-muted/30 p-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-lg">{{ apiKey?.name }}</p>
|
||||||
|
<p class="text-sm text-muted-foreground font-mono">
|
||||||
|
{{ apiKey?.api_key_masked }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
:variant="allowedModels.length === 0 ? 'default' : 'outline'"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
{{ allowedModels.length === 0 ? '允许全部' : `限制 ${allowedModels.length} 个模型` }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 左右对比布局 -->
|
||||||
|
<div class="flex gap-2 items-stretch">
|
||||||
|
<!-- 左侧:可添加的模型 -->
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<p class="text-sm font-medium shrink-0">可添加</p>
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索模型..."
|
||||||
|
class="pl-7 h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="upstreamModelsLoaded"
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||||
|
title="刷新上游模型"
|
||||||
|
:disabled="fetchingUpstreamModels"
|
||||||
|
@click="fetchUpstreamModels()"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="!fetchingUpstreamModels"
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||||
|
title="从提供商获取模型"
|
||||||
|
@click="fetchUpstreamModels()"
|
||||||
|
>
|
||||||
|
<Zap class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<Loader2
|
||||||
|
v-else
|
||||||
|
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-if="loadingGlobalModels"
|
||||||
|
class="flex items-center justify-center h-full"
|
||||||
|
>
|
||||||
|
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="totalAvailableCount === 0 && !upstreamModelsLoaded"
|
||||||
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可添加模型' }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="p-2 space-y-2">
|
||||||
|
<!-- 全局模型折叠组 -->
|
||||||
|
<div
|
||||||
|
v-if="availableGlobalModels.length > 0 || !upstreamModelsLoaded"
|
||||||
|
class="border rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
||||||
|
@click="toggleGroupCollapse('global')"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
class="w-4 h-4 transition-transform shrink-0"
|
||||||
|
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium">全局模型</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
({{ availableGlobalModels.length }})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="availableGlobalModels.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-primary hover:underline shrink-0"
|
||||||
|
@click.stop="selectAllGlobalModels"
|
||||||
|
>
|
||||||
|
{{ isAllGlobalModelsSelected ? '取消' : '全选' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-show="!collapsedGroups.has('global')"
|
||||||
|
class="p-2 space-y-1 border-t"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="availableGlobalModels.length === 0"
|
||||||
|
class="py-4 text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
所有全局模型均已添加
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="model in availableGlobalModels"
|
||||||
|
v-else
|
||||||
|
:key="model.name"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||||
|
:class="selectedLeftIds.includes(model.name)
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'hover:bg-muted/50'"
|
||||||
|
@click="toggleLeftSelection(model.name)"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:checked="selectedLeftIds.includes(model.name)"
|
||||||
|
@update:checked="toggleLeftSelection(model.name)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm truncate">{{ model.display_name }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 从提供商获取的模型折叠组 -->
|
||||||
|
<div
|
||||||
|
v-for="group in upstreamModelGroups"
|
||||||
|
:key="group.api_format"
|
||||||
|
class="border rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
||||||
|
@click="toggleGroupCollapse(group.api_format)"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
class="w-4 h-4 transition-transform shrink-0"
|
||||||
|
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium">
|
||||||
|
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
({{ group.models.length }})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-primary hover:underline shrink-0"
|
||||||
|
@click.stop="selectAllUpstreamModels(group.api_format)"
|
||||||
|
>
|
||||||
|
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消' : '全选' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-show="!collapsedGroups.has(group.api_format)"
|
||||||
|
class="p-2 space-y-1 border-t"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="model in group.models"
|
||||||
|
:key="model.id"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||||
|
:class="selectedLeftIds.includes(model.id)
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'hover:bg-muted/50'"
|
||||||
|
@click="toggleLeftSelection(model.id)"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:checked="selectedLeftIds.includes(model.id)"
|
||||||
|
@update:checked="toggleLeftSelection(model.id)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm truncate">{{ model.id }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||||
|
{{ model.owned_by || model.id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中间:操作按钮 -->
|
||||||
|
<div class="flex flex-col items-center justify-center w-12 shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-9 h-8"
|
||||||
|
:class="selectedLeftIds.length > 0 ? 'border-primary' : ''"
|
||||||
|
:disabled="selectedLeftIds.length === 0"
|
||||||
|
title="添加选中"
|
||||||
|
@click="addSelected"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
class="w-6 h-6 stroke-[3]"
|
||||||
|
:class="selectedLeftIds.length > 0 ? 'text-primary' : ''"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-9 h-8"
|
||||||
|
:class="selectedRightIds.length > 0 ? 'border-primary' : ''"
|
||||||
|
:disabled="selectedRightIds.length === 0"
|
||||||
|
title="移除选中"
|
||||||
|
@click="removeSelected"
|
||||||
|
>
|
||||||
|
<ChevronLeft
|
||||||
|
class="w-6 h-6 stroke-[3]"
|
||||||
|
:class="selectedRightIds.length > 0 ? 'text-primary' : ''"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:已添加的允许模型 -->
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-medium">已添加</p>
|
||||||
|
<Button
|
||||||
|
v-if="allowedModels.length > 0"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
@click="toggleSelectAllRight"
|
||||||
|
>
|
||||||
|
{{ isAllRightSelected ? '取消' : '全选' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-if="allowedModels.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p class="text-sm">允许访问全部模型</p>
|
||||||
|
<p class="text-xs mt-1">添加模型以限制访问范围</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="p-2 space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="modelName in allowedModels"
|
||||||
|
:key="'allowed-' + modelName"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||||
|
:class="selectedRightIds.includes(modelName)
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'hover:bg-muted/50'"
|
||||||
|
@click="toggleRightSelection(modelName)"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:checked="selectedRightIds.includes(modelName)"
|
||||||
|
@update:checked="toggleRightSelection(modelName)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm truncate">
|
||||||
|
{{ getModelDisplayName(modelName) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||||
|
{{ modelName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-between w-full">
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ hasChanges ? '有未保存的更改' : '' }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button variant="outline" @click="handleCancel">取消</Button>
|
||||||
|
<Button :disabled="saving || !hasChanges" @click="handleSave">
|
||||||
|
{{ saving ? '保存中...' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
Zap,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronDown
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { Dialog, Button, Input, Checkbox, Badge } from '@/components/ui'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
import {
|
||||||
|
updateProviderKey,
|
||||||
|
API_FORMAT_LABELS,
|
||||||
|
type EndpointAPIKey,
|
||||||
|
type AllowedModels,
|
||||||
|
} from '@/api/endpoints'
|
||||||
|
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||||
|
import { adminApi } from '@/api/admin'
|
||||||
|
import type { UpstreamModel } from '@/api/endpoints/types'
|
||||||
|
|
||||||
|
interface AvailableModel {
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
apiKey: EndpointAPIKey | null
|
||||||
|
providerId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { success, error: showError } = useToast()
|
||||||
|
|
||||||
|
const isOpen = computed(() => props.open)
|
||||||
|
const saving = ref(false)
|
||||||
|
const loadingGlobalModels = ref(false)
|
||||||
|
const fetchingUpstreamModels = ref(false)
|
||||||
|
const upstreamModelsLoaded = ref(false)
|
||||||
|
|
||||||
|
// 用于取消异步操作的标志
|
||||||
|
let loadingCancelled = false
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// 折叠状态
|
||||||
|
const collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 可用模型列表(全局模型)
|
||||||
|
const allGlobalModels = ref<AvailableModel[]>([])
|
||||||
|
// 上游模型列表
|
||||||
|
const upstreamModels = ref<UpstreamModel[]>([])
|
||||||
|
|
||||||
|
// 已添加的允许模型(右侧)
|
||||||
|
const allowedModels = ref<string[]>([])
|
||||||
|
const initialAllowedModels = ref<string[]>([])
|
||||||
|
|
||||||
|
// 选中状态
|
||||||
|
const selectedLeftIds = ref<string[]>([])
|
||||||
|
const selectedRightIds = ref<string[]>([])
|
||||||
|
|
||||||
|
// 是否有更改
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
if (allowedModels.value.length !== initialAllowedModels.value.length) return true
|
||||||
|
const sorted1 = [...allowedModels.value].sort()
|
||||||
|
const sorted2 = [...initialAllowedModels.value].sort()
|
||||||
|
return sorted1.some((v, i) => v !== sorted2[i])
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算可添加的全局模型(排除已添加的)
|
||||||
|
const availableGlobalModelsBase = computed(() => {
|
||||||
|
const allowedSet = new Set(allowedModels.value)
|
||||||
|
return allGlobalModels.value.filter(m => !allowedSet.has(m.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索过滤后的全局模型
|
||||||
|
const availableGlobalModels = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return availableGlobalModelsBase.value
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
return availableGlobalModelsBase.value.filter(m =>
|
||||||
|
m.name.toLowerCase().includes(query) ||
|
||||||
|
m.display_name.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算可添加的上游模型(排除已添加的)
|
||||||
|
const availableUpstreamModelsBase = computed(() => {
|
||||||
|
const allowedSet = new Set(allowedModels.value)
|
||||||
|
return upstreamModels.value.filter(m => !allowedSet.has(m.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索过滤后的上游模型
|
||||||
|
const availableUpstreamModels = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return availableUpstreamModelsBase.value
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
return availableUpstreamModelsBase.value.filter(m =>
|
||||||
|
m.id.toLowerCase().includes(query) ||
|
||||||
|
(m.owned_by && m.owned_by.toLowerCase().includes(query))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按 API 格式分组的上游模型
|
||||||
|
const upstreamModelGroups = computed(() => {
|
||||||
|
const groups: Record<string, UpstreamModel[]> = {}
|
||||||
|
for (const model of availableUpstreamModels.value) {
|
||||||
|
const format = model.api_format || 'unknown'
|
||||||
|
if (!groups[format]) groups[format] = []
|
||||||
|
groups[format].push(model)
|
||||||
|
}
|
||||||
|
const order = Object.keys(API_FORMAT_LABELS)
|
||||||
|
return Object.entries(groups)
|
||||||
|
.map(([api_format, models]) => ({ api_format, models }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIndex = order.indexOf(a.api_format)
|
||||||
|
const bIndex = order.indexOf(b.api_format)
|
||||||
|
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
||||||
|
if (aIndex === -1) return 1
|
||||||
|
if (bIndex === -1) return -1
|
||||||
|
return aIndex - bIndex
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 总可添加数量
|
||||||
|
const totalAvailableCount = computed(() => {
|
||||||
|
return availableGlobalModels.value.length + availableUpstreamModels.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// 右侧全选状态
|
||||||
|
const isAllRightSelected = computed(() =>
|
||||||
|
allowedModels.value.length > 0 &&
|
||||||
|
selectedRightIds.value.length === allowedModels.value.length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 全局模型是否全选
|
||||||
|
const isAllGlobalModelsSelected = computed(() => {
|
||||||
|
if (availableGlobalModels.value.length === 0) return false
|
||||||
|
return availableGlobalModels.value.every(m => selectedLeftIds.value.includes(m.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查某个上游组是否全选
|
||||||
|
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
|
||||||
|
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
|
||||||
|
if (!group || group.models.length === 0) return false
|
||||||
|
return group.models.every(m => selectedLeftIds.value.includes(m.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模型显示名称
|
||||||
|
function getModelDisplayName(name: string): string {
|
||||||
|
const globalModel = allGlobalModels.value.find(m => m.name === name)
|
||||||
|
if (globalModel) return globalModel.display_name
|
||||||
|
const upstreamModel = upstreamModels.value.find(m => m.id === name)
|
||||||
|
if (upstreamModel) return upstreamModel.id
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载全局模型
|
||||||
|
async function loadGlobalModels() {
|
||||||
|
loadingGlobalModels.value = true
|
||||||
|
try {
|
||||||
|
const response = await getGlobalModels({ limit: 1000 })
|
||||||
|
// 检查是否已取消(dialog 已关闭)
|
||||||
|
if (loadingCancelled) return
|
||||||
|
allGlobalModels.value = response.models.map((m: GlobalModelResponse) => ({
|
||||||
|
name: m.name,
|
||||||
|
display_name: m.display_name
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
if (loadingCancelled) return
|
||||||
|
showError('加载全局模型失败', '错误')
|
||||||
|
} finally {
|
||||||
|
loadingGlobalModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从提供商获取模型(使用当前 key)
|
||||||
|
async function fetchUpstreamModels() {
|
||||||
|
if (!props.providerId || !props.apiKey) return
|
||||||
|
try {
|
||||||
|
fetchingUpstreamModels.value = true
|
||||||
|
// 使用当前 key 的 ID 来查询上游模型
|
||||||
|
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
|
||||||
|
// 检查是否已取消
|
||||||
|
if (loadingCancelled) return
|
||||||
|
if (response.success && response.data?.models) {
|
||||||
|
upstreamModels.value = response.data.models
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
const allGroups = new Set(['global'])
|
||||||
|
for (const model of response.data.models) {
|
||||||
|
if (model.api_format) allGroups.add(model.api_format)
|
||||||
|
}
|
||||||
|
collapsedGroups.value = allGroups
|
||||||
|
} else {
|
||||||
|
showError(response.data?.error || '获取上游模型失败', '错误')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (loadingCancelled) return
|
||||||
|
showError(err.response?.data?.detail || '获取上游模型失败', '错误')
|
||||||
|
} finally {
|
||||||
|
fetchingUpstreamModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换折叠状态
|
||||||
|
function toggleGroupCollapse(group: string) {
|
||||||
|
if (collapsedGroups.value.has(group)) {
|
||||||
|
collapsedGroups.value.delete(group)
|
||||||
|
} else {
|
||||||
|
collapsedGroups.value.add(group)
|
||||||
|
}
|
||||||
|
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否为字典模式(按 API 格式区分)
|
||||||
|
const isDictMode = ref(false)
|
||||||
|
|
||||||
|
// 解析 allowed_models
|
||||||
|
function parseAllowedModels(allowed: AllowedModels): string[] {
|
||||||
|
if (allowed === null || allowed === undefined) {
|
||||||
|
isDictMode.value = false
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (Array.isArray(allowed)) {
|
||||||
|
isDictMode.value = false
|
||||||
|
return [...allowed]
|
||||||
|
}
|
||||||
|
// 字典模式:合并所有格式的模型,并设置警告标志
|
||||||
|
isDictMode.value = true
|
||||||
|
const all = new Set<string>()
|
||||||
|
for (const models of Object.values(allowed)) {
|
||||||
|
models.forEach(m => all.add(m))
|
||||||
|
}
|
||||||
|
return Array.from(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左侧选择
|
||||||
|
function toggleLeftSelection(name: string) {
|
||||||
|
const idx = selectedLeftIds.value.indexOf(name)
|
||||||
|
if (idx === -1) {
|
||||||
|
selectedLeftIds.value.push(name)
|
||||||
|
} else {
|
||||||
|
selectedLeftIds.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧选择
|
||||||
|
function toggleRightSelection(name: string) {
|
||||||
|
const idx = selectedRightIds.value.indexOf(name)
|
||||||
|
if (idx === -1) {
|
||||||
|
selectedRightIds.value.push(name)
|
||||||
|
} else {
|
||||||
|
selectedRightIds.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧全选切换
|
||||||
|
function toggleSelectAllRight() {
|
||||||
|
if (isAllRightSelected.value) {
|
||||||
|
selectedRightIds.value = []
|
||||||
|
} else {
|
||||||
|
selectedRightIds.value = [...allowedModels.value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选全局模型
|
||||||
|
function selectAllGlobalModels() {
|
||||||
|
const allNames = availableGlobalModels.value.map(m => m.name)
|
||||||
|
const allSelected = allNames.every(name => selectedLeftIds.value.includes(name))
|
||||||
|
if (allSelected) {
|
||||||
|
selectedLeftIds.value = selectedLeftIds.value.filter(id => !allNames.includes(id))
|
||||||
|
} else {
|
||||||
|
const newNames = allNames.filter(name => !selectedLeftIds.value.includes(name))
|
||||||
|
selectedLeftIds.value.push(...newNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选某个 API 格式的上游模型
|
||||||
|
function selectAllUpstreamModels(apiFormat: string) {
|
||||||
|
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
|
||||||
|
if (!group) return
|
||||||
|
const allIds = group.models.map(m => m.id)
|
||||||
|
const allSelected = allIds.every(id => selectedLeftIds.value.includes(id))
|
||||||
|
if (allSelected) {
|
||||||
|
selectedLeftIds.value = selectedLeftIds.value.filter(id => !allIds.includes(id))
|
||||||
|
} else {
|
||||||
|
const newIds = allIds.filter(id => !selectedLeftIds.value.includes(id))
|
||||||
|
selectedLeftIds.value.push(...newIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加选中的模型到右侧
|
||||||
|
function addSelected() {
|
||||||
|
for (const name of selectedLeftIds.value) {
|
||||||
|
if (!allowedModels.value.includes(name)) {
|
||||||
|
allowedModels.value.push(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectedLeftIds.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从右侧移除选中的模型
|
||||||
|
function removeSelected() {
|
||||||
|
allowedModels.value = allowedModels.value.filter(
|
||||||
|
name => !selectedRightIds.value.includes(name)
|
||||||
|
)
|
||||||
|
selectedRightIds.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听对话框打开
|
||||||
|
watch(() => props.open, async (open) => {
|
||||||
|
if (open && props.apiKey) {
|
||||||
|
// 重置取消标志
|
||||||
|
loadingCancelled = false
|
||||||
|
|
||||||
|
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
|
||||||
|
allowedModels.value = [...parsed]
|
||||||
|
initialAllowedModels.value = [...parsed]
|
||||||
|
selectedLeftIds.value = []
|
||||||
|
selectedRightIds.value = []
|
||||||
|
searchQuery.value = ''
|
||||||
|
upstreamModels.value = []
|
||||||
|
upstreamModelsLoaded.value = false
|
||||||
|
collapsedGroups.value = new Set()
|
||||||
|
|
||||||
|
await loadGlobalModels()
|
||||||
|
} else {
|
||||||
|
// dialog 关闭时设置取消标志
|
||||||
|
loadingCancelled = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载时取消所有异步操作
|
||||||
|
onUnmounted(() => {
|
||||||
|
loadingCancelled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleDialogUpdate(value: boolean) {
|
||||||
|
if (!value) emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!props.apiKey) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// 空列表 = null(允许全部)
|
||||||
|
const newAllowed: AllowedModels = allowedModels.value.length > 0
|
||||||
|
? [...allowedModels.value]
|
||||||
|
: null
|
||||||
|
|
||||||
|
await updateProviderKey(props.apiKey.id, { allowed_models: newAllowed })
|
||||||
|
success('模型权限已更新', '成功')
|
||||||
|
emit('saved')
|
||||||
|
emit('close')
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(parseApiError(err, '保存失败'), '错误')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,22 +2,18 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
:model-value="isOpen"
|
:model-value="isOpen"
|
||||||
:title="isEditMode ? '编辑密钥' : '添加密钥'"
|
:title="isEditMode ? '编辑密钥' : '添加密钥'"
|
||||||
:description="isEditMode ? '修改 API 密钥配置' : '为端点添加新的 API 密钥'"
|
:description="isEditMode ? '修改 API 密钥配置' : '为提供商添加新的 API 密钥'"
|
||||||
:icon="isEditMode ? SquarePen : Key"
|
:icon="isEditMode ? SquarePen : Key"
|
||||||
size="2xl"
|
size="2xl"
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="space-y-5"
|
class="space-y-4"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@submit.prevent="handleSave"
|
@submit.prevent="handleSave"
|
||||||
>
|
>
|
||||||
<!-- 基本信息 -->
|
<!-- 基本信息 -->
|
||||||
<div class="space-y-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<h3 class="text-sm font-medium border-b pb-2">
|
|
||||||
基本信息
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<Label :for="keyNameInputId">密钥名称 *</Label>
|
<Label :for="keyNameInputId">密钥名称 *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -36,23 +32,6 @@
|
|||||||
data-1p-ignore="true"
|
data-1p-ignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Label for="rate_multiplier">成本倍率 *</Label>
|
|
||||||
<Input
|
|
||||||
id="rate_multiplier"
|
|
||||||
v-model.number="form.rate_multiplier"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0.01"
|
|
||||||
required
|
|
||||||
placeholder="1.0"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
|
||||||
真实成本 = 表面成本 × 倍率
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label :for="apiKeyInputId">API 密钥 {{ editingKey ? '' : '*' }}</Label>
|
<Label :for="apiKeyInputId">API 密钥 {{ editingKey ? '' : '*' }}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -83,10 +62,12 @@
|
|||||||
v-else-if="editingKey"
|
v-else-if="editingKey"
|
||||||
class="text-xs text-muted-foreground mt-1"
|
class="text-xs text-muted-foreground mt-1"
|
||||||
>
|
>
|
||||||
留空表示不修改,输入新值则覆盖
|
留空表示不修改
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 备注 -->
|
||||||
<div>
|
<div>
|
||||||
<Label for="note">备注</Label>
|
<Label for="note">备注</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -95,98 +76,115 @@
|
|||||||
placeholder="可选的备注信息"
|
placeholder="可选的备注信息"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API 格式选择 -->
|
||||||
|
<div v-if="sortedApiFormats.length > 0">
|
||||||
|
<Label class="mb-1.5 block">支持的 API 格式 *</Label>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="format in sortedApiFormats"
|
||||||
|
:key="format"
|
||||||
|
class="flex items-center justify-between rounded-md border px-2 py-1.5 transition-colors cursor-pointer"
|
||||||
|
:class="form.api_formats.includes(format)
|
||||||
|
? 'bg-primary/5 border-primary/30'
|
||||||
|
: 'bg-muted/30 border-border hover:border-muted-foreground/30'"
|
||||||
|
@click="toggleApiFormat(format)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span
|
||||||
|
class="w-4 h-4 rounded border flex items-center justify-center text-xs shrink-0"
|
||||||
|
:class="form.api_formats.includes(format)
|
||||||
|
? 'bg-primary border-primary text-primary-foreground'
|
||||||
|
: 'border-muted-foreground/30'"
|
||||||
|
>
|
||||||
|
<span v-if="form.api_formats.includes(format)">✓</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-sm whitespace-nowrap"
|
||||||
|
:class="form.api_formats.includes(format) ? 'text-primary' : 'text-muted-foreground'"
|
||||||
|
>{{ API_FORMAT_LABELS[format] || format }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center shrink-0 ml-2 text-xs text-muted-foreground gap-1"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<span>×</span>
|
||||||
|
<input
|
||||||
|
:value="form.rate_multipliers[format] ?? ''"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
placeholder="1"
|
||||||
|
class="w-9 bg-transparent text-right outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
:class="form.api_formats.includes(format) ? 'text-primary' : 'text-muted-foreground'"
|
||||||
|
title="成本倍率"
|
||||||
|
@input="(e) => updateRateMultiplier(format, (e.target as HTMLInputElement).value)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 调度与限流 -->
|
<!-- 配置项 -->
|
||||||
<div class="space-y-3">
|
<div class="grid grid-cols-4 gap-3">
|
||||||
<h3 class="text-sm font-medium border-b pb-2">
|
|
||||||
调度与限流
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<Label for="internal_priority">内部优先级</Label>
|
<Label
|
||||||
|
for="internal_priority"
|
||||||
|
class="text-xs"
|
||||||
|
>优先级</Label>
|
||||||
<Input
|
<Input
|
||||||
id="internal_priority"
|
id="internal_priority"
|
||||||
v-model.number="form.internal_priority"
|
v-model.number="form.internal_priority"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
class="h-8"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
<p class="text-xs text-muted-foreground mt-0.5">
|
||||||
数字越小越优先
|
越小越优先
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label for="max_concurrent">最大并发</Label>
|
<Label
|
||||||
|
for="rpm_limit"
|
||||||
|
class="text-xs"
|
||||||
|
>RPM 限制</Label>
|
||||||
<Input
|
<Input
|
||||||
id="max_concurrent"
|
id="rpm_limit"
|
||||||
:model-value="form.max_concurrent ?? ''"
|
:model-value="form.rpm_limit ?? ''"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
placeholder="留空启用自适应"
|
max="10000"
|
||||||
@update:model-value="(v) => form.max_concurrent = parseNumberInput(v)"
|
placeholder="自适应"
|
||||||
|
class="h-8"
|
||||||
|
@update:model-value="(v) => form.rpm_limit = parseNullableNumberInput(v, { min: 1, max: 10000 })"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
<p class="text-xs text-muted-foreground mt-0.5">
|
||||||
留空 = 自适应模式
|
留空自适应
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<Label for="rate_limit">速率限制(/分钟)</Label>
|
<Label
|
||||||
<Input
|
for="cache_ttl_minutes"
|
||||||
id="rate_limit"
|
class="text-xs"
|
||||||
:model-value="form.rate_limit ?? ''"
|
>缓存 TTL</Label>
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label for="daily_limit">每日限制</Label>
|
|
||||||
<Input
|
|
||||||
id="daily_limit"
|
|
||||||
:model-value="form.daily_limit ?? ''"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
@update:model-value="(v) => form.daily_limit = parseNumberInput(v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label for="monthly_limit">每月限制</Label>
|
|
||||||
<Input
|
|
||||||
id="monthly_limit"
|
|
||||||
:model-value="form.monthly_limit ?? ''"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
@update:model-value="(v) => form.monthly_limit = parseNumberInput(v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 缓存与熔断 -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<h3 class="text-sm font-medium border-b pb-2">
|
|
||||||
缓存与熔断
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label for="cache_ttl_minutes">缓存 TTL (分钟)</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="cache_ttl_minutes"
|
id="cache_ttl_minutes"
|
||||||
:model-value="form.cache_ttl_minutes ?? ''"
|
:model-value="form.cache_ttl_minutes ?? ''"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="60"
|
max="60"
|
||||||
|
class="h-8"
|
||||||
@update:model-value="(v) => form.cache_ttl_minutes = parseNumberInput(v, { min: 0, max: 60 }) ?? 5"
|
@update:model-value="(v) => form.cache_ttl_minutes = parseNumberInput(v, { min: 0, max: 60 }) ?? 5"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
<p class="text-xs text-muted-foreground mt-0.5">
|
||||||
0 = 禁用缓存亲和性
|
分钟,0禁用
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label for="max_probe_interval_minutes">熔断探测间隔 (分钟)</Label>
|
<Label
|
||||||
|
for="max_probe_interval_minutes"
|
||||||
|
class="text-xs"
|
||||||
|
>熔断探测</Label>
|
||||||
<Input
|
<Input
|
||||||
id="max_probe_interval_minutes"
|
id="max_probe_interval_minutes"
|
||||||
:model-value="form.max_probe_interval_minutes ?? ''"
|
:model-value="form.max_probe_interval_minutes ?? ''"
|
||||||
@@ -194,37 +192,31 @@
|
|||||||
min="2"
|
min="2"
|
||||||
max="32"
|
max="32"
|
||||||
placeholder="32"
|
placeholder="32"
|
||||||
|
class="h-8"
|
||||||
@update:model-value="(v) => form.max_probe_interval_minutes = parseNumberInput(v, { min: 2, max: 32 }) ?? 32"
|
@update:model-value="(v) => form.max_probe_interval_minutes = parseNumberInput(v, { min: 2, max: 32 }) ?? 32"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
<p class="text-xs text-muted-foreground mt-0.5">
|
||||||
范围 2-32 分钟
|
分钟,2-32
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 能力标签配置 -->
|
<!-- 能力标签 -->
|
||||||
<div
|
<div v-if="availableCapabilities.length > 0">
|
||||||
v-if="availableCapabilities.length > 0"
|
<Label class="text-xs mb-1.5 block">能力标签</Label>
|
||||||
class="space-y-3"
|
<div class="flex flex-wrap gap-1.5">
|
||||||
>
|
<button
|
||||||
<h3 class="text-sm font-medium border-b pb-2">
|
|
||||||
能力标签
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<label
|
|
||||||
v-for="cap in availableCapabilities"
|
v-for="cap in availableCapabilities"
|
||||||
:key="cap.name"
|
:key="cap.name"
|
||||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-sm transition-colors"
|
||||||
|
:class="form.capabilities[cap.name]
|
||||||
|
? 'bg-primary/10 border-primary/50 text-primary'
|
||||||
|
: 'bg-card border-border hover:bg-muted/50 text-muted-foreground'"
|
||||||
|
@click="form.capabilities[cap.name] = !form.capabilities[cap.name]"
|
||||||
>
|
>
|
||||||
<input
|
{{ cap.display_name }}
|
||||||
type="checkbox"
|
</button>
|
||||||
:checked="form.capabilities[cap.name] || false"
|
|
||||||
class="rounded"
|
|
||||||
@change="form.capabilities[cap.name] = !form.capabilities[cap.name]"
|
|
||||||
>
|
|
||||||
<span>{{ cap.display_name }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -240,25 +232,27 @@
|
|||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
>
|
>
|
||||||
{{ saving ? '保存中...' : '保存' }}
|
{{ saving ? (isEditMode ? '保存中...' : '添加中...') : (isEditMode ? '保存' : '添加') }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { Dialog, Button, Input, Label } from '@/components/ui'
|
import { Dialog, Button, Input, Label } from '@/components/ui'
|
||||||
import { Key, SquarePen } from 'lucide-vue-next'
|
import { Key, SquarePen } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useFormDialog } from '@/composables/useFormDialog'
|
import { useFormDialog } from '@/composables/useFormDialog'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { parseNumberInput } from '@/utils/form'
|
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import {
|
import {
|
||||||
addEndpointKey,
|
addProviderKey,
|
||||||
updateEndpointKey,
|
updateProviderKey,
|
||||||
getAllCapabilities,
|
getAllCapabilities,
|
||||||
|
API_FORMAT_LABELS,
|
||||||
|
sortApiFormats,
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type EndpointAPIKeyUpdate,
|
type EndpointAPIKeyUpdate,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
@@ -270,6 +264,7 @@ const props = defineProps<{
|
|||||||
endpoint: ProviderEndpoint | null
|
endpoint: ProviderEndpoint | null
|
||||||
editingKey: EndpointAPIKey | null
|
editingKey: EndpointAPIKey | null
|
||||||
providerId: string | null
|
providerId: string | null
|
||||||
|
availableApiFormats: string[] // Provider 支持的所有 API 格式
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -279,6 +274,9 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
|
||||||
|
// 排序后的可用 API 格式列表
|
||||||
|
const sortedApiFormats = computed(() => sortApiFormats(props.availableApiFormats))
|
||||||
|
|
||||||
const isOpen = computed(() => props.open)
|
const isOpen = computed(() => props.open)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const formNonce = ref(createFieldNonce())
|
const formNonce = ref(createFieldNonce())
|
||||||
@@ -297,12 +295,10 @@ const availableCapabilities = ref<CapabilityDefinition[]>([])
|
|||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '',
|
||||||
api_key: '',
|
api_key: '',
|
||||||
rate_multiplier: 1.0,
|
api_formats: [] as string[], // 支持的 API 格式列表
|
||||||
internal_priority: 50,
|
rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率
|
||||||
max_concurrent: undefined as number | undefined,
|
internal_priority: 10,
|
||||||
rate_limit: undefined as number | undefined,
|
rpm_limit: undefined as number | null | undefined, // RPM 限制(null=自适应,undefined=保持原值)
|
||||||
daily_limit: undefined as number | undefined,
|
|
||||||
monthly_limit: undefined as number | undefined,
|
|
||||||
cache_ttl_minutes: 5,
|
cache_ttl_minutes: 5,
|
||||||
max_probe_interval_minutes: 32,
|
max_probe_interval_minutes: 32,
|
||||||
note: '',
|
note: '',
|
||||||
@@ -323,6 +319,43 @@ onMounted(() => {
|
|||||||
loadCapabilities()
|
loadCapabilities()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// API 格式切换
|
||||||
|
function toggleApiFormat(format: string) {
|
||||||
|
const index = form.value.api_formats.indexOf(format)
|
||||||
|
if (index === -1) {
|
||||||
|
// 添加格式
|
||||||
|
form.value.api_formats.push(format)
|
||||||
|
} else {
|
||||||
|
// 移除格式前检查:至少保留一个格式
|
||||||
|
if (form.value.api_formats.length <= 1) {
|
||||||
|
showError('至少需要选择一个 API 格式', '验证失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 移除格式,但保留倍率配置(用户可能只是临时取消)
|
||||||
|
form.value.api_formats.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新指定格式的成本倍率
|
||||||
|
function updateRateMultiplier(format: string, value: string | number) {
|
||||||
|
// 使用对象替换以确保 Vue 3 响应性
|
||||||
|
const newMultipliers = { ...form.value.rate_multipliers }
|
||||||
|
|
||||||
|
if (value === '' || value === null || value === undefined) {
|
||||||
|
// 清空时删除该格式的配置(使用默认倍率)
|
||||||
|
delete newMultipliers[format]
|
||||||
|
} else {
|
||||||
|
const numValue = typeof value === 'string' ? parseFloat(value) : value
|
||||||
|
// 限制倍率范围:0.01 - 100
|
||||||
|
if (!isNaN(numValue) && numValue >= 0.01 && numValue <= 100) {
|
||||||
|
newMultipliers[format] = numValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换整个对象以触发响应式更新
|
||||||
|
form.value.rate_multipliers = newMultipliers
|
||||||
|
}
|
||||||
|
|
||||||
// API 密钥输入框样式计算
|
// API 密钥输入框样式计算
|
||||||
function getApiKeyInputClass(): string {
|
function getApiKeyInputClass(): string {
|
||||||
const classes = []
|
const classes = []
|
||||||
@@ -349,8 +382,8 @@ const apiKeyError = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果输入了值,检查长度
|
// 如果输入了值,检查长度
|
||||||
if (apiKey.length < 10) {
|
if (apiKey.length < 3) {
|
||||||
return 'API 密钥至少需要 10 个字符'
|
return 'API 密钥至少需要 3 个字符'
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
@@ -363,12 +396,10 @@ function resetForm() {
|
|||||||
form.value = {
|
form.value = {
|
||||||
name: '',
|
name: '',
|
||||||
api_key: '',
|
api_key: '',
|
||||||
rate_multiplier: 1.0,
|
api_formats: [], // 默认不选中任何格式
|
||||||
internal_priority: 50,
|
rate_multipliers: {},
|
||||||
max_concurrent: undefined,
|
internal_priority: 10,
|
||||||
rate_limit: undefined,
|
rpm_limit: undefined,
|
||||||
daily_limit: undefined,
|
|
||||||
monthly_limit: undefined,
|
|
||||||
cache_ttl_minutes: 5,
|
cache_ttl_minutes: 5,
|
||||||
max_probe_interval_minutes: 32,
|
max_probe_interval_minutes: 32,
|
||||||
note: '',
|
note: '',
|
||||||
@@ -377,6 +408,14 @@ function resetForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加成功后清除部分字段以便继续添加
|
||||||
|
function clearForNextAdd() {
|
||||||
|
formNonce.value = createFieldNonce()
|
||||||
|
apiKeyFocused.value = false
|
||||||
|
form.value.name = ''
|
||||||
|
form.value.api_key = ''
|
||||||
|
}
|
||||||
|
|
||||||
// 加载密钥数据(编辑模式)
|
// 加载密钥数据(编辑模式)
|
||||||
function loadKeyData() {
|
function loadKeyData() {
|
||||||
if (!props.editingKey) return
|
if (!props.editingKey) return
|
||||||
@@ -385,13 +424,13 @@ function loadKeyData() {
|
|||||||
form.value = {
|
form.value = {
|
||||||
name: props.editingKey.name,
|
name: props.editingKey.name,
|
||||||
api_key: '',
|
api_key: '',
|
||||||
rate_multiplier: props.editingKey.rate_multiplier || 1.0,
|
api_formats: props.editingKey.api_formats?.length > 0
|
||||||
internal_priority: props.editingKey.internal_priority ?? 50,
|
? [...props.editingKey.api_formats]
|
||||||
|
: [], // 编辑模式下保持原有选择,不默认全选
|
||||||
|
rate_multipliers: { ...(props.editingKey.rate_multipliers || {}) },
|
||||||
|
internal_priority: props.editingKey.internal_priority ?? 10,
|
||||||
// 保留原始的 null/undefined 状态,null 表示自适应模式
|
// 保留原始的 null/undefined 状态,null 表示自适应模式
|
||||||
max_concurrent: props.editingKey.max_concurrent ?? undefined,
|
rpm_limit: props.editingKey.rpm_limit ?? undefined,
|
||||||
rate_limit: props.editingKey.rate_limit ?? undefined,
|
|
||||||
daily_limit: props.editingKey.daily_limit ?? undefined,
|
|
||||||
monthly_limit: props.editingKey.monthly_limit ?? undefined,
|
|
||||||
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
|
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
|
||||||
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
|
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
|
||||||
note: props.editingKey.note || '',
|
note: props.editingKey.note || '',
|
||||||
@@ -415,7 +454,11 @@ function createFieldNonce(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!props.endpoint) return
|
// 必须有 providerId
|
||||||
|
if (!props.providerId) {
|
||||||
|
showError('无法保存:缺少提供商信息', '错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 提交前验证
|
// 提交前验证
|
||||||
if (apiKeyError.value) {
|
if (apiKeyError.value) {
|
||||||
@@ -429,6 +472,12 @@ async function handleSave() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证至少选择一个 API 格式
|
||||||
|
if (form.value.api_formats.length === 0) {
|
||||||
|
showError('请至少选择一个 API 格式', '验证失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 过滤出有效的能力配置(只包含值为 true 的)
|
// 过滤出有效的能力配置(只包含值为 true 的)
|
||||||
const activeCapabilities: Record<string, boolean> = {}
|
const activeCapabilities: Record<string, boolean> = {}
|
||||||
for (const [key, value] of Object.entries(form.value.capabilities)) {
|
for (const [key, value] of Object.entries(form.value.capabilities)) {
|
||||||
@@ -440,21 +489,27 @@ async function handleSave() {
|
|||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
// 准备 rate_multipliers 数据:只保留已选中格式的倍率配置
|
||||||
|
const filteredMultipliers: Record<string, number> = {}
|
||||||
|
for (const format of form.value.api_formats) {
|
||||||
|
if (form.value.rate_multipliers[format] !== undefined) {
|
||||||
|
filteredMultipliers[format] = form.value.rate_multipliers[format]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rateMultipliersData = Object.keys(filteredMultipliers).length > 0
|
||||||
|
? filteredMultipliers
|
||||||
|
: null
|
||||||
|
|
||||||
if (props.editingKey) {
|
if (props.editingKey) {
|
||||||
// 更新模式
|
// 更新模式
|
||||||
// 注意:max_concurrent 需要显式发送 null 来切换到自适应模式
|
// 注意:rpm_limit 使用 null 表示自适应模式
|
||||||
// undefined 会在 JSON 中被忽略,所以用 null 表示"清空/自适应"
|
// undefined 表示"保持原值不变"(会在 JSON 序列化时被忽略)
|
||||||
const updateData: EndpointAPIKeyUpdate = {
|
const updateData: EndpointAPIKeyUpdate = {
|
||||||
|
api_formats: form.value.api_formats,
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
rate_multiplier: form.value.rate_multiplier,
|
rate_multipliers: rateMultipliersData,
|
||||||
internal_priority: form.value.internal_priority,
|
internal_priority: form.value.internal_priority,
|
||||||
// 显式使用 null 表示自适应模式,这样后端能区分"未提供"和"设置为 null"
|
rpm_limit: form.value.rpm_limit,
|
||||||
// 注意:只有 max_concurrent 需要这种处理,因为它有"自适应模式"的概念
|
|
||||||
// 其他限制字段(rate_limit 等)不支持"清空"操作,undefined 会被 JSON 忽略即不更新
|
|
||||||
max_concurrent: form.value.max_concurrent === undefined ? null : form.value.max_concurrent,
|
|
||||||
rate_limit: form.value.rate_limit,
|
|
||||||
daily_limit: form.value.daily_limit,
|
|
||||||
monthly_limit: form.value.monthly_limit,
|
|
||||||
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
||||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||||
note: form.value.note,
|
note: form.value.note,
|
||||||
@@ -466,26 +521,27 @@ async function handleSave() {
|
|||||||
updateData.api_key = form.value.api_key
|
updateData.api_key = form.value.api_key
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateEndpointKey(props.editingKey.id, updateData)
|
await updateProviderKey(props.editingKey.id, updateData)
|
||||||
success('密钥已更新', '成功')
|
success('密钥已更新', '成功')
|
||||||
} else {
|
} else {
|
||||||
// 新增
|
// 新增模式
|
||||||
await addEndpointKey(props.endpoint.id, {
|
await addProviderKey(props.providerId, {
|
||||||
endpoint_id: props.endpoint.id,
|
api_formats: form.value.api_formats,
|
||||||
api_key: form.value.api_key,
|
api_key: form.value.api_key,
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
rate_multiplier: form.value.rate_multiplier,
|
rate_multipliers: rateMultipliersData,
|
||||||
internal_priority: form.value.internal_priority,
|
internal_priority: form.value.internal_priority,
|
||||||
max_concurrent: form.value.max_concurrent,
|
rpm_limit: form.value.rpm_limit,
|
||||||
rate_limit: form.value.rate_limit,
|
|
||||||
daily_limit: form.value.daily_limit,
|
|
||||||
monthly_limit: form.value.monthly_limit,
|
|
||||||
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
||||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||||
note: form.value.note,
|
note: form.value.note,
|
||||||
capabilities: capabilitiesData || undefined
|
capabilities: capabilitiesData || undefined
|
||||||
})
|
})
|
||||||
success('密钥已添加', '成功')
|
success('密钥已添加', '成功')
|
||||||
|
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
|
||||||
|
emit('saved')
|
||||||
|
clearForNextAdd()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('saved')
|
emit('saved')
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
|
|
||||||
<!-- 提供商信息 -->
|
<!-- 提供商信息 -->
|
||||||
<div class="flex-1 min-w-0 flex items-center gap-2">
|
<div class="flex-1 min-w-0 flex items-center gap-2">
|
||||||
<span class="font-medium text-sm truncate">{{ provider.display_name }}</span>
|
<span class="font-medium text-sm truncate">{{ provider.name }}</span>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="!provider.is_active"
|
v-if="!provider.is_active"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -262,17 +262,17 @@
|
|||||||
<div class="shrink-0 flex items-center gap-3">
|
<div class="shrink-0 flex items-center gap-3">
|
||||||
<!-- 健康度 -->
|
<!-- 健康度 -->
|
||||||
<div
|
<div
|
||||||
v-if="key.success_rate !== null"
|
v-if="key.health_score != null"
|
||||||
class="text-xs text-right"
|
class="text-xs text-right"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="font-medium tabular-nums"
|
class="font-medium tabular-nums"
|
||||||
:class="[
|
:class="[
|
||||||
key.success_rate >= 0.95 ? 'text-green-600' :
|
key.health_score >= 0.95 ? 'text-green-600' :
|
||||||
key.success_rate >= 0.8 ? 'text-yellow-600' : 'text-red-500'
|
key.health_score >= 0.5 ? 'text-yellow-600' : 'text-red-500'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ (key.success_rate * 100).toFixed(0) }}%
|
{{ ((key.health_score || 0) * 100).toFixed(0) }}%
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-muted-foreground opacity-70">
|
<div class="text-[10px] text-muted-foreground opacity-70">
|
||||||
{{ key.request_count }} reqs
|
{{ key.request_count }} reqs
|
||||||
@@ -319,19 +319,6 @@
|
|||||||
<div class="flex items-center gap-2 pl-4 border-l border-border">
|
<div class="flex items-center gap-2 pl-4 border-l border-border">
|
||||||
<span class="text-xs text-muted-foreground">调度:</span>
|
<span class="text-xs text-muted-foreground">调度:</span>
|
||||||
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md">
|
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
|
||||||
:class="[
|
|
||||||
schedulingMode === 'fixed_order'
|
|
||||||
? 'bg-primary text-primary-foreground shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
||||||
]"
|
|
||||||
title="严格按优先级顺序,不考虑缓存"
|
|
||||||
@click="schedulingMode = 'fixed_order'"
|
|
||||||
>
|
|
||||||
固定顺序
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
||||||
@@ -345,6 +332,32 @@
|
|||||||
>
|
>
|
||||||
缓存亲和
|
缓存亲和
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
||||||
|
:class="[
|
||||||
|
schedulingMode === 'load_balance'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||||
|
]"
|
||||||
|
title="同优先级内随机轮换,不考虑缓存"
|
||||||
|
@click="schedulingMode = 'load_balance'"
|
||||||
|
>
|
||||||
|
负载均衡
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
||||||
|
:class="[
|
||||||
|
schedulingMode === 'fixed_order'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||||
|
]"
|
||||||
|
title="严格按优先级顺序,不考虑缓存"
|
||||||
|
@click="schedulingMode = 'fixed_order'"
|
||||||
|
>
|
||||||
|
固定顺序
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +395,7 @@ import { Dialog } from '@/components/ui'
|
|||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { updateProvider, updateEndpointKey } from '@/api/endpoints'
|
import { updateProvider, updateProviderKey } from '@/api/endpoints'
|
||||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
|
|
||||||
@@ -400,6 +413,7 @@ interface KeyWithMeta {
|
|||||||
endpoint_base_url: string
|
endpoint_base_url: string
|
||||||
api_format: string
|
api_format: string
|
||||||
capabilities: string[]
|
capabilities: string[]
|
||||||
|
health_score: number | null
|
||||||
success_rate: number | null
|
success_rate: number | null
|
||||||
avg_response_time_ms: number | null
|
avg_response_time_ms: number | null
|
||||||
request_count: number
|
request_count: number
|
||||||
@@ -444,7 +458,7 @@ const saving = ref(false)
|
|||||||
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
|
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
|
||||||
|
|
||||||
// 调度模式状态
|
// 调度模式状态
|
||||||
const schedulingMode = ref<'fixed_order' | 'cache_affinity'>('cache_affinity')
|
const schedulingMode = ref<'fixed_order' | 'load_balance' | 'cache_affinity'>('cache_affinity')
|
||||||
|
|
||||||
// 可用的 API 格式
|
// 可用的 API 格式
|
||||||
const availableFormats = computed(() => {
|
const availableFormats = computed(() => {
|
||||||
@@ -477,7 +491,11 @@ async function loadCurrentPriorityMode() {
|
|||||||
activeMainTab.value = currentMode === 'global_key' ? 'key' : 'provider'
|
activeMainTab.value = currentMode === 'global_key' ? 'key' : 'provider'
|
||||||
|
|
||||||
const currentSchedulingMode = schedulingResponse.value || 'cache_affinity'
|
const currentSchedulingMode = schedulingResponse.value || 'cache_affinity'
|
||||||
schedulingMode.value = currentSchedulingMode === 'fixed_order' ? 'fixed_order' : 'cache_affinity'
|
if (currentSchedulingMode === 'fixed_order' || currentSchedulingMode === 'load_balance' || currentSchedulingMode === 'cache_affinity') {
|
||||||
|
schedulingMode.value = currentSchedulingMode
|
||||||
|
} else {
|
||||||
|
schedulingMode.value = 'cache_affinity'
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
activeMainTab.value = 'provider'
|
activeMainTab.value = 'provider'
|
||||||
schedulingMode.value = 'cache_affinity'
|
schedulingMode.value = 'cache_affinity'
|
||||||
@@ -678,7 +696,7 @@ async function save() {
|
|||||||
const keys = keysByFormat.value[format]
|
const keys = keysByFormat.value[format]
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
// 使用用户设置的 priority 值,相同 priority 会做负载均衡
|
// 使用用户设置的 priority 值,相同 priority 会做负载均衡
|
||||||
keyUpdates.push(updateEndpointKey(key.id, { global_priority: key.priority }))
|
keyUpdates.push(updateProviderKey(key.id, { global_priority: key.priority }))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,12 @@
|
|||||||
|
|
||||||
<template v-else-if="provider">
|
<template v-else-if="provider">
|
||||||
<!-- 头部:名称 + 快捷操作 -->
|
<!-- 头部:名称 + 快捷操作 -->
|
||||||
<div class="sticky top-0 z-10 bg-background border-b p-4 sm:p-6">
|
<div class="sticky top-0 z-10 bg-background border-b px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-3">
|
||||||
<div class="flex items-start justify-between gap-3 sm:gap-4">
|
<div class="flex items-start justify-between gap-3 sm:gap-4">
|
||||||
<div class="space-y-1 flex-1 min-w-0">
|
<div class="space-y-1 flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h2 class="text-lg sm:text-xl font-bold truncate">
|
<h2 class="text-lg sm:text-xl font-bold truncate">
|
||||||
{{ provider.display_name }}
|
{{ provider.name }}
|
||||||
</h2>
|
</h2>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="provider.is_active ? 'default' : 'secondary'"
|
:variant="provider.is_active ? 'default' : 'secondary'"
|
||||||
@@ -39,9 +39,11 @@
|
|||||||
{{ provider.is_active ? '活跃' : '已停用' }}
|
{{ provider.is_active ? '活跃' : '已停用' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<!-- 网站链接 -->
|
||||||
<span class="text-sm text-muted-foreground font-mono">{{ provider.name }}</span>
|
<div
|
||||||
<template v-if="provider.website">
|
v-if="provider.website"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
<span class="text-muted-foreground">·</span>
|
<span class="text-muted-foreground">·</span>
|
||||||
<a
|
<a
|
||||||
:href="provider.website"
|
:href="provider.website"
|
||||||
@@ -49,10 +51,7 @@
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-xs text-primary hover:underline truncate"
|
class="text-xs text-primary hover:underline truncate"
|
||||||
title="访问官网"
|
title="访问官网"
|
||||||
>
|
>{{ provider.website }}</a>
|
||||||
{{ provider.website }}
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
@@ -82,6 +81,22 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 端点 API 格式 -->
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap mt-3">
|
||||||
|
<template v-for="endpoint in endpoints" :key="endpoint.id">
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-md border border-border bg-background hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors font-medium"
|
||||||
|
:class="{ 'opacity-40': !endpoint.is_active }"
|
||||||
|
:title="`编辑 ${API_FORMAT_LABELS[endpoint.api_format]} 端点`"
|
||||||
|
@click="handleEditEndpoint(endpoint)"
|
||||||
|
>{{ API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format }}</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-md border border-dashed border-border hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors text-muted-foreground"
|
||||||
|
title="编辑端点"
|
||||||
|
@click="showAddEndpointDialog"
|
||||||
|
>编辑</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6 p-4 sm:p-6">
|
<div class="space-y-6 p-4 sm:p-6">
|
||||||
@@ -127,241 +142,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 端点与密钥管理 -->
|
<!-- 密钥管理 -->
|
||||||
<Card class="overflow-hidden">
|
<Card class="overflow-hidden">
|
||||||
<div class="p-4 border-b border-border/60">
|
<div class="p-4 border-b border-border/60">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
<h3 class="text-sm font-semibold">
|
||||||
<span>端点与密钥管理</span>
|
密钥管理
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="endpoints.length > 0"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8"
|
class="h-8"
|
||||||
@click="showAddEndpointDialog"
|
@click="handleAddKeyToFirstEndpoint"
|
||||||
>
|
>
|
||||||
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
||||||
添加端点
|
添加密钥
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 端点列表 -->
|
|
||||||
<div
|
|
||||||
v-if="endpoints.length > 0"
|
|
||||||
class="divide-y divide-border/40"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="endpoint in endpoints"
|
|
||||||
:key="endpoint.id"
|
|
||||||
class="group"
|
|
||||||
>
|
|
||||||
<!-- 端点头部 - 可点击展开/收起 -->
|
|
||||||
<div
|
|
||||||
class="p-4 hover:bg-muted/30 transition-colors cursor-pointer"
|
|
||||||
@click="toggleEndpoint(endpoint.id)"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<ChevronRight
|
|
||||||
class="w-4 h-4 text-muted-foreground transition-transform shrink-0"
|
|
||||||
:class="{ 'rotate-90': expandedEndpoints.has(endpoint.id) }"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm font-medium">{{ endpoint.api_format }}</span>
|
|
||||||
<Badge
|
|
||||||
v-if="!endpoint.is_active"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-[10px] px-1.5 py-0"
|
|
||||||
>
|
|
||||||
已停用
|
|
||||||
</Badge>
|
|
||||||
<span class="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
<Key class="w-3 h-3" />
|
|
||||||
{{ endpoint.keys?.filter((k: EndpointAPIKey) => k.is_active).length || 0 }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="endpoint.max_retries"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ endpoint.max_retries }}次重试
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="endpoint.timeout"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ endpoint.timeout }}s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5 mt-0.5">
|
|
||||||
<span class="text-xs text-muted-foreground font-mono truncate">
|
|
||||||
{{ endpoint.base_url }}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-5 w-5 shrink-0"
|
|
||||||
title="复制 Base URL"
|
|
||||||
@click.stop="copyToClipboard(endpoint.base_url)"
|
|
||||||
>
|
|
||||||
<Copy class="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
v-if="hasUnhealthyKeys(endpoint)"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 text-green-600"
|
|
||||||
title="恢复所有密钥健康状态"
|
|
||||||
:disabled="recoveringEndpointId === endpoint.id"
|
|
||||||
@click="handleRecoverAllKeys(endpoint)"
|
|
||||||
>
|
|
||||||
<Loader2
|
|
||||||
v-if="recoveringEndpointId === endpoint.id"
|
|
||||||
class="w-3.5 h-3.5 animate-spin"
|
|
||||||
/>
|
|
||||||
<RefreshCw
|
|
||||||
v-else
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
title="添加密钥"
|
|
||||||
@click="handleAddKey(endpoint)"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
title="编辑端点"
|
|
||||||
@click="handleEditEndpoint(endpoint)"
|
|
||||||
>
|
|
||||||
<Edit class="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
:disabled="togglingEndpointId === endpoint.id"
|
|
||||||
:title="endpoint.is_active ? '点击停用' : '点击启用'"
|
|
||||||
@click="toggleEndpointActive(endpoint)"
|
|
||||||
>
|
|
||||||
<Power class="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
title="删除端点"
|
|
||||||
@click="handleDeleteEndpoint(endpoint)"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 端点详情 - 可展开区域 -->
|
|
||||||
<div
|
|
||||||
v-if="expandedEndpoints.has(endpoint.id)"
|
|
||||||
class="px-4 pb-4 bg-muted/20 border-t border-border/40"
|
|
||||||
>
|
|
||||||
<div class="space-y-3 pt-3">
|
|
||||||
<!-- 端点配置信息 -->
|
|
||||||
<div
|
|
||||||
v-if="endpoint.custom_path || endpoint.rpm_limit"
|
|
||||||
class="flex flex-wrap gap-x-4 gap-y-1 text-xs"
|
|
||||||
>
|
|
||||||
<div v-if="endpoint.custom_path">
|
|
||||||
<span class="text-muted-foreground">自定义路径:</span>
|
|
||||||
<span class="ml-1 font-mono">{{ endpoint.custom_path }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="endpoint.rpm_limit">
|
|
||||||
<span class="text-muted-foreground">RPM:</span>
|
|
||||||
<span class="ml-1 font-medium">{{ endpoint.rpm_limit }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 密钥列表 -->
|
<!-- 密钥列表 -->
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
<div
|
||||||
v-if="endpoint.keys && endpoint.keys.length > 0"
|
v-if="allKeys.length > 0"
|
||||||
class="space-y-2"
|
class="divide-y divide-border/40"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="key in endpoint.keys"
|
v-for="{ key, endpoint } in allKeys"
|
||||||
:key="key.id"
|
:key="key.id"
|
||||||
draggable="true"
|
class="px-4 py-2.5 hover:bg-muted/30 transition-colors"
|
||||||
class="p-3 bg-background rounded-md border transition-all duration-150 group/key"
|
|
||||||
:class="{
|
|
||||||
'border-border/40 hover:border-border/80': dragState.targetKeyId !== key.id,
|
|
||||||
'border-primary border-2 bg-primary/5': dragState.targetKeyId === key.id,
|
|
||||||
'opacity-50': dragState.draggedKeyId === key.id,
|
|
||||||
'cursor-grabbing': dragState.isDragging
|
|
||||||
}"
|
|
||||||
@dragstart="handleDragStart($event, key, endpoint)"
|
|
||||||
@dragend="handleDragEnd"
|
|
||||||
@dragover="handleDragOver($event, key)"
|
|
||||||
@dragleave="handleDragLeave"
|
|
||||||
@drop="handleDrop($event, key, endpoint)"
|
|
||||||
>
|
>
|
||||||
<!-- 密钥主要信息行 -->
|
<!-- 第一行:名称 + 状态 + 操作按钮 -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<!-- 拖动手柄 -->
|
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
|
||||||
<div
|
<span class="text-xs font-mono text-muted-foreground">
|
||||||
class="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground"
|
{{ key.api_key_masked }}
|
||||||
title="拖动排序"
|
|
||||||
>
|
|
||||||
<GripVertical class="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-xs font-medium truncate">{{ key.name || '未命名密钥' }}</span>
|
|
||||||
<Badge
|
|
||||||
:variant="key.is_active ? 'default' : 'secondary'"
|
|
||||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
|
||||||
>
|
|
||||||
{{ key.is_active ? '活跃' : '禁用' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="text-[10px] font-mono text-muted-foreground truncate max-w-[180px]">
|
|
||||||
{{ revealedKeys.has(key.id) ? revealedKeys.get(key.id) : key.api_key_masked }}
|
|
||||||
</span>
|
</span>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-5 w-5 shrink-0"
|
|
||||||
:title="revealedKeys.has(key.id) ? '隐藏密钥' : '显示密钥'"
|
|
||||||
:disabled="revealingKeyId === key.id"
|
|
||||||
@click.stop="toggleKeyReveal(key)"
|
|
||||||
>
|
|
||||||
<Loader2
|
|
||||||
v-if="revealingKeyId === key.id"
|
|
||||||
class="w-3 h-3 animate-spin"
|
|
||||||
/>
|
|
||||||
<EyeOff
|
|
||||||
v-else-if="revealedKeys.has(key.id)"
|
|
||||||
class="w-3 h-3"
|
|
||||||
/>
|
|
||||||
<Eye
|
|
||||||
v-else
|
|
||||||
class="w-3 h-3"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -371,14 +188,36 @@
|
|||||||
>
|
>
|
||||||
<Copy class="w-3 h-3" />
|
<Copy class="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Badge
|
||||||
|
v-if="!key.is_active"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
|
>
|
||||||
|
禁用
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="key.circuit_breaker_open"
|
||||||
|
variant="destructive"
|
||||||
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
|
>
|
||||||
|
熔断
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- 并发 + 健康度 + 操作按钮 -->
|
||||||
<div class="flex items-center gap-1.5 ml-auto shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<!-- RPM 限制信息(放在最前面) -->
|
||||||
|
<span
|
||||||
|
v-if="key.rpm_limit || key.is_adaptive"
|
||||||
|
class="text-[10px] text-muted-foreground mr-1"
|
||||||
|
>
|
||||||
|
{{ key.is_adaptive ? '自适应' : key.rpm_limit }} RPM
|
||||||
|
</span>
|
||||||
|
<!-- 健康度 -->
|
||||||
<div
|
<div
|
||||||
v-if="key.health_score !== undefined"
|
v-if="key.health_score !== undefined"
|
||||||
class="flex items-center gap-1"
|
class="flex items-center gap-1 mr-1"
|
||||||
>
|
>
|
||||||
<div class="w-12 h-1 bg-muted/80 rounded-full overflow-hidden">
|
<div class="w-10 h-1.5 bg-muted/80 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="h-full transition-all duration-300"
|
class="h-full transition-all duration-300"
|
||||||
:class="getHealthScoreBarColor(key.health_score || 0)"
|
:class="getHealthScoreBarColor(key.health_score || 0)"
|
||||||
@@ -386,22 +225,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="text-[10px] font-bold tabular-nums w-[30px] text-right"
|
class="text-[10px] font-medium tabular-nums"
|
||||||
:class="getHealthScoreColor(key.health_score || 0)"
|
:class="getHealthScoreColor(key.health_score || 0)"
|
||||||
>
|
>
|
||||||
{{ ((key.health_score || 0) * 100).toFixed(0) }}%
|
{{ ((key.health_score || 0) * 100).toFixed(0) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
|
||||||
v-if="key.circuit_breaker_open"
|
|
||||||
variant="destructive"
|
|
||||||
class="text-[10px] px-1.5 py-0"
|
|
||||||
>
|
|
||||||
熔断
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 ml-2">
|
|
||||||
<Button
|
<Button
|
||||||
v-if="key.circuit_breaker_open || (key.health_score !== undefined && key.health_score < 0.5)"
|
v-if="key.circuit_breaker_open || (key.health_score !== undefined && key.health_score < 0.5)"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -410,16 +239,16 @@
|
|||||||
title="刷新健康状态"
|
title="刷新健康状态"
|
||||||
@click="handleRecoverKey(key)"
|
@click="handleRecoverKey(key)"
|
||||||
>
|
>
|
||||||
<RefreshCw class="w-3 h-3" />
|
<RefreshCw class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7"
|
||||||
title="配置允许的模型"
|
title="模型权限"
|
||||||
@click="handleConfigKeyModels(key)"
|
@click="handleKeyPermissions(key)"
|
||||||
>
|
>
|
||||||
<Layers class="w-3 h-3" />
|
<Shield class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -428,7 +257,7 @@
|
|||||||
title="编辑密钥"
|
title="编辑密钥"
|
||||||
@click="handleEditKey(endpoint, key)"
|
@click="handleEditKey(endpoint, key)"
|
||||||
>
|
>
|
||||||
<Edit class="w-3 h-3" />
|
<Edit class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -438,7 +267,7 @@
|
|||||||
:title="key.is_active ? '点击停用' : '点击启用'"
|
:title="key.is_active ? '点击停用' : '点击启用'"
|
||||||
@click="toggleKeyActive(key)"
|
@click="toggleKeyActive(key)"
|
||||||
>
|
>
|
||||||
<Power class="w-3 h-3" />
|
<Power class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -447,90 +276,46 @@
|
|||||||
title="删除密钥"
|
title="删除密钥"
|
||||||
@click="handleDeleteKey(key)"
|
@click="handleDeleteKey(key)"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3 h-3" />
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 -->
|
||||||
<!-- 密钥详细信息 -->
|
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
|
||||||
<div class="flex items-center text-[11px]">
|
<!-- 优先级放最前面,支持点击编辑 -->
|
||||||
<!-- 左侧固定信息 -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- 可点击编辑的优先级 -->
|
|
||||||
<span
|
<span
|
||||||
v-if="editingPriorityKey !== key.id"
|
v-if="editingPriorityKey !== key.id"
|
||||||
class="text-muted-foreground cursor-pointer hover:text-foreground hover:bg-muted/50 px-1 rounded transition-colors"
|
title="点击编辑优先级"
|
||||||
title="点击编辑优先级,数字越小优先级越高"
|
class="font-medium text-foreground/80 cursor-pointer hover:text-primary hover:underline"
|
||||||
@click="startEditPriority(key)"
|
@click="startEditPriority(key)"
|
||||||
>
|
>P{{ key.internal_priority }}</span>
|
||||||
P {{ key.internal_priority }}
|
|
||||||
</span>
|
|
||||||
<!-- 编辑模式 -->
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<span class="text-muted-foreground">P</span>
|
|
||||||
<input
|
<input
|
||||||
ref="priorityInput"
|
v-else
|
||||||
v-model.number="editingPriorityValue"
|
ref="priorityInputRef"
|
||||||
type="number"
|
v-model="editingPriorityValue"
|
||||||
class="w-12 h-5 px-1 text-[11px] border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
type="text"
|
||||||
min="0"
|
inputmode="numeric"
|
||||||
@keyup.enter="savePriority(key, endpoint)"
|
pattern="[0-9]*"
|
||||||
@keyup.escape="cancelEditPriority"
|
class="w-8 h-5 px-1 text-[11px] text-center border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary font-medium text-foreground/80"
|
||||||
@blur="savePriority(key, endpoint)"
|
@keydown="(e) => handlePriorityKeydown(e, key)"
|
||||||
|
@blur="handlePriorityBlur(key)"
|
||||||
>
|
>
|
||||||
</span>
|
<span class="text-muted-foreground/40">|</span>
|
||||||
<span
|
<!-- API 格式:展开显示每个格式和倍率 -->
|
||||||
class="text-muted-foreground"
|
<template
|
||||||
title="成本倍率,实际成本 = 模型价格 × 倍率"
|
v-for="(format, idx) in getKeyApiFormats(key, endpoint)"
|
||||||
|
:key="format"
|
||||||
>
|
>
|
||||||
{{ key.rate_multiplier }}x
|
<span v-if="idx > 0" class="text-muted-foreground/40">/</span>
|
||||||
</span>
|
<span>{{ API_FORMAT_SHORT[format] || format }} {{ getKeyRateMultiplier(key, format) }}x</span>
|
||||||
<span
|
</template>
|
||||||
v-if="key.success_rate !== undefined"
|
<span v-if="key.rate_limit">| {{ key.rate_limit }}rpm</span>
|
||||||
class="text-muted-foreground"
|
|
||||||
title="成功率 = 成功次数 / 总请求数"
|
|
||||||
>
|
|
||||||
{{ (key.success_rate * 100).toFixed(1) }}% ({{ key.success_count }}/{{ key.request_count }})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- 右侧动态信息 -->
|
|
||||||
<div class="flex items-center gap-2 ml-auto">
|
|
||||||
<span
|
<span
|
||||||
v-if="key.next_probe_at"
|
v-if="key.next_probe_at"
|
||||||
class="text-amber-600 dark:text-amber-400"
|
class="text-amber-600 dark:text-amber-400"
|
||||||
title="熔断器探测恢复时间"
|
|
||||||
>
|
>
|
||||||
{{ formatProbeTime(key.next_probe_at) }}探测
|
| {{ formatProbeTime(key.next_probe_at) }}探测
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
v-if="key.rate_limit"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
title="每分钟请求数限制"
|
|
||||||
>
|
|
||||||
{{ key.rate_limit }}rpm
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="key.max_concurrent || key.is_adaptive"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
:title="key.is_adaptive ? `自适应并发限制(学习值: ${key.learned_max_concurrent ?? '未学习'})` : `固定并发限制: ${key.max_concurrent}`"
|
|
||||||
>
|
|
||||||
{{ key.is_adaptive ? '自适应' : '固定' }}并发: {{ key.is_adaptive ? (key.learned_max_concurrent ?? '学习中') : key.max_concurrent }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="text-xs text-muted-foreground text-center py-4"
|
|
||||||
>
|
|
||||||
暂无密钥
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,12 +325,12 @@
|
|||||||
v-else
|
v-else
|
||||||
class="p-8 text-center text-muted-foreground"
|
class="p-8 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Server class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<Key class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
暂无端点配置
|
暂无密钥配置
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs mt-1">
|
<p class="text-xs mt-1">
|
||||||
点击上方"添加端点"按钮创建第一个端点
|
{{ endpoints.length > 0 ? '点击上方"添加密钥"按钮创建第一个密钥' : '请先添加端点,然后再添加密钥' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -575,12 +360,12 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<!-- 端点表单对话框(添加/编辑) -->
|
<!-- 端点表单对话框(管理/编辑) -->
|
||||||
<EndpointFormDialog
|
<EndpointFormDialog
|
||||||
v-if="provider && open"
|
v-if="provider && open"
|
||||||
v-model="endpointDialogOpen"
|
v-model="endpointDialogOpen"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:endpoint="endpointToEdit"
|
:endpoints="endpoints"
|
||||||
@endpoint-created="handleEndpointChanged"
|
@endpoint-created="handleEndpointChanged"
|
||||||
@endpoint-updated="handleEndpointChanged"
|
@endpoint-updated="handleEndpointChanged"
|
||||||
/>
|
/>
|
||||||
@@ -606,17 +391,18 @@
|
|||||||
:endpoint="currentEndpoint"
|
:endpoint="currentEndpoint"
|
||||||
:editing-key="editingKey"
|
:editing-key="editingKey"
|
||||||
:provider-id="provider ? provider.id : null"
|
:provider-id="provider ? provider.id : null"
|
||||||
|
:available-api-formats="provider?.api_formats || []"
|
||||||
@close="keyFormDialogOpen = false"
|
@close="keyFormDialogOpen = false"
|
||||||
@saved="handleKeyChanged"
|
@saved="handleKeyChanged"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 密钥允许模型配置对话框 -->
|
<!-- 模型权限对话框 -->
|
||||||
<KeyAllowedModelsDialog
|
<KeyAllowedModelsEditDialog
|
||||||
v-if="open"
|
v-if="open"
|
||||||
:open="keyAllowedModelsDialogOpen"
|
:open="keyPermissionsDialogOpen"
|
||||||
:api-key="editingKey"
|
:api-key="editingKey"
|
||||||
:provider-id="provider ? provider.id : null"
|
:provider-id="providerId || ''"
|
||||||
@close="keyAllowedModelsDialogOpen = false"
|
@close="keyPermissionsDialogOpen = false"
|
||||||
@saved="handleKeyChanged"
|
@saved="handleKeyChanged"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -639,7 +425,7 @@
|
|||||||
v-if="open && provider"
|
v-if="open && provider"
|
||||||
:open="modelFormDialogOpen"
|
:open="modelFormDialogOpen"
|
||||||
:provider-id="provider.id"
|
:provider-id="provider.id"
|
||||||
:provider-name="provider.display_name"
|
:provider-name="provider.name"
|
||||||
:editing-model="editingModel"
|
:editing-model="editingModel"
|
||||||
@update:open="modelFormDialogOpen = $event"
|
@update:open="modelFormDialogOpen = $event"
|
||||||
@saved="handleModelSaved"
|
@saved="handleModelSaved"
|
||||||
@@ -650,7 +436,7 @@
|
|||||||
v-if="open"
|
v-if="open"
|
||||||
:model-value="deleteModelConfirmOpen"
|
:model-value="deleteModelConfirmOpen"
|
||||||
title="移除模型支持"
|
title="移除模型支持"
|
||||||
:description="`确定要移除提供商 ${provider?.display_name} 对模型 ${modelToDelete?.global_model_display_name || modelToDelete?.provider_model_name} 的支持吗?这不会删除全局模型,只是该提供商将不再支持此模型。`"
|
:description="`确定要移除提供商 ${provider?.name} 对模型 ${modelToDelete?.global_model_display_name || modelToDelete?.provider_model_name} 的支持吗?这不会删除全局模型,只是该提供商将不再支持此模型。`"
|
||||||
confirm-text="移除"
|
confirm-text="移除"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -664,7 +450,7 @@
|
|||||||
v-if="open && provider"
|
v-if="open && provider"
|
||||||
:open="batchAssignDialogOpen"
|
:open="batchAssignDialogOpen"
|
||||||
:provider-id="provider.id"
|
:provider-id="provider.id"
|
||||||
:provider-name="provider.display_name"
|
:provider-name="provider.name"
|
||||||
:provider-identifier="provider.name"
|
:provider-identifier="provider.name"
|
||||||
@update:open="batchAssignDialogOpen = $event"
|
@update:open="batchAssignDialogOpen = $event"
|
||||||
@changed="handleBatchAssignChanged"
|
@changed="handleBatchAssignChanged"
|
||||||
@@ -672,7 +458,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed, nextTick } from 'vue'
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -684,11 +470,12 @@ import {
|
|||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
Power,
|
Power,
|
||||||
Layers,
|
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Copy,
|
Copy,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff
|
EyeOff,
|
||||||
|
ExternalLink,
|
||||||
|
Shield
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
@@ -699,7 +486,7 @@ import { useClipboard } from '@/composables/useClipboard'
|
|||||||
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||||
import {
|
import {
|
||||||
KeyFormDialog,
|
KeyFormDialog,
|
||||||
KeyAllowedModelsDialog,
|
KeyAllowedModelsEditDialog,
|
||||||
ModelsTab,
|
ModelsTab,
|
||||||
ModelAliasesTab,
|
ModelAliasesTab,
|
||||||
BatchAssignModelsDialog
|
BatchAssignModelsDialog
|
||||||
@@ -711,14 +498,17 @@ import {
|
|||||||
deleteEndpoint as deleteEndpointAPI,
|
deleteEndpoint as deleteEndpointAPI,
|
||||||
deleteEndpointKey,
|
deleteEndpointKey,
|
||||||
recoverKeyHealth,
|
recoverKeyHealth,
|
||||||
getEndpointKeys,
|
getProviderKeys,
|
||||||
updateEndpoint,
|
updateEndpoint,
|
||||||
updateEndpointKey,
|
updateProviderKey,
|
||||||
batchUpdateKeyPriority,
|
|
||||||
revealEndpointKey,
|
revealEndpointKey,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type Model
|
type Model,
|
||||||
|
API_FORMAT_LABELS,
|
||||||
|
API_FORMAT_ORDER,
|
||||||
|
API_FORMAT_SHORT,
|
||||||
|
sortApiFormats,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { deleteModel as deleteModelAPI } from '@/api/endpoints/models'
|
import { deleteModel as deleteModelAPI } from '@/api/endpoints/models'
|
||||||
|
|
||||||
@@ -747,17 +537,17 @@ const { copyToClipboard } = useClipboard()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const provider = ref<any>(null)
|
const provider = ref<any>(null)
|
||||||
const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
||||||
|
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
|
||||||
const expandedEndpoints = ref<Set<string>>(new Set())
|
const expandedEndpoints = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
// 端点相关状态
|
// 端点相关状态
|
||||||
const endpointDialogOpen = ref(false)
|
const endpointDialogOpen = ref(false)
|
||||||
const endpointToEdit = ref<ProviderEndpoint | null>(null)
|
|
||||||
const deleteEndpointConfirmOpen = ref(false)
|
const deleteEndpointConfirmOpen = ref(false)
|
||||||
const endpointToDelete = ref<ProviderEndpoint | null>(null)
|
const endpointToDelete = ref<ProviderEndpoint | null>(null)
|
||||||
|
|
||||||
// 密钥相关状态
|
// 密钥相关状态
|
||||||
const keyFormDialogOpen = ref(false)
|
const keyFormDialogOpen = ref(false)
|
||||||
const keyAllowedModelsDialogOpen = ref(false)
|
const keyPermissionsDialogOpen = ref(false)
|
||||||
const currentEndpoint = ref<ProviderEndpoint | null>(null)
|
const currentEndpoint = ref<ProviderEndpoint | null>(null)
|
||||||
const editingKey = ref<EndpointAPIKey | null>(null)
|
const editingKey = ref<EndpointAPIKey | null>(null)
|
||||||
const deleteKeyConfirmOpen = ref(false)
|
const deleteKeyConfirmOpen = ref(false)
|
||||||
@@ -791,13 +581,15 @@ const dragState = ref({
|
|||||||
// 点击编辑优先级相关状态
|
// 点击编辑优先级相关状态
|
||||||
const editingPriorityKey = ref<string | null>(null)
|
const editingPriorityKey = ref<string | null>(null)
|
||||||
const editingPriorityValue = ref<number>(0)
|
const editingPriorityValue = ref<number>(0)
|
||||||
|
const priorityInputRef = ref<HTMLInputElement[] | null>(null)
|
||||||
|
const prioritySaving = ref(false)
|
||||||
|
|
||||||
// 任意模态窗口打开时,阻止抽屉被误关闭
|
// 任意模态窗口打开时,阻止抽屉被误关闭
|
||||||
const hasBlockingDialogOpen = computed(() =>
|
const hasBlockingDialogOpen = computed(() =>
|
||||||
endpointDialogOpen.value ||
|
endpointDialogOpen.value ||
|
||||||
deleteEndpointConfirmOpen.value ||
|
deleteEndpointConfirmOpen.value ||
|
||||||
keyFormDialogOpen.value ||
|
keyFormDialogOpen.value ||
|
||||||
keyAllowedModelsDialogOpen.value ||
|
keyPermissionsDialogOpen.value ||
|
||||||
deleteKeyConfirmOpen.value ||
|
deleteKeyConfirmOpen.value ||
|
||||||
modelFormDialogOpen.value ||
|
modelFormDialogOpen.value ||
|
||||||
deleteModelConfirmOpen.value ||
|
deleteModelConfirmOpen.value ||
|
||||||
@@ -806,6 +598,36 @@ const hasBlockingDialogOpen = computed(() =>
|
|||||||
modelAliasesTabRef.value?.dialogOpen
|
modelAliasesTabRef.value?.dialogOpen
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 所有密钥的扁平列表(带端点信息)
|
||||||
|
// key 通过 api_formats 字段确定支持的格式,endpoint 可能为 undefined
|
||||||
|
const allKeys = computed(() => {
|
||||||
|
const result: { key: EndpointAPIKey; endpoint?: ProviderEndpointWithKeys }[] = []
|
||||||
|
const seenKeyIds = new Set<string>()
|
||||||
|
|
||||||
|
// 1. 先添加 Provider 级别的 keys
|
||||||
|
for (const key of providerKeys.value) {
|
||||||
|
if (!seenKeyIds.has(key.id)) {
|
||||||
|
seenKeyIds.add(key.id)
|
||||||
|
// key 没有关联特定 endpoint
|
||||||
|
result.push({ key, endpoint: undefined })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 再遍历所有端点的 keys(历史数据)
|
||||||
|
for (const endpoint of endpoints.value) {
|
||||||
|
if (endpoint.keys) {
|
||||||
|
for (const key of endpoint.keys) {
|
||||||
|
if (!seenKeyIds.has(key.id)) {
|
||||||
|
seenKeyIds.add(key.id)
|
||||||
|
result.push({ key, endpoint })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
// 监听 providerId 变化
|
// 监听 providerId 变化
|
||||||
watch(() => props.providerId, (newId) => {
|
watch(() => props.providerId, (newId) => {
|
||||||
if (newId && props.open) {
|
if (newId && props.open) {
|
||||||
@@ -823,18 +645,18 @@ watch(() => props.open, (newOpen) => {
|
|||||||
// 重置所有状态
|
// 重置所有状态
|
||||||
provider.value = null
|
provider.value = null
|
||||||
endpoints.value = []
|
endpoints.value = []
|
||||||
|
providerKeys.value = [] // 清空 Provider 级别的 keys
|
||||||
expandedEndpoints.value.clear()
|
expandedEndpoints.value.clear()
|
||||||
|
|
||||||
// 重置所有对话框状态
|
// 重置所有对话框状态
|
||||||
endpointDialogOpen.value = false
|
endpointDialogOpen.value = false
|
||||||
deleteEndpointConfirmOpen.value = false
|
deleteEndpointConfirmOpen.value = false
|
||||||
keyFormDialogOpen.value = false
|
keyFormDialogOpen.value = false
|
||||||
keyAllowedModelsDialogOpen.value = false
|
keyPermissionsDialogOpen.value = false
|
||||||
deleteKeyConfirmOpen.value = false
|
deleteKeyConfirmOpen.value = false
|
||||||
batchAssignDialogOpen.value = false
|
batchAssignDialogOpen.value = false
|
||||||
|
|
||||||
// 重置临时数据
|
// 重置临时数据
|
||||||
endpointToEdit.value = null
|
|
||||||
endpointToDelete.value = null
|
endpointToDelete.value = null
|
||||||
currentEndpoint.value = null
|
currentEndpoint.value = null
|
||||||
editingKey.value = null
|
editingKey.value = null
|
||||||
@@ -873,15 +695,14 @@ async function handleRelatedDataRefresh() {
|
|||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示添加端点对话框
|
// 显示端点管理对话框
|
||||||
function showAddEndpointDialog() {
|
function showAddEndpointDialog() {
|
||||||
endpointToEdit.value = null // 添加模式
|
|
||||||
endpointDialogOpen.value = true
|
endpointDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 端点事件处理 =====
|
// ===== 端点事件处理 =====
|
||||||
function handleEditEndpoint(endpoint: ProviderEndpoint) {
|
function handleEditEndpoint(_endpoint: ProviderEndpoint) {
|
||||||
endpointToEdit.value = endpoint // 编辑模式
|
// 点击任何端点都打开管理对话框
|
||||||
endpointDialogOpen.value = true
|
endpointDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,9 +728,8 @@ async function confirmDeleteEndpoint() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleEndpointChanged() {
|
async function handleEndpointChanged() {
|
||||||
await loadEndpoints()
|
await Promise.all([loadProvider(), loadEndpoints()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
endpointToEdit.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 密钥事件处理 =====
|
// ===== 密钥事件处理 =====
|
||||||
@@ -919,15 +739,22 @@ function handleAddKey(endpoint: ProviderEndpoint) {
|
|||||||
keyFormDialogOpen.value = true
|
keyFormDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEditKey(endpoint: ProviderEndpoint, key: EndpointAPIKey) {
|
// 添加密钥(如果有多个端点则添加到第一个)
|
||||||
currentEndpoint.value = endpoint
|
function handleAddKeyToFirstEndpoint() {
|
||||||
|
if (endpoints.value.length > 0) {
|
||||||
|
handleAddKey(endpoints.value[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditKey(endpoint: ProviderEndpoint | undefined, key: EndpointAPIKey) {
|
||||||
|
currentEndpoint.value = endpoint || null
|
||||||
editingKey.value = key
|
editingKey.value = key
|
||||||
keyFormDialogOpen.value = true
|
keyFormDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleConfigKeyModels(key: EndpointAPIKey) {
|
function handleKeyPermissions(key: EndpointAPIKey) {
|
||||||
editingKey.value = key
|
editingKey.value = key
|
||||||
keyAllowedModelsDialogOpen.value = true
|
keyPermissionsDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换密钥显示/隐藏
|
// 切换密钥显示/隐藏
|
||||||
@@ -1080,7 +907,7 @@ async function toggleKeyActive(key: EndpointAPIKey) {
|
|||||||
togglingKeyId.value = key.id
|
togglingKeyId.value = key.id
|
||||||
try {
|
try {
|
||||||
const newStatus = !key.is_active
|
const newStatus = !key.is_active
|
||||||
await updateEndpointKey(key.id, { is_active: newStatus })
|
await updateProviderKey(key.id, { is_active: newStatus })
|
||||||
key.is_active = newStatus
|
key.is_active = newStatus
|
||||||
showSuccess(newStatus ? '密钥已启用' : '密钥已停用')
|
showSuccess(newStatus ? '密钥已启用' : '密钥已停用')
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
@@ -1240,9 +1067,11 @@ async function handleDrop(event: DragEvent, targetKey: EndpointAPIKey, endpoint:
|
|||||||
|
|
||||||
handleDragEnd()
|
handleDragEnd()
|
||||||
|
|
||||||
// 调用 API 批量更新
|
// 调用 API 批量更新(使用循环调用 updateProviderKey 替代已废弃的 batchUpdateKeyPriority)
|
||||||
try {
|
try {
|
||||||
await batchUpdateKeyPriority(endpoint.id, priorities)
|
await Promise.all(
|
||||||
|
priorities.map(p => updateProviderKey(p.key_id, { internal_priority: p.internal_priority }))
|
||||||
|
)
|
||||||
showSuccess('优先级已更新')
|
showSuccess('优先级已更新')
|
||||||
// 重新加载以获取更新后的数据
|
// 重新加载以获取更新后的数据
|
||||||
await loadEndpoints()
|
await loadEndpoints()
|
||||||
@@ -1258,15 +1087,43 @@ async function handleDrop(event: DragEvent, targetKey: EndpointAPIKey, endpoint:
|
|||||||
function startEditPriority(key: EndpointAPIKey) {
|
function startEditPriority(key: EndpointAPIKey) {
|
||||||
editingPriorityKey.value = key.id
|
editingPriorityKey.value = key.id
|
||||||
editingPriorityValue.value = key.internal_priority ?? 0
|
editingPriorityValue.value = key.internal_priority ?? 0
|
||||||
|
prioritySaving.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
// v-for 中的 ref 是数组,取第一个元素
|
||||||
|
const input = Array.isArray(priorityInputRef.value) ? priorityInputRef.value[0] : priorityInputRef.value
|
||||||
|
input?.focus()
|
||||||
|
input?.select()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEditPriority() {
|
function cancelEditPriority() {
|
||||||
editingPriorityKey.value = null
|
editingPriorityKey.value = null
|
||||||
|
prioritySaving.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePriority(key: EndpointAPIKey, endpoint: ProviderEndpointWithKeys) {
|
function handlePriorityKeydown(e: KeyboardEvent, key: EndpointAPIKey) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!prioritySaving.value) {
|
||||||
|
prioritySaving.value = true
|
||||||
|
savePriority(key)
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelEditPriority()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePriorityBlur(key: EndpointAPIKey) {
|
||||||
|
// 如果已经在保存中(Enter触发),不重复保存
|
||||||
|
if (prioritySaving.value) return
|
||||||
|
savePriority(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePriority(key: EndpointAPIKey) {
|
||||||
const keyId = editingPriorityKey.value
|
const keyId = editingPriorityKey.value
|
||||||
const newPriority = editingPriorityValue.value
|
const newPriority = parseInt(String(editingPriorityValue.value), 10) || 0
|
||||||
|
|
||||||
if (!keyId || newPriority < 0) {
|
if (!keyId || newPriority < 0) {
|
||||||
cancelEditPriority()
|
cancelEditPriority()
|
||||||
@@ -1282,17 +1139,15 @@ async function savePriority(key: EndpointAPIKey, endpoint: ProviderEndpointWithK
|
|||||||
cancelEditPriority()
|
cancelEditPriority()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateEndpointKey(keyId, { internal_priority: newPriority })
|
await updateProviderKey(keyId, { internal_priority: newPriority })
|
||||||
showSuccess('优先级已更新')
|
showSuccess('优先级已更新')
|
||||||
// 更新本地数据
|
// 更新本地数据 - 更新 providerKeys 中的数据
|
||||||
if (endpoint.keys) {
|
const keyToUpdate = providerKeys.value.find(k => k.id === keyId)
|
||||||
const keyToUpdate = endpoint.keys.find(k => k.id === keyId)
|
|
||||||
if (keyToUpdate) {
|
if (keyToUpdate) {
|
||||||
keyToUpdate.internal_priority = newPriority
|
keyToUpdate.internal_priority = newPriority
|
||||||
}
|
}
|
||||||
// 重新排序
|
// 重新排序
|
||||||
endpoint.keys.sort((a, b) => (a.internal_priority ?? 0) - (b.internal_priority ?? 0))
|
providerKeys.value.sort((a, b) => (a.internal_priority ?? 0) - (b.internal_priority ?? 0))
|
||||||
}
|
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showError(err.response?.data?.detail || '更新优先级失败', '错误')
|
showError(err.response?.data?.detail || '更新优先级失败', '错误')
|
||||||
@@ -1318,6 +1173,28 @@ function formatProbeTime(probeTime: string): string {
|
|||||||
return '即将探测'
|
return '即将探测'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取密钥的 API 格式列表(按指定顺序排序)
|
||||||
|
function getKeyApiFormats(key: EndpointAPIKey, endpoint?: ProviderEndpointWithKeys): string[] {
|
||||||
|
let formats: string[] = []
|
||||||
|
if (key.api_formats && key.api_formats.length > 0) {
|
||||||
|
formats = [...key.api_formats]
|
||||||
|
} else if (endpoint) {
|
||||||
|
formats = [endpoint.api_format]
|
||||||
|
}
|
||||||
|
// 使用统一的排序函数
|
||||||
|
return sortApiFormats(formats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取密钥在指定 API 格式下的成本倍率
|
||||||
|
function getKeyRateMultiplier(key: EndpointAPIKey, format: string): number {
|
||||||
|
// 优先使用 rate_multipliers 中指定格式的倍率
|
||||||
|
if (key.rate_multipliers && key.rate_multipliers[format] !== undefined) {
|
||||||
|
return key.rate_multipliers[format]
|
||||||
|
}
|
||||||
|
// 回退到默认倍率
|
||||||
|
return key.rate_multiplier || 1.0
|
||||||
|
}
|
||||||
|
|
||||||
// 健康度颜色
|
// 健康度颜色
|
||||||
function getHealthScoreColor(score: number): string {
|
function getHealthScoreColor(score: number): string {
|
||||||
if (score >= 0.8) return 'text-green-600 dark:text-green-400'
|
if (score >= 0.8) return 'text-green-600 dark:text-green-400'
|
||||||
@@ -1354,22 +1231,22 @@ async function loadEndpoints() {
|
|||||||
if (!props.providerId) return
|
if (!props.providerId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const endpointsList = await getProviderEndpoints(props.providerId)
|
// 并行加载端点列表和 Provider 级别的 keys
|
||||||
|
const [endpointsList, providerKeysResult] = await Promise.all([
|
||||||
|
getProviderEndpoints(props.providerId),
|
||||||
|
getProviderKeys(props.providerId).catch(() => []),
|
||||||
|
])
|
||||||
|
|
||||||
// 为每个端点加载其密钥
|
providerKeys.value = providerKeysResult
|
||||||
const endpointsWithKeys = await Promise.all(
|
// 按 API 格式排序
|
||||||
endpointsList.map(async (endpoint) => {
|
endpoints.value = endpointsList.sort((a, b) => {
|
||||||
try {
|
const aIdx = API_FORMAT_ORDER.indexOf(a.api_format)
|
||||||
const keys = await getEndpointKeys(endpoint.id)
|
const bIdx = API_FORMAT_ORDER.indexOf(b.api_format)
|
||||||
return { ...endpoint, keys }
|
if (aIdx === -1 && bIdx === -1) return 0
|
||||||
} catch {
|
if (aIdx === -1) return 1
|
||||||
// 如果获取密钥失败,返回空数组
|
if (bIdx === -1) return -1
|
||||||
return { ...endpoint, keys: [] }
|
return aIdx - bIdx
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
|
||||||
endpoints.value = endpointsWithKeys
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showError(err.response?.data?.detail || '加载端点失败', '错误')
|
showError(err.response?.data?.detail || '加载端点失败', '错误')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,47 +4,29 @@
|
|||||||
:title="isEditMode ? '编辑提供商' : '添加提供商'"
|
:title="isEditMode ? '编辑提供商' : '添加提供商'"
|
||||||
:description="isEditMode ? '更新提供商配置。API 端点和密钥需在详情页面单独管理。' : '创建新的提供商配置。创建后可以为其添加 API 端点和密钥。'"
|
:description="isEditMode ? '更新提供商配置。API 端点和密钥需在详情页面单独管理。' : '创建新的提供商配置。创建后可以为其添加 API 端点和密钥。'"
|
||||||
:icon="isEditMode ? SquarePen : Server"
|
:icon="isEditMode ? SquarePen : Server"
|
||||||
size="2xl"
|
size="xl"
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="space-y-6"
|
class="space-y-5"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit"
|
||||||
>
|
>
|
||||||
<!-- 基本信息 -->
|
<!-- 基本信息 -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
<h3 class="text-sm font-medium border-b pb-2">
|
<h3 class="text-sm font-medium border-b pb-2">
|
||||||
基本信息
|
基本信息
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- 添加模式显示提供商标识 -->
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div
|
<div class="space-y-1.5">
|
||||||
v-if="!isEditMode"
|
<Label for="name">名称 *</Label>
|
||||||
class="space-y-2"
|
|
||||||
>
|
|
||||||
<Label for="name">提供商标识 *</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
placeholder="例如: openai-primary"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
唯一ID,创建后不可修改
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="display_name">显示名称 *</Label>
|
|
||||||
<Input
|
|
||||||
id="display_name"
|
|
||||||
v-model="form.display_name"
|
|
||||||
placeholder="例如: OpenAI 主账号"
|
placeholder="例如: OpenAI 主账号"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<Label for="website">主站链接</Label>
|
<Label for="website">主站链接</Label>
|
||||||
<Input
|
<Input
|
||||||
id="website"
|
id="website"
|
||||||
@@ -55,24 +37,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<Label for="description">描述</Label>
|
<Label for="description">描述</Label>
|
||||||
<Textarea
|
<Input
|
||||||
id="description"
|
id="description"
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
placeholder="提供商描述(可选)"
|
placeholder="提供商描述(可选)"
|
||||||
rows="2"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 计费与限流 -->
|
<!-- 计费与限流 / 请求配置 -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<h3 class="text-sm font-medium border-b pb-2">
|
<h3 class="text-sm font-medium border-b pb-2">
|
||||||
计费与限流
|
计费与限流
|
||||||
</h3>
|
</h3>
|
||||||
|
<h3 class="text-sm font-medium border-b pb-2">
|
||||||
|
请求配置
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<Label>计费类型</Label>
|
<Label>计费类型</Label>
|
||||||
<Select
|
<Select
|
||||||
v-model="form.billing_type"
|
v-model="form.billing_type"
|
||||||
@@ -82,81 +68,131 @@
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="monthly_quota">
|
<SelectItem value="monthly_quota">月卡额度</SelectItem>
|
||||||
月卡额度
|
<SelectItem value="pay_as_you_go">按量付费</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="free_tier">免费套餐</SelectItem>
|
||||||
<SelectItem value="pay_as_you_go">
|
|
||||||
按量付费
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="free_tier">
|
|
||||||
免费套餐
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<Label>RPM 限制</Label>
|
<div class="space-y-1.5">
|
||||||
|
<Label>超时时间 (秒)</Label>
|
||||||
<Input
|
<Input
|
||||||
:model-value="form.rpm_limit ?? ''"
|
:model-value="form.timeout ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="600"
|
||||||
|
placeholder="默认 300"
|
||||||
|
@update:model-value="(v) => form.timeout = parseNumberInput(v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>最大重试次数</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="form.max_retries ?? ''"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="不限制请留空"
|
max="10"
|
||||||
@update:model-value="(v) => form.rpm_limit = parseNumberInput(v)"
|
placeholder="默认 2"
|
||||||
|
@update:model-value="(v) => form.max_retries = parseNumberInput(v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 月卡配置 -->
|
<!-- 月卡配置 -->
|
||||||
<div
|
<div
|
||||||
v-if="form.billing_type === 'monthly_quota'"
|
v-if="form.billing_type === 'monthly_quota'"
|
||||||
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
|
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs">周期额度 (USD)</Label>
|
<Label class="text-xs">周期额度 (USD)</Label>
|
||||||
<Input
|
<Input
|
||||||
:model-value="form.monthly_quota_usd ?? ''"
|
:model-value="form.monthly_quota_usd ?? ''"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="h-9"
|
|
||||||
@update:model-value="(v) => form.monthly_quota_usd = parseNumberInput(v, { allowFloat: true })"
|
@update:model-value="(v) => form.monthly_quota_usd = parseNumberInput(v, { allowFloat: true })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs">重置周期 (天)</Label>
|
<Label class="text-xs">重置周期 (天)</Label>
|
||||||
<Input
|
<Input
|
||||||
:model-value="form.quota_reset_day ?? ''"
|
:model-value="form.quota_reset_day ?? ''"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="365"
|
max="365"
|
||||||
class="h-9"
|
|
||||||
@update:model-value="(v) => form.quota_reset_day = parseNumberInput(v) ?? 30"
|
@update:model-value="(v) => form.quota_reset_day = parseNumberInput(v) ?? 30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs">
|
<Label class="text-xs">
|
||||||
周期开始时间
|
周期开始时间 <span class="text-red-500">*</span>
|
||||||
<span class="text-red-500">*</span>
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
v-model="form.quota_last_reset_at"
|
v-model="form.quota_last_reset_at"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="h-9"
|
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
系统会自动统计从该时间点开始的使用量
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs">过期时间</Label>
|
<Label class="text-xs">过期时间</Label>
|
||||||
<Input
|
<Input
|
||||||
v-model="form.quota_expires_at"
|
v-model="form.quota_expires_at"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="h-9"
|
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">
|
</div>
|
||||||
留空表示永久有效
|
</div>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<!-- 代理配置 -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium">
|
||||||
|
代理配置
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
:model-value="form.proxy_enabled"
|
||||||
|
@update:model-value="(v: boolean) => form.proxy_enabled = v"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">启用代理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="form.proxy_enabled"
|
||||||
|
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label class="text-xs">代理地址 *</Label>
|
||||||
|
<Input
|
||||||
|
v-model="form.proxy_url"
|
||||||
|
placeholder="http://proxy:port 或 socks5://proxy:port"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label class="text-xs">用户名</Label>
|
||||||
|
<Input
|
||||||
|
v-model="form.proxy_username"
|
||||||
|
placeholder="可选"
|
||||||
|
autocomplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label class="text-xs">密码</Label>
|
||||||
|
<Input
|
||||||
|
v-model="form.proxy_password"
|
||||||
|
type="password"
|
||||||
|
placeholder="可选"
|
||||||
|
autocomplete="new-password"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +208,7 @@
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
:disabled="loading || !form.display_name || (!isEditMode && !form.name)"
|
:disabled="loading || !form.name"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存' : '创建') }}
|
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存' : '创建') }}
|
||||||
@@ -187,13 +223,13 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
Textarea,
|
|
||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
Switch,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { Server, SquarePen } from 'lucide-vue-next'
|
import { Server, SquarePen } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
@@ -223,7 +259,6 @@ const internalOpen = computed(() => props.modelValue)
|
|||||||
// 表单数据
|
// 表单数据
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '',
|
||||||
display_name: '',
|
|
||||||
description: '',
|
description: '',
|
||||||
website: '',
|
website: '',
|
||||||
// 计费配置
|
// 计费配置
|
||||||
@@ -232,19 +267,25 @@ const form = ref({
|
|||||||
quota_reset_day: 30,
|
quota_reset_day: 30,
|
||||||
quota_last_reset_at: '', // 周期开始时间
|
quota_last_reset_at: '', // 周期开始时间
|
||||||
quota_expires_at: '',
|
quota_expires_at: '',
|
||||||
rpm_limit: undefined as string | number | undefined,
|
|
||||||
provider_priority: 999,
|
provider_priority: 999,
|
||||||
// 状态配置
|
// 状态配置
|
||||||
is_active: true,
|
is_active: true,
|
||||||
rate_limit: undefined as number | undefined,
|
rate_limit: undefined as number | undefined,
|
||||||
concurrent_limit: undefined as number | undefined,
|
concurrent_limit: undefined as number | undefined,
|
||||||
|
// 请求配置
|
||||||
|
timeout: undefined as number | undefined,
|
||||||
|
max_retries: undefined as number | undefined,
|
||||||
|
// 代理配置(扁平化便于表单绑定)
|
||||||
|
proxy_enabled: false,
|
||||||
|
proxy_url: '',
|
||||||
|
proxy_username: '',
|
||||||
|
proxy_password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// 重置表单
|
// 重置表单
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.value = {
|
form.value = {
|
||||||
name: '',
|
name: '',
|
||||||
display_name: '',
|
|
||||||
description: '',
|
description: '',
|
||||||
website: '',
|
website: '',
|
||||||
billing_type: 'pay_as_you_go',
|
billing_type: 'pay_as_you_go',
|
||||||
@@ -252,11 +293,18 @@ function resetForm() {
|
|||||||
quota_reset_day: 30,
|
quota_reset_day: 30,
|
||||||
quota_last_reset_at: '',
|
quota_last_reset_at: '',
|
||||||
quota_expires_at: '',
|
quota_expires_at: '',
|
||||||
rpm_limit: undefined,
|
|
||||||
provider_priority: 999,
|
provider_priority: 999,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
rate_limit: undefined,
|
rate_limit: undefined,
|
||||||
concurrent_limit: undefined,
|
concurrent_limit: undefined,
|
||||||
|
// 请求配置
|
||||||
|
timeout: undefined,
|
||||||
|
max_retries: undefined,
|
||||||
|
// 代理配置
|
||||||
|
proxy_enabled: false,
|
||||||
|
proxy_url: '',
|
||||||
|
proxy_username: '',
|
||||||
|
proxy_password: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,9 +312,9 @@ function resetForm() {
|
|||||||
function loadProviderData() {
|
function loadProviderData() {
|
||||||
if (!props.provider) return
|
if (!props.provider) return
|
||||||
|
|
||||||
|
const proxy = props.provider.proxy
|
||||||
form.value = {
|
form.value = {
|
||||||
name: props.provider.name,
|
name: props.provider.name,
|
||||||
display_name: props.provider.display_name,
|
|
||||||
description: props.provider.description || '',
|
description: props.provider.description || '',
|
||||||
website: props.provider.website || '',
|
website: props.provider.website || '',
|
||||||
billing_type: (props.provider.billing_type as 'monthly_quota' | 'pay_as_you_go' | 'free_tier') || 'pay_as_you_go',
|
billing_type: (props.provider.billing_type as 'monthly_quota' | 'pay_as_you_go' | 'free_tier') || 'pay_as_you_go',
|
||||||
@@ -276,11 +324,18 @@ function loadProviderData() {
|
|||||||
new Date(props.provider.quota_last_reset_at).toISOString().slice(0, 16) : '',
|
new Date(props.provider.quota_last_reset_at).toISOString().slice(0, 16) : '',
|
||||||
quota_expires_at: props.provider.quota_expires_at ?
|
quota_expires_at: props.provider.quota_expires_at ?
|
||||||
new Date(props.provider.quota_expires_at).toISOString().slice(0, 16) : '',
|
new Date(props.provider.quota_expires_at).toISOString().slice(0, 16) : '',
|
||||||
rpm_limit: props.provider.rpm_limit ?? undefined,
|
|
||||||
provider_priority: props.provider.provider_priority || 999,
|
provider_priority: props.provider.provider_priority || 999,
|
||||||
is_active: props.provider.is_active,
|
is_active: props.provider.is_active,
|
||||||
rate_limit: undefined,
|
rate_limit: undefined,
|
||||||
concurrent_limit: undefined,
|
concurrent_limit: undefined,
|
||||||
|
// 请求配置
|
||||||
|
timeout: props.provider.timeout ?? undefined,
|
||||||
|
max_retries: props.provider.max_retries ?? undefined,
|
||||||
|
// 代理配置
|
||||||
|
proxy_enabled: proxy?.enabled ?? false,
|
||||||
|
proxy_url: proxy?.url || '',
|
||||||
|
proxy_username: proxy?.username || '',
|
||||||
|
proxy_password: proxy?.password || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,17 +357,37 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启用代理时必须填写代理地址
|
||||||
|
if (form.value.proxy_enabled && !form.value.proxy_url) {
|
||||||
|
showError('启用代理时必须填写代理地址', '验证失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
// 构建代理配置
|
||||||
|
const proxy = form.value.proxy_enabled ? {
|
||||||
|
url: form.value.proxy_url,
|
||||||
|
username: form.value.proxy_username || undefined,
|
||||||
|
password: form.value.proxy_password || undefined,
|
||||||
|
enabled: true,
|
||||||
|
} : null
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...form.value,
|
name: form.value.name,
|
||||||
rpm_limit:
|
description: form.value.description || undefined,
|
||||||
form.value.rpm_limit === undefined || form.value.rpm_limit === ''
|
website: form.value.website || undefined,
|
||||||
? null
|
billing_type: form.value.billing_type,
|
||||||
: Number(form.value.rpm_limit),
|
monthly_quota_usd: form.value.monthly_quota_usd,
|
||||||
// 空字符串时不发送
|
quota_reset_day: form.value.quota_reset_day,
|
||||||
quota_last_reset_at: form.value.quota_last_reset_at || undefined,
|
quota_last_reset_at: form.value.quota_last_reset_at || undefined,
|
||||||
quota_expires_at: form.value.quota_expires_at || undefined,
|
quota_expires_at: form.value.quota_expires_at || undefined,
|
||||||
|
provider_priority: form.value.provider_priority,
|
||||||
|
is_active: form.value.is_active,
|
||||||
|
// 请求配置
|
||||||
|
timeout: form.value.timeout ?? undefined,
|
||||||
|
max_retries: form.value.max_retries ?? undefined,
|
||||||
|
proxy,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditMode.value && props.provider) {
|
if (isEditMode.value && props.provider) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { default as ProviderFormDialog } from './ProviderFormDialog.vue'
|
|||||||
export { default as EndpointFormDialog } from './EndpointFormDialog.vue'
|
export { default as EndpointFormDialog } from './EndpointFormDialog.vue'
|
||||||
export { default as KeyFormDialog } from './KeyFormDialog.vue'
|
export { default as KeyFormDialog } from './KeyFormDialog.vue'
|
||||||
export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue'
|
export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue'
|
||||||
|
export { default as KeyAllowedModelsEditDialog } from './KeyAllowedModelsEditDialog.vue'
|
||||||
export { default as PriorityManagementDialog } from './PriorityManagementDialog.vue'
|
export { default as PriorityManagementDialog } from './PriorityManagementDialog.vue'
|
||||||
export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vue'
|
export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vue'
|
||||||
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
||||||
|
|||||||
@@ -178,7 +178,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
class="h-8 w-8 hover:text-destructive"
|
||||||
title="删除"
|
title="删除"
|
||||||
@click="deleteModel(model)"
|
@click="deleteModel(model)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,8 +18,22 @@
|
|||||||
<span class="flex-shrink-0">多</span>
|
<span class="flex-shrink-0">多</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="hasError"
|
||||||
|
class="h-full min-h-[160px] flex items-center justify-center text-sm text-destructive"
|
||||||
|
>
|
||||||
|
<AlertCircle class="h-4 w-4 mr-1.5" />
|
||||||
|
加载失败
|
||||||
|
</div>
|
||||||
<ActivityHeatmap
|
<ActivityHeatmap
|
||||||
v-if="hasData"
|
v-else-if="hasData"
|
||||||
:data="data"
|
:data="data"
|
||||||
:show-header="false"
|
:show-header="false"
|
||||||
/>
|
/>
|
||||||
@@ -34,6 +48,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
|
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
|
||||||
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||||
@@ -41,6 +56,8 @@ import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: ActivityHeatmapData | null
|
data: ActivityHeatmapData | null
|
||||||
title: string
|
title: string
|
||||||
|
isLoading?: boolean
|
||||||
|
hasError?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||||
|
|||||||
@@ -289,14 +289,14 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误信息卡片 -->
|
<!-- 响应客户端错误卡片 -->
|
||||||
<Card
|
<Card
|
||||||
v-if="detail.error_message"
|
v-if="detail.error_message"
|
||||||
class="border-red-200 dark:border-red-800"
|
class="border-red-200 dark:border-red-800"
|
||||||
>
|
>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
|
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
|
||||||
错误信息
|
响应客户端错误
|
||||||
</h4>
|
</h4>
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||||
<p class="text-sm text-red-800 dark:text-red-300">
|
<p class="text-sm text-red-800 dark:text-red-300">
|
||||||
@@ -431,7 +431,7 @@
|
|||||||
|
|
||||||
<TabsContent value="response-headers">
|
<TabsContent value="response-headers">
|
||||||
<JsonContent
|
<JsonContent
|
||||||
:data="detail.response_headers"
|
:data="actualResponseHeaders"
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
:expand-depth="currentExpandDepth"
|
:expand-depth="currentExpandDepth"
|
||||||
:is-dark="isDark"
|
:is-dark="isDark"
|
||||||
@@ -614,6 +614,25 @@ const tabs = [
|
|||||||
{ name: 'metadata', label: '元数据' },
|
{ name: 'metadata', label: '元数据' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 判断数据是否有实际内容(非空对象/数组)
|
||||||
|
function hasContent(data: unknown): boolean {
|
||||||
|
if (data === null || data === undefined) return false
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
return Object.keys(data as object).length > 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取实际的响应头(优先 client_response_headers,回退到 response_headers)
|
||||||
|
const actualResponseHeaders = computed(() => {
|
||||||
|
if (!detail.value) return null
|
||||||
|
// 优先返回客户端响应头,如果没有则回退到提供商响应头
|
||||||
|
if (hasContent(detail.value.client_response_headers)) {
|
||||||
|
return detail.value.client_response_headers
|
||||||
|
}
|
||||||
|
return detail.value.response_headers
|
||||||
|
})
|
||||||
|
|
||||||
// 根据实际数据决定显示哪些 Tab
|
// 根据实际数据决定显示哪些 Tab
|
||||||
const visibleTabs = computed(() => {
|
const visibleTabs = computed(() => {
|
||||||
if (!detail.value) return []
|
if (!detail.value) return []
|
||||||
@@ -621,15 +640,15 @@ const visibleTabs = computed(() => {
|
|||||||
return tabs.filter(tab => {
|
return tabs.filter(tab => {
|
||||||
switch (tab.name) {
|
switch (tab.name) {
|
||||||
case 'request-headers':
|
case 'request-headers':
|
||||||
return detail.value!.request_headers && Object.keys(detail.value!.request_headers).length > 0
|
return hasContent(detail.value!.request_headers)
|
||||||
case 'request-body':
|
case 'request-body':
|
||||||
return detail.value!.request_body !== null && detail.value!.request_body !== undefined
|
return hasContent(detail.value!.request_body)
|
||||||
case 'response-headers':
|
case 'response-headers':
|
||||||
return detail.value!.response_headers && Object.keys(detail.value!.response_headers).length > 0
|
return hasContent(actualResponseHeaders.value)
|
||||||
case 'response-body':
|
case 'response-body':
|
||||||
return detail.value!.response_body !== null && detail.value!.response_body !== undefined
|
return hasContent(detail.value!.response_body)
|
||||||
case 'metadata':
|
case 'metadata':
|
||||||
return detail.value!.metadata && Object.keys(detail.value!.metadata).length > 0
|
return hasContent(detail.value!.metadata)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -775,7 +794,7 @@ function copyJsonToClipboard(tabName: string) {
|
|||||||
data = detail.value.request_body
|
data = detail.value.request_body
|
||||||
break
|
break
|
||||||
case 'response-headers':
|
case 'response-headers':
|
||||||
data = detail.value.response_headers
|
data = actualResponseHeaders.value
|
||||||
break
|
break
|
||||||
case 'response-body':
|
case 'response-body':
|
||||||
data = detail.value.response_body
|
data = detail.value.response_body
|
||||||
|
|||||||
@@ -32,6 +32,17 @@
|
|||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||||
|
|
||||||
|
<!-- 通用搜索 -->
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
id="usage-records-search"
|
||||||
|
v-model="localSearch"
|
||||||
|
:placeholder="isAdmin ? '搜索用户/密钥/模型/提供商' : '搜索密钥/模型'"
|
||||||
|
class="w-32 sm:w-48 h-8 text-xs border-border/60 pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 用户筛选(仅管理员可见) -->
|
<!-- 用户筛选(仅管理员可见) -->
|
||||||
<Select
|
<Select
|
||||||
v-if="isAdmin && availableUsers.length > 0"
|
v-if="isAdmin && availableUsers.length > 0"
|
||||||
@@ -164,6 +175,12 @@
|
|||||||
>
|
>
|
||||||
用户
|
用户
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
v-if="!isAdmin"
|
||||||
|
class="h-12 font-semibold w-[100px]"
|
||||||
|
>
|
||||||
|
密钥
|
||||||
|
</TableHead>
|
||||||
<TableHead class="h-12 font-semibold w-[140px]">
|
<TableHead class="h-12 font-semibold w-[140px]">
|
||||||
模型
|
模型
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -196,7 +213,7 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="records.length === 0">
|
<TableRow v-if="records.length === 0">
|
||||||
<TableCell
|
<TableCell
|
||||||
:colspan="isAdmin ? 9 : 7"
|
:colspan="isAdmin ? 9 : 8"
|
||||||
class="text-center py-12 text-muted-foreground"
|
class="text-center py-12 text-muted-foreground"
|
||||||
>
|
>
|
||||||
暂无请求记录
|
暂无请求记录
|
||||||
@@ -218,7 +235,34 @@
|
|||||||
class="py-4 w-[100px] truncate"
|
class="py-4 w-[100px] truncate"
|
||||||
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
||||||
>
|
>
|
||||||
|
<div class="flex flex-col text-xs gap-0.5">
|
||||||
|
<span class="truncate">
|
||||||
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
|
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="record.api_key?.name"
|
||||||
|
class="text-muted-foreground truncate"
|
||||||
|
:title="record.api_key.name"
|
||||||
|
>
|
||||||
|
{{ record.api_key.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<!-- 用户页面的密钥列 -->
|
||||||
|
<TableCell
|
||||||
|
v-if="!isAdmin"
|
||||||
|
class="py-4 w-[100px]"
|
||||||
|
:title="record.api_key?.name || '-'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col text-xs gap-0.5">
|
||||||
|
<span class="truncate">{{ record.api_key?.name || '-' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="record.api_key?.display"
|
||||||
|
class="text-muted-foreground truncate"
|
||||||
|
>
|
||||||
|
{{ record.api_key.display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
class="font-medium py-4 w-[140px]"
|
class="font-medium py-4 w-[140px]"
|
||||||
@@ -438,6 +482,7 @@ import {
|
|||||||
TableCard,
|
TableCard,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Input,
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
@@ -451,7 +496,7 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { RefreshCcw } from 'lucide-vue-next'
|
import { RefreshCcw, Search } from 'lucide-vue-next'
|
||||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||||
import { formatDateTime } from '../composables'
|
import { formatDateTime } from '../composables'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
@@ -471,6 +516,7 @@ const props = defineProps<{
|
|||||||
// 时间段
|
// 时间段
|
||||||
selectedPeriod: string
|
selectedPeriod: string
|
||||||
// 筛选
|
// 筛选
|
||||||
|
filterSearch: string
|
||||||
filterUser: string
|
filterUser: string
|
||||||
filterModel: string
|
filterModel: string
|
||||||
filterProvider: string
|
filterProvider: string
|
||||||
@@ -489,6 +535,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:selectedPeriod': [value: string]
|
'update:selectedPeriod': [value: string]
|
||||||
|
'update:filterSearch': [value: string]
|
||||||
'update:filterUser': [value: string]
|
'update:filterUser': [value: string]
|
||||||
'update:filterModel': [value: string]
|
'update:filterModel': [value: string]
|
||||||
'update:filterProvider': [value: string]
|
'update:filterProvider': [value: string]
|
||||||
@@ -507,6 +554,23 @@ const filterModelSelectOpen = ref(false)
|
|||||||
const filterProviderSelectOpen = ref(false)
|
const filterProviderSelectOpen = ref(false)
|
||||||
const filterStatusSelectOpen = ref(false)
|
const filterStatusSelectOpen = ref(false)
|
||||||
|
|
||||||
|
// 通用搜索(输入防抖)
|
||||||
|
const localSearch = ref(props.filterSearch)
|
||||||
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch(() => props.filterSearch, (value) => {
|
||||||
|
if (value !== localSearch.value) {
|
||||||
|
localSearch.value = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(localSearch, (value) => {
|
||||||
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
|
emit('update:filterSearch', value)
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
// 动态计时器相关
|
// 动态计时器相关
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||||
@@ -574,6 +638,10 @@ function handleRowClick(event: MouseEvent, id: string) {
|
|||||||
// 组件卸载时清理
|
// 组件卸载时清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式化 API 格式显示名称
|
// 格式化 API 格式显示名称
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface PaginationParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterParams {
|
export interface FilterParams {
|
||||||
|
search?: string
|
||||||
user_id?: string
|
user_id?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -64,9 +65,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 活跃度热图数据
|
|
||||||
const activityHeatmapData = computed(() => stats.value.activity_heatmap)
|
|
||||||
|
|
||||||
// 加载统计数据(不加载记录)
|
// 加载统计数据(不加载记录)
|
||||||
async function loadStats(dateRange?: DateRangeParams) {
|
async function loadStats(dateRange?: DateRangeParams) {
|
||||||
isLoadingStats.value = true
|
isLoadingStats.value = true
|
||||||
@@ -93,7 +91,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
cache_stats: (statsData as any).cache_stats,
|
cache_stats: (statsData as any).cache_stats,
|
||||||
period_start: '',
|
period_start: '',
|
||||||
period_end: '',
|
period_end: '',
|
||||||
activity_heatmap: statsData.activity_heatmap || null
|
activity_heatmap: null
|
||||||
}
|
}
|
||||||
|
|
||||||
modelStats.value = modelData.map(item => ({
|
modelStats.value = modelData.map(item => ({
|
||||||
@@ -143,7 +141,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
avg_response_time: userData.avg_response_time || 0,
|
avg_response_time: userData.avg_response_time || 0,
|
||||||
period_start: '',
|
period_start: '',
|
||||||
period_end: '',
|
period_end: '',
|
||||||
activity_heatmap: userData.activity_heatmap || null
|
activity_heatmap: null
|
||||||
}
|
}
|
||||||
|
|
||||||
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
|
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
|
||||||
@@ -237,11 +235,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
pagination: PaginationParams,
|
pagination: PaginationParams,
|
||||||
filters?: FilterParams
|
filters?: FilterParams
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isAdminPage.value) {
|
|
||||||
// 用户页面不需要分页加载,记录已在 loadStats 中获取
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingRecords.value = true
|
isLoadingRecords.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -255,6 +248,12 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加筛选条件
|
// 添加筛选条件
|
||||||
|
if (filters?.search?.trim()) {
|
||||||
|
params.search = filters.search.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdminPage.value) {
|
||||||
|
// 管理员页面:使用管理员 API
|
||||||
if (filters?.user_id) {
|
if (filters?.user_id) {
|
||||||
params.user_id = filters.user_id
|
params.user_id = filters.user_id
|
||||||
}
|
}
|
||||||
@@ -269,10 +268,14 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await usageApi.getAllUsageRecords(params)
|
const response = await usageApi.getAllUsageRecords(params)
|
||||||
|
|
||||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||||
totalRecords.value = response.total || 0
|
totalRecords.value = response.total || 0
|
||||||
|
} else {
|
||||||
|
// 用户页面:使用用户 API
|
||||||
|
const userData = await meApi.getUsage(params)
|
||||||
|
currentRecords.value = (userData.records || []) as UsageRecord[]
|
||||||
|
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('加载记录失败:', error)
|
log.error('加载记录失败:', error)
|
||||||
currentRecords.value = []
|
currentRecords.value = []
|
||||||
@@ -305,7 +308,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
enhancedModelStats,
|
enhancedModelStats,
|
||||||
activityHeatmapData,
|
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
loadStats,
|
loadStats,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { ActivityHeatmap } from '@/types/activity'
|
|
||||||
|
|
||||||
// 统计数据状态
|
// 统计数据状态
|
||||||
export interface UsageStatsState {
|
export interface UsageStatsState {
|
||||||
total_requests: number
|
total_requests: number
|
||||||
@@ -17,7 +15,6 @@ export interface UsageStatsState {
|
|||||||
}
|
}
|
||||||
period_start: string
|
period_start: string
|
||||||
period_end: string
|
period_end: string
|
||||||
activity_heatmap: ActivityHeatmap | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型统计
|
// 模型统计
|
||||||
@@ -64,6 +61,11 @@ export interface UsageRecord {
|
|||||||
user_id?: string
|
user_id?: string
|
||||||
username?: string
|
username?: string
|
||||||
user_email?: string
|
user_email?: string
|
||||||
|
api_key?: {
|
||||||
|
id: string | null
|
||||||
|
name: string | null
|
||||||
|
display: string | null
|
||||||
|
} | null
|
||||||
provider: string
|
provider: string
|
||||||
api_key_name?: string
|
api_key_name?: string
|
||||||
rate_multiplier?: number
|
rate_multiplier?: number
|
||||||
@@ -115,7 +117,6 @@ export function createDefaultStats(): UsageStatsState {
|
|||||||
error_rate: undefined,
|
error_rate: undefined,
|
||||||
cache_stats: undefined,
|
cache_stats: undefined,
|
||||||
period_start: '',
|
period_start: '',
|
||||||
period_end: '',
|
period_end: ''
|
||||||
activity_heatmap: null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,7 +252,7 @@
|
|||||||
@click.stop
|
@click.stop
|
||||||
@change="toggleSelection('allowed_providers', provider.id)"
|
@change="toggleSelection('allowed_providers', provider.id)"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ provider.display_name || provider.name }}</span>
|
<span class="text-sm">{{ provider.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="providers.length === 0"
|
v-if="providers.length === 0"
|
||||||
@@ -273,8 +273,8 @@
|
|||||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||||
@click="endpointDropdownOpen = !endpointDropdownOpen"
|
@click="endpointDropdownOpen = !endpointDropdownOpen"
|
||||||
>
|
>
|
||||||
<span :class="form.allowed_endpoints.length ? 'text-foreground' : 'text-muted-foreground'">
|
<span :class="form.allowed_api_formats.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||||
{{ form.allowed_endpoints.length ? `已选择 ${form.allowed_endpoints.length} 个` : '全部可用' }}
|
{{ form.allowed_api_formats.length ? `已选择 ${form.allowed_api_formats.length} 个` : '全部可用' }}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||||
@@ -294,14 +294,14 @@
|
|||||||
v-for="format in apiFormats"
|
v-for="format in apiFormats"
|
||||||
:key="format.value"
|
:key="format.value"
|
||||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||||
@click="toggleSelection('allowed_endpoints', format.value)"
|
@click="toggleSelection('allowed_api_formats', format.value)"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="form.allowed_endpoints.includes(format.value)"
|
:checked="form.allowed_api_formats.includes(format.value)"
|
||||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||||
@click.stop
|
@click.stop
|
||||||
@change="toggleSelection('allowed_endpoints', format.value)"
|
@change="toggleSelection('allowed_api_formats', format.value)"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ format.label }}</span>
|
<span class="text-sm">{{ format.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,55 +316,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型多选下拉框 -->
|
<!-- 模型多选下拉框 -->
|
||||||
<div class="space-y-2">
|
<ModelMultiSelect
|
||||||
<Label class="text-sm font-medium">允许的模型</Label>
|
v-model="form.allowed_models"
|
||||||
<div class="relative">
|
:models="globalModels"
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
|
||||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
|
||||||
>
|
|
||||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
|
||||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
|
||||||
:class="modelDropdownOpen ? 'rotate-180' : ''"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="modelDropdownOpen"
|
|
||||||
class="fixed inset-0 z-[80]"
|
|
||||||
@click.stop="modelDropdownOpen = false"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="modelDropdownOpen"
|
|
||||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="model in globalModels"
|
|
||||||
:key="model.name"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
|
||||||
@click="toggleSelection('allowed_models', model.name)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.allowed_models.includes(model.name)"
|
|
||||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
|
||||||
@click.stop
|
|
||||||
@change="toggleSelection('allowed_models', model.name)"
|
|
||||||
>
|
|
||||||
<span class="text-sm">{{ model.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="globalModels.length === 0"
|
|
||||||
class="px-3 py-2 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无可用模型
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -404,10 +359,12 @@ import {
|
|||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next'
|
import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next'
|
||||||
import { useFormDialog } from '@/composables/useFormDialog'
|
import { useFormDialog } from '@/composables/useFormDialog'
|
||||||
|
import { ModelMultiSelect } from '@/components/common'
|
||||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||||
import { getGlobalModels } from '@/api/global-models'
|
import { getGlobalModels } from '@/api/global-models'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||||
|
|
||||||
export interface UserFormData {
|
export interface UserFormData {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -417,7 +374,7 @@ export interface UserFormData {
|
|||||||
role: 'admin' | 'user'
|
role: 'admin' | 'user'
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_endpoints?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,11 +397,10 @@ const roleSelectOpen = ref(false)
|
|||||||
// 下拉框状态
|
// 下拉框状态
|
||||||
const providerDropdownOpen = ref(false)
|
const providerDropdownOpen = ref(false)
|
||||||
const endpointDropdownOpen = ref(false)
|
const endpointDropdownOpen = ref(false)
|
||||||
const modelDropdownOpen = ref(false)
|
|
||||||
|
|
||||||
// 选项数据
|
// 选项数据
|
||||||
const providers = ref<any[]>([])
|
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||||
const globalModels = ref<any[]>([])
|
const globalModels = ref<GlobalModelResponse[]>([])
|
||||||
const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
@@ -458,7 +414,7 @@ const form = ref({
|
|||||||
unlimited: false,
|
unlimited: false,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
allowed_providers: [] as string[],
|
allowed_providers: [] as string[],
|
||||||
allowed_endpoints: [] as string[],
|
allowed_api_formats: [] as string[],
|
||||||
allowed_models: [] as string[]
|
allowed_models: [] as string[]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -479,7 +435,7 @@ function resetForm() {
|
|||||||
unlimited: false,
|
unlimited: false,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
allowed_providers: [],
|
allowed_providers: [],
|
||||||
allowed_endpoints: [],
|
allowed_api_formats: [],
|
||||||
allowed_models: []
|
allowed_models: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,7 +454,7 @@ function loadUserData() {
|
|||||||
unlimited: props.user.quota_usd == null,
|
unlimited: props.user.quota_usd == null,
|
||||||
is_active: props.user.is_active ?? true,
|
is_active: props.user.is_active ?? true,
|
||||||
allowed_providers: props.user.allowed_providers || [],
|
allowed_providers: props.user.allowed_providers || [],
|
||||||
allowed_endpoints: props.user.allowed_endpoints || [],
|
allowed_api_formats: props.user.allowed_api_formats || [],
|
||||||
allowed_models: props.user.allowed_models || []
|
allowed_models: props.user.allowed_models || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,7 +495,7 @@ async function loadAccessControlOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 切换选择
|
// 切换选择
|
||||||
function toggleSelection(field: 'allowed_providers' | 'allowed_endpoints' | 'allowed_models', value: string) {
|
function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'allowed_models', value: string) {
|
||||||
const arr = form.value[field]
|
const arr = form.value[field]
|
||||||
const index = arr.indexOf(value)
|
const index = arr.indexOf(value)
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -564,7 +520,7 @@ async function handleSubmit() {
|
|||||||
quota_usd: form.value.unlimited ? null : form.value.quota,
|
quota_usd: form.value.unlimited ? null : form.value.quota,
|
||||||
role: form.value.role,
|
role: form.value.role,
|
||||||
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,
|
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,
|
||||||
allowed_endpoints: form.value.allowed_endpoints.length > 0 ? form.value.allowed_endpoints : null,
|
allowed_api_formats: form.value.allowed_api_formats.length > 0 ? form.value.allowed_api_formats : null,
|
||||||
allowed_models: form.value.allowed_models.length > 0 ? form.value.allowed_models : null
|
allowed_models: form.value.allowed_models.length > 0 ? form.value.allowed_models : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -280,11 +280,30 @@
|
|||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- GitHub Link -->
|
||||||
|
<a
|
||||||
|
href="https://github.com/fawney19/Aether"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||||
|
title="GitHub 仓库"
|
||||||
|
>
|
||||||
|
<GithubIcon class="h-4 w-4" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|
||||||
|
<!-- 更新提示弹窗 -->
|
||||||
|
<UpdateDialog
|
||||||
|
v-if="updateInfo"
|
||||||
|
v-model="showUpdateDialog"
|
||||||
|
:current-version="updateInfo.current_version"
|
||||||
|
:latest-version="updateInfo.latest_version || ''"
|
||||||
|
:release-url="updateInfo.release_url"
|
||||||
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -294,14 +313,17 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useDarkMode } from '@/composables/useDarkMode'
|
import { useDarkMode } from '@/composables/useDarkMode'
|
||||||
import { isDemoMode } from '@/config/demo'
|
import { isDemoMode } from '@/config/demo'
|
||||||
|
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import AppShell from '@/components/layout/AppShell.vue'
|
import AppShell from '@/components/layout/AppShell.vue'
|
||||||
import SidebarNav from '@/components/layout/SidebarNav.vue'
|
import SidebarNav from '@/components/layout/SidebarNav.vue'
|
||||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||||
|
import UpdateDialog from '@/components/common/UpdateDialog.vue'
|
||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
Users,
|
Users,
|
||||||
Key,
|
Key,
|
||||||
|
KeyRound,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Cog,
|
Cog,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -322,6 +344,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Mail,
|
Mail,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -333,17 +356,67 @@ const showAuthError = ref(false)
|
|||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
let authCheckInterval: number | null = null
|
let authCheckInterval: number | null = null
|
||||||
|
|
||||||
|
// 更新检查相关
|
||||||
|
const showUpdateDialog = ref(false)
|
||||||
|
const updateInfo = ref<CheckUpdateResponse | null>(null)
|
||||||
|
|
||||||
// 路由变化时自动关闭移动端菜单
|
// 路由变化时自动关闭移动端菜单
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 检查是否应该显示更新提示
|
||||||
|
function shouldShowUpdatePrompt(latestVersion: string): boolean {
|
||||||
|
const ignoreKey = 'aether_update_ignore'
|
||||||
|
const ignoreData = localStorage.getItem(ignoreKey)
|
||||||
|
if (!ignoreData) return true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { version, until } = JSON.parse(ignoreData)
|
||||||
|
// 如果忽略的是同一版本且未过期,则不显示
|
||||||
|
if (version === latestVersion && Date.now() < until) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败,显示提示
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查更新
|
||||||
|
async function checkForUpdate() {
|
||||||
|
// 只有管理员才检查更新
|
||||||
|
if (authStore.user?.role !== 'admin') return
|
||||||
|
|
||||||
|
// 同一会话内只检查一次
|
||||||
|
const sessionKey = 'aether_update_checked'
|
||||||
|
if (sessionStorage.getItem(sessionKey)) return
|
||||||
|
sessionStorage.setItem(sessionKey, '1')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await adminApi.checkUpdate()
|
||||||
|
if (result.has_update && result.latest_version) {
|
||||||
|
if (shouldShowUpdatePrompt(result.latest_version)) {
|
||||||
|
updateInfo.value = result
|
||||||
|
showUpdateDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 静默失败,不影响用户体验
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
authCheckInterval = setInterval(() => {
|
authCheckInterval = setInterval(() => {
|
||||||
if (authStore.user && !authStore.token) {
|
if (authStore.user && !authStore.token) {
|
||||||
showAuthError.value = true
|
showAuthError.value = true
|
||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
|
|
||||||
|
// 延迟检查更新,避免影响页面加载
|
||||||
|
setTimeout(() => {
|
||||||
|
checkForUpdate()
|
||||||
|
}, 2000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -387,6 +460,7 @@ const navigation = computed(() => {
|
|||||||
items: [
|
items: [
|
||||||
{ name: '模型目录', href: '/dashboard/models', icon: Box },
|
{ name: '模型目录', href: '/dashboard/models', icon: Box },
|
||||||
{ name: 'API 密钥', href: '/dashboard/api-keys', icon: Key },
|
{ name: 'API 密钥', href: '/dashboard/api-keys', icon: Key },
|
||||||
|
{ name: '访问令牌', href: '/dashboard/management-tokens', icon: KeyRound },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -412,6 +486,7 @@ const navigation = computed(() => {
|
|||||||
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
|
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
|
||||||
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
||||||
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
||||||
|
{ name: '访问令牌', href: '/admin/management-tokens', icon: KeyRound },
|
||||||
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
|
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -423,6 +498,7 @@ const navigation = computed(() => {
|
|||||||
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
||||||
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
||||||
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
||||||
|
{ name: 'LDAP 配置', href: '/admin/ldap', icon: Shield },
|
||||||
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const MOCK_ADMIN_USER: User = {
|
|||||||
used_usd: 156.78,
|
used_usd: 156.78,
|
||||||
total_usd: 1234.56,
|
total_usd: 1234.56,
|
||||||
allowed_providers: null,
|
allowed_providers: null,
|
||||||
allowed_endpoints: null,
|
allowed_api_formats: null,
|
||||||
allowed_models: null,
|
allowed_models: null,
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
last_login_at: new Date().toISOString()
|
last_login_at: new Date().toISOString()
|
||||||
@@ -38,7 +38,7 @@ export const MOCK_NORMAL_USER: User = {
|
|||||||
used_usd: 45.32,
|
used_usd: 45.32,
|
||||||
total_usd: 245.32,
|
total_usd: 245.32,
|
||||||
allowed_providers: null,
|
allowed_providers: null,
|
||||||
allowed_endpoints: null,
|
allowed_api_formats: null,
|
||||||
allowed_models: null,
|
allowed_models: null,
|
||||||
created_at: '2024-06-01T00:00:00Z',
|
created_at: '2024-06-01T00:00:00Z',
|
||||||
last_login_at: new Date().toISOString()
|
last_login_at: new Date().toISOString()
|
||||||
@@ -274,7 +274,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
|||||||
used_usd: 156.78,
|
used_usd: 156.78,
|
||||||
total_usd: 1234.56,
|
total_usd: 1234.56,
|
||||||
allowed_providers: null,
|
allowed_providers: null,
|
||||||
allowed_endpoints: null,
|
allowed_api_formats: null,
|
||||||
allowed_models: null,
|
allowed_models: null,
|
||||||
created_at: '2024-01-01T00:00:00Z'
|
created_at: '2024-01-01T00:00:00Z'
|
||||||
},
|
},
|
||||||
@@ -288,7 +288,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
|||||||
used_usd: 45.32,
|
used_usd: 45.32,
|
||||||
total_usd: 245.32,
|
total_usd: 245.32,
|
||||||
allowed_providers: null,
|
allowed_providers: null,
|
||||||
allowed_endpoints: null,
|
allowed_api_formats: null,
|
||||||
allowed_models: null,
|
allowed_models: null,
|
||||||
created_at: '2024-06-01T00:00:00Z'
|
created_at: '2024-06-01T00:00:00Z'
|
||||||
},
|
},
|
||||||
@@ -302,7 +302,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
|||||||
used_usd: 23.45,
|
used_usd: 23.45,
|
||||||
total_usd: 123.45,
|
total_usd: 123.45,
|
||||||
allowed_providers: null,
|
allowed_providers: null,
|
||||||
allowed_endpoints: null,
|
allowed_api_formats: null,
|
||||||
allowed_models: null,
|
allowed_models: null,
|
||||||
created_at: '2024-03-15T00:00:00Z'
|
created_at: '2024-03-15T00:00:00Z'
|
||||||
},
|
},
|
||||||
@@ -316,7 +316,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
|||||||
used_usd: 89.12,
|
used_usd: 89.12,
|
||||||
total_usd: 589.12,
|
total_usd: 589.12,
|
||||||
allowed_providers: null,
|
allowed_providers: null,
|
||||||
allowed_endpoints: null,
|
allowed_api_formats: null,
|
||||||
allowed_models: null,
|
allowed_models: null,
|
||||||
created_at: '2024-02-20T00:00:00Z'
|
created_at: '2024-02-20T00:00:00Z'
|
||||||
},
|
},
|
||||||
@@ -330,7 +330,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
|||||||
used_usd: 30.00,
|
used_usd: 30.00,
|
||||||
total_usd: 30.00,
|
total_usd: 30.00,
|
||||||
allowed_providers: null,
|
allowed_providers: null,
|
||||||
allowed_endpoints: null,
|
allowed_api_formats: null,
|
||||||
allowed_models: null,
|
allowed_models: null,
|
||||||
created_at: '2024-04-10T00:00:00Z'
|
created_at: '2024-04-10T00:00:00Z'
|
||||||
}
|
}
|
||||||
@@ -424,8 +424,7 @@ export const MOCK_ADMIN_API_KEYS: AdminApiKeysResponse = {
|
|||||||
export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
|
export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
|
||||||
{
|
{
|
||||||
id: 'provider-001',
|
id: 'provider-001',
|
||||||
name: 'duck_coding_free',
|
name: 'DuckCodingFree',
|
||||||
display_name: 'DuckCodingFree',
|
|
||||||
description: '',
|
description: '',
|
||||||
website: 'https://duckcoding.com',
|
website: 'https://duckcoding.com',
|
||||||
provider_priority: 1,
|
provider_priority: 1,
|
||||||
@@ -451,8 +450,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'provider-002',
|
id: 'provider-002',
|
||||||
name: 'open_claude_code',
|
name: 'OpenClaudeCode',
|
||||||
display_name: 'OpenClaudeCode',
|
|
||||||
description: '',
|
description: '',
|
||||||
website: 'https://www.openclaudecode.cn',
|
website: 'https://www.openclaudecode.cn',
|
||||||
provider_priority: 2,
|
provider_priority: 2,
|
||||||
@@ -477,8 +475,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'provider-003',
|
id: 'provider-003',
|
||||||
name: '88_code',
|
name: '88Code',
|
||||||
display_name: '88Code',
|
|
||||||
description: '',
|
description: '',
|
||||||
website: 'https://www.88code.org/',
|
website: 'https://www.88code.org/',
|
||||||
provider_priority: 3,
|
provider_priority: 3,
|
||||||
@@ -503,8 +500,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'provider-004',
|
id: 'provider-004',
|
||||||
name: 'ikun_code',
|
name: 'IKunCode',
|
||||||
display_name: 'IKunCode',
|
|
||||||
description: '',
|
description: '',
|
||||||
website: 'https://api.ikuncode.cc',
|
website: 'https://api.ikuncode.cc',
|
||||||
provider_priority: 4,
|
provider_priority: 4,
|
||||||
@@ -531,8 +527,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'provider-005',
|
id: 'provider-005',
|
||||||
name: 'duck_coding',
|
name: 'DuckCoding',
|
||||||
display_name: 'DuckCoding',
|
|
||||||
description: '',
|
description: '',
|
||||||
website: 'https://duckcoding.com',
|
website: 'https://duckcoding.com',
|
||||||
provider_priority: 5,
|
provider_priority: 5,
|
||||||
@@ -561,8 +556,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'provider-006',
|
id: 'provider-006',
|
||||||
name: 'privnode',
|
name: 'Privnode',
|
||||||
display_name: 'Privnode',
|
|
||||||
description: '',
|
description: '',
|
||||||
website: 'https://privnode.com',
|
website: 'https://privnode.com',
|
||||||
provider_priority: 6,
|
provider_priority: 6,
|
||||||
@@ -584,8 +578,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'provider-007',
|
id: 'provider-007',
|
||||||
name: 'undying_api',
|
name: 'UndyingAPI',
|
||||||
display_name: 'UndyingAPI',
|
|
||||||
description: '',
|
description: '',
|
||||||
website: 'https://vip.undyingapi.com',
|
website: 'https://vip.undyingapi.com',
|
||||||
provider_priority: 7,
|
provider_priority: 7,
|
||||||
|
|||||||
@@ -367,6 +367,11 @@ function generateMockUsageRecords(count: number = 100) {
|
|||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
user_email: user.email,
|
user_email: user.email,
|
||||||
|
api_key: {
|
||||||
|
id: `key-${user.id}-${Math.ceil(Math.random() * 2)}`,
|
||||||
|
name: `${user.username} Key ${Math.ceil(Math.random() * 3)}`,
|
||||||
|
display: `sk-ae...${String(1000 + Math.floor(Math.random() * 9000))}`
|
||||||
|
},
|
||||||
provider: model.provider,
|
provider: model.provider,
|
||||||
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
|
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
|
||||||
rate_multiplier: 1.0,
|
rate_multiplier: 1.0,
|
||||||
@@ -413,16 +418,16 @@ const MOCK_ALIASES = [
|
|||||||
|
|
||||||
// Mock Endpoint Keys
|
// Mock Endpoint Keys
|
||||||
const MOCK_ENDPOINT_KEYS = [
|
const MOCK_ENDPOINT_KEYS = [
|
||||||
{ id: 'ekey-001', endpoint_id: 'ep-001', api_key_masked: 'sk-ant...abc1', name: 'Primary Key', rate_multiplier: 1.0, internal_priority: 1, health_score: 98, consecutive_failures: 0, request_count: 5000, success_count: 4950, error_count: 50, success_rate: 99, avg_response_time_ms: 1200, is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
|
{ id: 'ekey-001', provider_id: 'provider-001', api_formats: ['CLAUDE'], api_key_masked: 'sk-ant...abc1', name: 'Primary Key', rate_multiplier: 1.0, internal_priority: 1, health_score: 0.98, consecutive_failures: 0, request_count: 5000, success_count: 4950, error_count: 50, success_rate: 0.99, avg_response_time_ms: 1200, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
|
||||||
{ id: 'ekey-002', endpoint_id: 'ep-001', api_key_masked: 'sk-ant...def2', name: 'Backup Key', rate_multiplier: 1.0, internal_priority: 2, health_score: 95, consecutive_failures: 1, request_count: 2000, success_count: 1950, error_count: 50, success_rate: 97.5, avg_response_time_ms: 1350, is_active: true, created_at: '2024-02-01T00:00:00Z', updated_at: new Date().toISOString() },
|
{ id: 'ekey-002', provider_id: 'provider-001', api_formats: ['CLAUDE'], api_key_masked: 'sk-ant...def2', name: 'Backup Key', rate_multiplier: 1.0, internal_priority: 2, health_score: 0.95, consecutive_failures: 1, request_count: 2000, success_count: 1950, error_count: 50, success_rate: 0.975, avg_response_time_ms: 1350, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-02-01T00:00:00Z', updated_at: new Date().toISOString() },
|
||||||
{ id: 'ekey-003', endpoint_id: 'ep-002', api_key_masked: 'sk-oai...ghi3', name: 'OpenAI Main', rate_multiplier: 1.0, internal_priority: 1, health_score: 97, consecutive_failures: 0, request_count: 3500, success_count: 3450, error_count: 50, success_rate: 98.6, avg_response_time_ms: 900, is_active: true, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() }
|
{ id: 'ekey-003', provider_id: 'provider-002', api_formats: ['OPENAI'], api_key_masked: 'sk-oai...ghi3', name: 'OpenAI Main', rate_multiplier: 1.0, internal_priority: 1, health_score: 0.97, consecutive_failures: 0, request_count: 3500, success_count: 3450, error_count: 50, success_rate: 0.986, avg_response_time_ms: 900, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Mock Endpoints
|
// Mock Endpoints
|
||||||
const MOCK_ENDPOINTS = [
|
const MOCK_ENDPOINTS = [
|
||||||
{ id: 'ep-001', provider_id: 'provider-001', provider_name: 'anthropic', api_format: 'claude', base_url: 'https://api.anthropic.com', auth_type: 'bearer', timeout: 120, max_retries: 3, priority: 100, weight: 100, health_score: 98, consecutive_failures: 0, is_active: true, total_keys: 2, active_keys: 2, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
|
{ id: 'ep-001', provider_id: 'provider-001', provider_name: 'anthropic', api_format: 'CLAUDE', base_url: 'https://api.anthropic.com', timeout: 300, max_retries: 2, is_active: true, total_keys: 2, active_keys: 2, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
|
||||||
{ id: 'ep-002', provider_id: 'provider-002', provider_name: 'openai', api_format: 'openai', base_url: 'https://api.openai.com', auth_type: 'bearer', timeout: 60, max_retries: 3, priority: 90, weight: 100, health_score: 97, consecutive_failures: 0, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
|
{ id: 'ep-002', provider_id: 'provider-002', provider_name: 'openai', api_format: 'OPENAI', base_url: 'https://api.openai.com', timeout: 60, max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
|
||||||
{ id: 'ep-003', provider_id: 'provider-003', provider_name: 'google', api_format: 'gemini', base_url: 'https://generativelanguage.googleapis.com', auth_type: 'api_key', timeout: 60, max_retries: 3, priority: 80, weight: 100, health_score: 96, consecutive_failures: 0, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() }
|
{ id: 'ep-003', provider_id: 'provider-003', provider_name: 'google', api_format: 'GEMINI', base_url: 'https://generativelanguage.googleapis.com', timeout: 60, max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Mock 能力定义
|
// Mock 能力定义
|
||||||
@@ -576,7 +581,6 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
|||||||
return createMockResponse(MOCK_PROVIDERS.map(p => ({
|
return createMockResponse(MOCK_PROVIDERS.map(p => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
display_name: p.display_name,
|
|
||||||
is_active: p.is_active
|
is_active: p.is_active
|
||||||
})))
|
})))
|
||||||
},
|
},
|
||||||
@@ -685,7 +689,7 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
|||||||
used_usd: 0,
|
used_usd: 0,
|
||||||
total_usd: 0,
|
total_usd: 0,
|
||||||
allowed_providers: null,
|
allowed_providers: null,
|
||||||
allowed_endpoints: null,
|
allowed_api_formats: null,
|
||||||
allowed_models: null,
|
allowed_models: null,
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
@@ -835,10 +839,26 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
|||||||
'GET /api/admin/usage/records': async (config) => {
|
'GET /api/admin/usage/records': async (config) => {
|
||||||
await delay()
|
await delay()
|
||||||
requireAdmin()
|
requireAdmin()
|
||||||
const records = getUsageRecords()
|
let records = getUsageRecords()
|
||||||
const params = config.params || {}
|
const params = config.params || {}
|
||||||
const limit = parseInt(params.limit) || 20
|
const limit = parseInt(params.limit) || 20
|
||||||
const offset = parseInt(params.offset) || 0
|
const offset = parseInt(params.offset) || 0
|
||||||
|
|
||||||
|
// 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
|
// 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||||
|
if (typeof params.search === 'string' && params.search.trim()) {
|
||||||
|
const keywords = params.search.trim().toLowerCase().split(/\s+/)
|
||||||
|
records = records.filter(r => {
|
||||||
|
// 每个关键词都要匹配至少一个字段
|
||||||
|
return keywords.every((keyword: string) =>
|
||||||
|
(r.username || '').toLowerCase().includes(keyword) ||
|
||||||
|
(r.api_key?.name || '').toLowerCase().includes(keyword) ||
|
||||||
|
(r.model || '').toLowerCase().includes(keyword) ||
|
||||||
|
(r.provider || '').toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return createMockResponse({
|
return createMockResponse({
|
||||||
records: records.slice(offset, offset + limit),
|
records: records.slice(offset, offset + limit),
|
||||||
total: records.length,
|
total: records.length,
|
||||||
@@ -1201,13 +1221,8 @@ function generateMockEndpointsForProvider(providerId: string) {
|
|||||||
base_url: format.includes('CLAUDE') ? 'https://api.anthropic.com' :
|
base_url: format.includes('CLAUDE') ? 'https://api.anthropic.com' :
|
||||||
format.includes('OPENAI') ? 'https://api.openai.com' :
|
format.includes('OPENAI') ? 'https://api.openai.com' :
|
||||||
'https://generativelanguage.googleapis.com',
|
'https://generativelanguage.googleapis.com',
|
||||||
auth_type: format.includes('GEMINI') ? 'api_key' : 'bearer',
|
timeout: 300,
|
||||||
timeout: 120,
|
max_retries: 2,
|
||||||
max_retries: 3,
|
|
||||||
priority: 100 - index * 10,
|
|
||||||
weight: 100,
|
|
||||||
health_score: healthDetail?.health_score ?? 1.0,
|
|
||||||
consecutive_failures: healthDetail?.health_score && healthDetail.health_score < 0.7 ? 2 : 0,
|
|
||||||
is_active: healthDetail?.is_active ?? true,
|
is_active: healthDetail?.is_active ?? true,
|
||||||
total_keys: Math.ceil(Math.random() * 3) + 1,
|
total_keys: Math.ceil(Math.random() * 3) + 1,
|
||||||
active_keys: Math.ceil(Math.random() * 2) + 1,
|
active_keys: Math.ceil(Math.random() * 2) + 1,
|
||||||
@@ -1217,11 +1232,16 @@ function generateMockEndpointsForProvider(providerId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为 endpoint 生成 keys
|
// 为 provider 生成 keys(Key 归属 Provider,通过 api_formats 关联)
|
||||||
function generateMockKeysForEndpoint(endpointId: string, count: number = 2) {
|
const PROVIDER_KEYS_CACHE: Record<string, any[]> = {}
|
||||||
|
function generateMockKeysForProvider(providerId: string, count: number = 2) {
|
||||||
|
const provider = MOCK_PROVIDERS.find(p => p.id === providerId)
|
||||||
|
const formats = provider?.api_formats || []
|
||||||
|
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
id: `key-${endpointId}-${i + 1}`,
|
id: `key-${providerId}-${i + 1}`,
|
||||||
endpoint_id: endpointId,
|
provider_id: providerId,
|
||||||
|
api_formats: i === 0 ? formats : formats.slice(0, 1),
|
||||||
api_key_masked: `sk-***...${Math.random().toString(36).substring(2, 6)}`,
|
api_key_masked: `sk-***...${Math.random().toString(36).substring(2, 6)}`,
|
||||||
name: i === 0 ? 'Primary Key' : `Backup Key ${i}`,
|
name: i === 0 ? 'Primary Key' : `Backup Key ${i}`,
|
||||||
rate_multiplier: 1.0,
|
rate_multiplier: 1.0,
|
||||||
@@ -1233,6 +1253,8 @@ function generateMockKeysForEndpoint(endpointId: string, count: number = 2) {
|
|||||||
error_count: Math.floor(Math.random() * 100),
|
error_count: Math.floor(Math.random() * 100),
|
||||||
success_rate: 0.95 + Math.random() * 0.04, // 0.95-0.99
|
success_rate: 0.95 + Math.random() * 0.04, // 0.95-0.99
|
||||||
avg_response_time_ms: 800 + Math.floor(Math.random() * 600),
|
avg_response_time_ms: 800 + Math.floor(Math.random() * 600),
|
||||||
|
cache_ttl_minutes: 5,
|
||||||
|
max_probe_interval_minutes: 32,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
@@ -1442,29 +1464,63 @@ registerDynamicRoute('PUT', '/api/admin/endpoints/:endpointId', async (config, p
|
|||||||
registerDynamicRoute('DELETE', '/api/admin/endpoints/:endpointId', async (_config, _params) => {
|
registerDynamicRoute('DELETE', '/api/admin/endpoints/:endpointId', async (_config, _params) => {
|
||||||
await delay()
|
await delay()
|
||||||
requireAdmin()
|
requireAdmin()
|
||||||
return createMockResponse({ message: '删除成功(演示模式)' })
|
return createMockResponse({ message: '删除成功(演示模式)', affected_keys_count: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Endpoint Keys 列表
|
// Provider Keys 列表
|
||||||
registerDynamicRoute('GET', '/api/admin/endpoints/:endpointId/keys', async (_config, params) => {
|
registerDynamicRoute('GET', '/api/admin/endpoints/providers/:providerId/keys', async (_config, params) => {
|
||||||
await delay()
|
await delay()
|
||||||
requireAdmin()
|
requireAdmin()
|
||||||
const keys = generateMockKeysForEndpoint(params.endpointId, 2)
|
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
|
||||||
return createMockResponse(keys)
|
PROVIDER_KEYS_CACHE[params.providerId] = generateMockKeysForProvider(params.providerId, 2)
|
||||||
|
}
|
||||||
|
return createMockResponse(PROVIDER_KEYS_CACHE[params.providerId])
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建 Key
|
// 为 Provider 创建 Key
|
||||||
registerDynamicRoute('POST', '/api/admin/endpoints/:endpointId/keys', async (config, params) => {
|
registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/keys', async (config, params) => {
|
||||||
await delay()
|
await delay()
|
||||||
requireAdmin()
|
requireAdmin()
|
||||||
const body = JSON.parse(config.data || '{}')
|
const body = JSON.parse(config.data || '{}')
|
||||||
return createMockResponse({
|
const apiKeyPlain = body.api_key || 'sk-demo'
|
||||||
|
const masked = apiKeyPlain.length >= 12
|
||||||
|
? `${apiKeyPlain.slice(0, 8)}***${apiKeyPlain.slice(-4)}`
|
||||||
|
: 'sk-***...demo'
|
||||||
|
|
||||||
|
const newKey = {
|
||||||
id: `key-demo-${Date.now()}`,
|
id: `key-demo-${Date.now()}`,
|
||||||
endpoint_id: params.endpointId,
|
provider_id: params.providerId,
|
||||||
api_key_masked: 'sk-***...demo',
|
api_formats: body.api_formats || [],
|
||||||
...body,
|
api_key_masked: masked,
|
||||||
created_at: new Date().toISOString()
|
api_key_plain: null,
|
||||||
})
|
name: body.name || 'New Key',
|
||||||
|
note: body.note,
|
||||||
|
rate_multiplier: body.rate_multiplier ?? 1.0,
|
||||||
|
rate_multipliers: body.rate_multipliers ?? null,
|
||||||
|
internal_priority: body.internal_priority ?? 50,
|
||||||
|
global_priority: body.global_priority ?? null,
|
||||||
|
rpm_limit: body.rpm_limit ?? null,
|
||||||
|
allowed_models: body.allowed_models ?? null,
|
||||||
|
capabilities: body.capabilities ?? null,
|
||||||
|
cache_ttl_minutes: body.cache_ttl_minutes ?? 5,
|
||||||
|
max_probe_interval_minutes: body.max_probe_interval_minutes ?? 32,
|
||||||
|
health_score: 1.0,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
request_count: 0,
|
||||||
|
success_count: 0,
|
||||||
|
error_count: 0,
|
||||||
|
success_rate: 0.0,
|
||||||
|
avg_response_time_ms: 0.0,
|
||||||
|
is_active: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
|
||||||
|
PROVIDER_KEYS_CACHE[params.providerId] = []
|
||||||
|
}
|
||||||
|
PROVIDER_KEYS_CACHE[params.providerId].push(newKey)
|
||||||
|
return createMockResponse(newKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Key 更新
|
// Key 更新
|
||||||
@@ -1482,6 +1538,50 @@ registerDynamicRoute('DELETE', '/api/admin/endpoints/keys/:keyId', async (_confi
|
|||||||
return createMockResponse({ message: '删除成功(演示模式)' })
|
return createMockResponse({ message: '删除成功(演示模式)' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Key Reveal
|
||||||
|
registerDynamicRoute('GET', '/api/admin/endpoints/keys/:keyId/reveal', async (_config, _params) => {
|
||||||
|
await delay()
|
||||||
|
requireAdmin()
|
||||||
|
return createMockResponse({ api_key: 'sk-demo-reveal' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keys grouped by format
|
||||||
|
mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
|
||||||
|
await delay()
|
||||||
|
requireAdmin()
|
||||||
|
|
||||||
|
// 确保每个 provider 都有 key 数据
|
||||||
|
for (const provider of MOCK_PROVIDERS) {
|
||||||
|
if (!PROVIDER_KEYS_CACHE[provider.id]) {
|
||||||
|
PROVIDER_KEYS_CACHE[provider.id] = generateMockKeysForProvider(provider.id, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped: Record<string, any[]> = {}
|
||||||
|
for (const provider of MOCK_PROVIDERS) {
|
||||||
|
const endpoints = generateMockEndpointsForProvider(provider.id)
|
||||||
|
const baseUrlByFormat = Object.fromEntries(endpoints.map(e => [e.api_format, e.base_url]))
|
||||||
|
const keys = PROVIDER_KEYS_CACHE[provider.id] || []
|
||||||
|
for (const key of keys) {
|
||||||
|
const formats: string[] = key.api_formats || []
|
||||||
|
for (const fmt of formats) {
|
||||||
|
if (!grouped[fmt]) grouped[fmt] = []
|
||||||
|
grouped[fmt].push({
|
||||||
|
...key,
|
||||||
|
api_format: fmt,
|
||||||
|
provider_name: provider.name,
|
||||||
|
endpoint_base_url: baseUrlByFormat[fmt],
|
||||||
|
global_priority: key.global_priority ?? null,
|
||||||
|
circuit_breaker_open: false,
|
||||||
|
capabilities: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMockResponse(grouped)
|
||||||
|
}
|
||||||
|
|
||||||
// Provider Models 列表
|
// Provider Models 列表
|
||||||
registerDynamicRoute('GET', '/api/admin/providers/:providerId/models', async (_config, params) => {
|
registerDynamicRoute('GET', '/api/admin/providers/:providerId/models', async (_config, params) => {
|
||||||
await delay()
|
await delay()
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'MyApiKeys',
|
name: 'MyApiKeys',
|
||||||
component: () => importWithRetry(() => import('@/views/user/MyApiKeys.vue'))
|
component: () => importWithRetry(() => import('@/views/user/MyApiKeys.vue'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'management-tokens',
|
||||||
|
name: 'ManagementTokens',
|
||||||
|
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue'))
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'announcements',
|
path: 'announcements',
|
||||||
name: 'Announcements',
|
name: 'Announcements',
|
||||||
@@ -81,6 +86,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'ApiKeys',
|
name: 'ApiKeys',
|
||||||
component: () => importWithRetry(() => import('@/views/admin/ApiKeys.vue'))
|
component: () => importWithRetry(() => import('@/views/admin/ApiKeys.vue'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'management-tokens',
|
||||||
|
name: 'AdminManagementTokens',
|
||||||
|
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue'))
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'providers',
|
path: 'providers',
|
||||||
name: 'ProviderManagement',
|
name: 'ProviderManagement',
|
||||||
@@ -111,6 +121,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'EmailSettings',
|
name: 'EmailSettings',
|
||||||
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ldap',
|
||||||
|
name: 'LdapSettings',
|
||||||
|
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue'))
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'audit-logs',
|
path: 'audit-logs',
|
||||||
name: 'AuditLogs',
|
name: 'AuditLogs',
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
|
|
||||||
async function login(email: string, password: string) {
|
async function login(email: string, password: string, authType: 'local' | 'ldap' = 'local') {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authApi.login({ email, password })
|
const response = await authApi.login({ email, password, auth_type: authType })
|
||||||
token.value = response.access_token
|
token.value = response.access_token
|
||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface ValidationError {
|
|||||||
const fieldNameMap: Record<string, string> = {
|
const fieldNameMap: Record<string, string> = {
|
||||||
'api_key': 'API 密钥',
|
'api_key': 'API 密钥',
|
||||||
'priority': '优先级',
|
'priority': '优先级',
|
||||||
'max_concurrent': '最大并发',
|
'rpm_limit': 'RPM 限制',
|
||||||
'rate_limit': '速率限制',
|
'rate_limit': '速率限制',
|
||||||
'daily_limit': '每日限制',
|
'daily_limit': '每日限制',
|
||||||
'monthly_limit': '每月限制',
|
'monthly_limit': '每月限制',
|
||||||
@@ -44,7 +44,6 @@ const fieldNameMap: Record<string, string> = {
|
|||||||
'monthly_quota_usd': '月度配额',
|
'monthly_quota_usd': '月度配额',
|
||||||
'quota_reset_day': '配额重置日',
|
'quota_reset_day': '配额重置日',
|
||||||
'quota_expires_at': '配额过期时间',
|
'quota_expires_at': '配额过期时间',
|
||||||
'rpm_limit': 'RPM 限制',
|
|
||||||
'cache_ttl_minutes': '缓存 TTL',
|
'cache_ttl_minutes': '缓存 TTL',
|
||||||
'max_probe_interval_minutes': '最大探测间隔',
|
'max_probe_interval_minutes': '最大探测间隔',
|
||||||
}
|
}
|
||||||
@@ -54,7 +53,7 @@ const fieldNameMap: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
const errorTypeMap: Record<string, (error: ValidationError) => string> = {
|
const errorTypeMap: Record<string, (error: ValidationError) => string> = {
|
||||||
'string_too_short': (error) => {
|
'string_too_short': (error) => {
|
||||||
const minLength = error.ctx?.min_length || 10
|
const minLength = error.ctx?.min_length || 3
|
||||||
return `长度不能少于 ${minLength} 个字符`
|
return `长度不能少于 ${minLength} 个字符`
|
||||||
},
|
},
|
||||||
'string_too_long': (error) => {
|
'string_too_long': (error) => {
|
||||||
@@ -151,11 +150,18 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
|
|||||||
return '无法连接到服务器,请检查网络连接'
|
return '无法连接到服务器,请检查网络连接'
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = err.response?.data?.detail
|
const data = err.response?.data
|
||||||
|
|
||||||
|
// 1. 处理 {error: {type, message}} 格式(ProxyException 返回格式)
|
||||||
|
if (data?.error?.message) {
|
||||||
|
return data.error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = data?.detail
|
||||||
|
|
||||||
// 如果没有 detail 字段
|
// 如果没有 detail 字段
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
return err.response?.data?.message || err.message || defaultMessage
|
return data?.message || err.message || defaultMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 处理 Pydantic 验证错误(数组格式)
|
// 1. 处理 Pydantic 验证错误(数组格式)
|
||||||
|
|||||||
@@ -54,6 +54,57 @@ export function parseNumberInput(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse number input value for nullable fields (like rpm_limit)
|
||||||
|
* Returns `null` when empty (to signal "use adaptive/default mode")
|
||||||
|
* Returns `undefined` when not provided (to signal "keep original value")
|
||||||
|
*
|
||||||
|
* @param value - Input value (string or number)
|
||||||
|
* @param options - Parse options
|
||||||
|
* @returns Parsed number, null (for empty/adaptive), or undefined
|
||||||
|
*/
|
||||||
|
export function parseNullableNumberInput(
|
||||||
|
value: string | number | null | undefined,
|
||||||
|
options: {
|
||||||
|
allowFloat?: boolean
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
} = {}
|
||||||
|
): number | null | undefined {
|
||||||
|
const { allowFloat = false, min, max } = options
|
||||||
|
|
||||||
|
// Empty string means "null" (adaptive mode)
|
||||||
|
if (value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// null/undefined means "keep original value"
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the value
|
||||||
|
const num = typeof value === 'string'
|
||||||
|
? (allowFloat ? parseFloat(value) : parseInt(value, 10))
|
||||||
|
: value
|
||||||
|
|
||||||
|
// Handle NaN - treat as null (adaptive mode)
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply min/max constraints
|
||||||
|
let result = num
|
||||||
|
if (min !== undefined && result < min) {
|
||||||
|
result = min
|
||||||
|
}
|
||||||
|
if (max !== undefined && result > max) {
|
||||||
|
result = max
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a handler function for number input with specific field
|
* Create a handler function for number input with specific field
|
||||||
* Useful for creating inline handlers in templates
|
* Useful for creating inline handlers in templates
|
||||||
|
|||||||
@@ -850,28 +850,20 @@ async function deleteApiKey(apiKey: AdminApiKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editApiKey(apiKey: AdminApiKey) {
|
function editApiKey(apiKey: AdminApiKey) {
|
||||||
// 计算过期天数
|
// 解析过期日期为 YYYY-MM-DD 格式
|
||||||
let expireDays: number | undefined = undefined
|
// 保留原始日期,不做时间过滤(避免编辑当天过期的 Key 时意外清空)
|
||||||
let neverExpire = true
|
let expiresAt: string | undefined = undefined
|
||||||
|
|
||||||
if (apiKey.expires_at) {
|
if (apiKey.expires_at) {
|
||||||
const expiresDate = new Date(apiKey.expires_at)
|
const expiresDate = new Date(apiKey.expires_at)
|
||||||
const now = new Date()
|
expiresAt = expiresDate.toISOString().split('T')[0]
|
||||||
const diffMs = expiresDate.getTime() - now.getTime()
|
|
||||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
|
||||||
|
|
||||||
if (diffDays > 0) {
|
|
||||||
expireDays = diffDays
|
|
||||||
neverExpire = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
editingKeyData.value = {
|
editingKeyData.value = {
|
||||||
id: apiKey.id,
|
id: apiKey.id,
|
||||||
name: apiKey.name || '',
|
name: apiKey.name || '',
|
||||||
expire_days: expireDays,
|
expires_at: expiresAt,
|
||||||
never_expire: neverExpire,
|
rate_limit: apiKey.rate_limit ?? undefined,
|
||||||
rate_limit: apiKey.rate_limit || 100,
|
|
||||||
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
|
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
|
||||||
allowed_providers: apiKey.allowed_providers || [],
|
allowed_providers: apiKey.allowed_providers || [],
|
||||||
allowed_api_formats: apiKey.allowed_api_formats || [],
|
allowed_api_formats: apiKey.allowed_api_formats || [],
|
||||||
@@ -1033,14 +1025,25 @@ function closeKeyFormDialog() {
|
|||||||
|
|
||||||
// 统一处理表单提交
|
// 统一处理表单提交
|
||||||
async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||||
|
// 验证过期日期(如果设置了,必须晚于今天)
|
||||||
|
if (data.expires_at) {
|
||||||
|
const selectedDate = new Date(data.expires_at)
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
if (selectedDate <= today) {
|
||||||
|
error('过期日期必须晚于今天')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
keyFormDialogRef.value?.setSaving(true)
|
keyFormDialogRef.value?.setSaving(true)
|
||||||
try {
|
try {
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
// 更新
|
// 更新
|
||||||
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
|
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
|
||||||
name: data.name || undefined,
|
name: data.name || undefined,
|
||||||
rate_limit: data.rate_limit,
|
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
|
||||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
expires_at: data.expires_at || null, // undefined/空 = 永不过期
|
||||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||||
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
|
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
|
||||||
allowed_providers: data.allowed_providers,
|
allowed_providers: data.allowed_providers,
|
||||||
@@ -1058,8 +1061,8 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
|||||||
const createData: CreateStandaloneApiKeyRequest = {
|
const createData: CreateStandaloneApiKeyRequest = {
|
||||||
name: data.name || undefined,
|
name: data.name || undefined,
|
||||||
initial_balance_usd: data.initial_balance_usd,
|
initial_balance_usd: data.initial_balance_usd,
|
||||||
rate_limit: data.rate_limit,
|
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
|
||||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
expires_at: data.expires_at || null, // undefined/空 = 永不过期
|
||||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||||
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
|
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
|
||||||
allowed_providers: data.allowed_providers,
|
allowed_providers: data.allowed_providers,
|
||||||
|
|||||||
@@ -106,23 +106,23 @@
|
|||||||
type="text"
|
type="text"
|
||||||
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
|
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
|
||||||
class="-webkit-text-security-disc"
|
class="-webkit-text-security-disc"
|
||||||
:class="smtpPasswordIsSet ? 'pr-8' : ''"
|
:class="(smtpPasswordIsSet || emailConfig.smtp_password) ? 'pr-10' : ''"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
data-lpignore="true"
|
data-lpignore="true"
|
||||||
data-1p-ignore="true"
|
data-1p-ignore="true"
|
||||||
data-form-type="other"
|
data-form-type="other"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="smtpPasswordIsSet"
|
v-if="smtpPasswordIsSet || emailConfig.smtp_password"
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||||
title="清除已保存的密码"
|
title="清除密码"
|
||||||
@click="handleClearSmtpPassword"
|
@click="handleClearSmtpPassword"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="16"
|
width="14"
|
||||||
height="16"
|
height="14"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -498,6 +498,7 @@ const smtpEncryptionSelectOpen = ref(false)
|
|||||||
const emailSuffixModeSelectOpen = ref(false)
|
const emailSuffixModeSelectOpen = ref(false)
|
||||||
const testSmtpLoading = ref(false)
|
const testSmtpLoading = ref(false)
|
||||||
const smtpPasswordIsSet = ref(false)
|
const smtpPasswordIsSet = ref(false)
|
||||||
|
const clearSmtpPassword = ref(false) // 标记是否要清除密码
|
||||||
|
|
||||||
// 邮件模板相关状态
|
// 邮件模板相关状态
|
||||||
const templateLoading = ref(false)
|
const templateLoading = ref(false)
|
||||||
@@ -710,6 +711,7 @@ async function loadEmailConfig() {
|
|||||||
// 配置不存在时使用默认值,无需处理
|
// 配置不存在时使用默认值,无需处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
clearSmtpPassword.value = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error('加载邮件配置失败')
|
error('加载邮件配置失败')
|
||||||
log.error('加载邮件配置失败:', err)
|
log.error('加载邮件配置失败:', err)
|
||||||
@@ -720,6 +722,12 @@ async function loadEmailConfig() {
|
|||||||
async function saveSmtpConfig() {
|
async function saveSmtpConfig() {
|
||||||
smtpSaveLoading.value = true
|
smtpSaveLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
const passwordAction: 'unchanged' | 'updated' | 'cleared' = emailConfig.value.smtp_password
|
||||||
|
? 'updated'
|
||||||
|
: clearSmtpPassword.value
|
||||||
|
? 'cleared'
|
||||||
|
: 'unchanged'
|
||||||
|
|
||||||
const configItems = [
|
const configItems = [
|
||||||
{
|
{
|
||||||
key: 'smtp_host',
|
key: 'smtp_host',
|
||||||
@@ -737,7 +745,7 @@ async function saveSmtpConfig() {
|
|||||||
description: 'SMTP 用户名'
|
description: 'SMTP 用户名'
|
||||||
},
|
},
|
||||||
// 只有输入了新密码才提交(空值表示保持原密码)
|
// 只有输入了新密码才提交(空值表示保持原密码)
|
||||||
...(emailConfig.value.smtp_password
|
...(passwordAction === 'updated'
|
||||||
? [{
|
? [{
|
||||||
key: 'smtp_password',
|
key: 'smtp_password',
|
||||||
value: emailConfig.value.smtp_password,
|
value: emailConfig.value.smtp_password,
|
||||||
@@ -770,8 +778,23 @@ async function saveSmtpConfig() {
|
|||||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 如果标记了清除密码,删除密码配置
|
||||||
|
if (passwordAction === 'cleared') {
|
||||||
|
promises.push(adminApi.deleteSystemConfig('smtp_password'))
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(promises)
|
await Promise.all(promises)
|
||||||
success('SMTP 配置已保存')
|
success('SMTP 配置已保存')
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
if (passwordAction === 'cleared') {
|
||||||
|
clearSmtpPassword.value = false
|
||||||
|
smtpPasswordIsSet.value = false
|
||||||
|
} else if (passwordAction === 'updated') {
|
||||||
|
clearSmtpPassword.value = false
|
||||||
|
smtpPasswordIsSet.value = true
|
||||||
|
}
|
||||||
|
emailConfig.value.smtp_password = null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error('保存配置失败')
|
error('保存配置失败')
|
||||||
log.error('保存 SMTP 配置失败:', err)
|
log.error('保存 SMTP 配置失败:', err)
|
||||||
@@ -812,15 +835,16 @@ async function saveEmailSuffixConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 清除 SMTP 密码
|
// 清除 SMTP 密码
|
||||||
async function handleClearSmtpPassword() {
|
function handleClearSmtpPassword() {
|
||||||
try {
|
// 如果有输入内容,先清空输入框
|
||||||
await adminApi.deleteSystemConfig('smtp_password')
|
if (emailConfig.value.smtp_password) {
|
||||||
smtpPasswordIsSet.value = false
|
|
||||||
emailConfig.value.smtp_password = null
|
emailConfig.value.smtp_password = null
|
||||||
success('SMTP 密码已清除')
|
return
|
||||||
} catch (err) {
|
}
|
||||||
error('清除密码失败')
|
// 标记要清除服务端密码(保存时生效)
|
||||||
log.error('清除 SMTP 密码失败:', err)
|
if (smtpPasswordIsSet.value) {
|
||||||
|
clearSmtpPassword.value = true
|
||||||
|
smtpPasswordIsSet.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
426
frontend/src/views/admin/LdapSettings.vue
Normal file
426
frontend/src/views/admin/LdapSettings.vue
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
<template>
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="LDAP 配置"
|
||||||
|
description="配置 LDAP 认证服务"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-6">
|
||||||
|
<CardSection
|
||||||
|
title="LDAP 服务器配置"
|
||||||
|
description="配置 LDAP 服务器连接参数"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="testLoading"
|
||||||
|
@click="handleTestConnection"
|
||||||
|
>
|
||||||
|
{{ testLoading ? '测试中...' : '测试连接' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="saveLoading"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
{{ saveLoading ? '保存中...' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="server-url"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
服务器地址
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="server-url"
|
||||||
|
v-model="ldapConfig.server_url"
|
||||||
|
type="text"
|
||||||
|
placeholder="ldap://ldap.example.com:389"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
格式: ldap://host:389 或 ldaps://host:636
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="bind-dn"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
绑定 DN
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="bind-dn"
|
||||||
|
v-model="ldapConfig.bind_dn"
|
||||||
|
type="text"
|
||||||
|
placeholder="cn=admin,dc=example,dc=com"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
用于连接 LDAP 服务器的管理员 DN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="bind-password"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
绑定密码
|
||||||
|
</Label>
|
||||||
|
<div class="relative mt-1">
|
||||||
|
<Input
|
||||||
|
id="bind-password"
|
||||||
|
v-model="ldapConfig.bind_password"
|
||||||
|
type="password"
|
||||||
|
:placeholder="hasPassword ? '已设置(留空保持不变)' : '请输入密码'"
|
||||||
|
:class="(hasPassword || ldapConfig.bind_password) ? 'pr-10' : ''"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="hasPassword || ldapConfig.bind_password"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
title="清除密码"
|
||||||
|
@click="handleClearPassword"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
><line
|
||||||
|
x1="18"
|
||||||
|
y1="6"
|
||||||
|
x2="6"
|
||||||
|
y2="18"
|
||||||
|
/><line
|
||||||
|
x1="6"
|
||||||
|
y1="6"
|
||||||
|
x2="18"
|
||||||
|
y2="18"
|
||||||
|
/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
绑定账号的密码
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="base-dn"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
基础 DN
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="base-dn"
|
||||||
|
v-model="ldapConfig.base_dn"
|
||||||
|
type="text"
|
||||||
|
placeholder="ou=users,dc=example,dc=com"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
用户搜索的基础 DN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="user-search-filter"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
用户搜索过滤器
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="user-search-filter"
|
||||||
|
v-model="ldapConfig.user_search_filter"
|
||||||
|
type="text"
|
||||||
|
placeholder="(uid={username})"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{username} 会被替换为登录用户名
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="username-attr"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
用户名属性
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="username-attr"
|
||||||
|
v-model="ldapConfig.username_attr"
|
||||||
|
type="text"
|
||||||
|
placeholder="uid"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
常用: uid (OpenLDAP), sAMAccountName (AD)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="email-attr"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
邮箱属性
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email-attr"
|
||||||
|
v-model="ldapConfig.email_attr"
|
||||||
|
type="text"
|
||||||
|
placeholder="mail"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="display-name-attr"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
显示名称属性
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="display-name-attr"
|
||||||
|
v-model="ldapConfig.display_name_attr"
|
||||||
|
type="text"
|
||||||
|
placeholder="cn"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="connect-timeout"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
连接超时 (秒)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="connect-timeout"
|
||||||
|
v-model.number="ldapConfig.connect_timeout"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
placeholder="10"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
单次 LDAP 操作超时时间 (1-60秒),跨国网络建议 15-30 秒
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">使用 STARTTLS</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
在非 SSL 连接上启用 TLS 加密
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="ldapConfig.use_starttls" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">启用 LDAP 认证</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
允许用户使用 LDAP 账号登录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="ldapConfig.is_enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">仅允许 LDAP 登录</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
禁用本地账号登录,仅允许 LDAP 认证
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="ldapConfig.is_exclusive" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
|
||||||
|
import { Button, Input, Label, Switch } from '@/components/ui'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { adminApi, type LdapConfigUpdateRequest } from '@/api/admin'
|
||||||
|
|
||||||
|
const { success, error } = useToast()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saveLoading = ref(false)
|
||||||
|
const testLoading = ref(false)
|
||||||
|
const hasPassword = ref(false)
|
||||||
|
const clearPassword = ref(false) // 标记是否要清除密码
|
||||||
|
|
||||||
|
const ldapConfig = ref({
|
||||||
|
server_url: '',
|
||||||
|
bind_dn: '',
|
||||||
|
bind_password: '',
|
||||||
|
base_dn: '',
|
||||||
|
user_search_filter: '(uid={username})',
|
||||||
|
username_attr: 'uid',
|
||||||
|
email_attr: 'mail',
|
||||||
|
display_name_attr: 'cn',
|
||||||
|
is_enabled: false,
|
||||||
|
is_exclusive: false,
|
||||||
|
use_starttls: false,
|
||||||
|
connect_timeout: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await adminApi.getLdapConfig()
|
||||||
|
ldapConfig.value = {
|
||||||
|
server_url: response.server_url || '',
|
||||||
|
bind_dn: response.bind_dn || '',
|
||||||
|
bind_password: '',
|
||||||
|
base_dn: response.base_dn || '',
|
||||||
|
user_search_filter: response.user_search_filter || '(uid={username})',
|
||||||
|
username_attr: response.username_attr || 'uid',
|
||||||
|
email_attr: response.email_attr || 'mail',
|
||||||
|
display_name_attr: response.display_name_attr || 'cn',
|
||||||
|
is_enabled: response.is_enabled || false,
|
||||||
|
is_exclusive: response.is_exclusive || false,
|
||||||
|
use_starttls: response.use_starttls || false,
|
||||||
|
connect_timeout: response.connect_timeout || 10,
|
||||||
|
}
|
||||||
|
hasPassword.value = !!response.has_bind_password
|
||||||
|
clearPassword.value = false
|
||||||
|
} catch (err) {
|
||||||
|
error('加载 LDAP 配置失败')
|
||||||
|
console.error('加载 LDAP 配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
saveLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: LdapConfigUpdateRequest = {
|
||||||
|
server_url: ldapConfig.value.server_url,
|
||||||
|
bind_dn: ldapConfig.value.bind_dn,
|
||||||
|
base_dn: ldapConfig.value.base_dn,
|
||||||
|
user_search_filter: ldapConfig.value.user_search_filter,
|
||||||
|
username_attr: ldapConfig.value.username_attr,
|
||||||
|
email_attr: ldapConfig.value.email_attr,
|
||||||
|
display_name_attr: ldapConfig.value.display_name_attr,
|
||||||
|
is_enabled: ldapConfig.value.is_enabled,
|
||||||
|
is_exclusive: ldapConfig.value.is_exclusive,
|
||||||
|
use_starttls: ldapConfig.value.use_starttls,
|
||||||
|
connect_timeout: ldapConfig.value.connect_timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用输入的新密码;否则如果标记清除则发送空字符串
|
||||||
|
let passwordAction: 'unchanged' | 'updated' | 'cleared' = 'unchanged'
|
||||||
|
if (ldapConfig.value.bind_password) {
|
||||||
|
payload.bind_password = ldapConfig.value.bind_password
|
||||||
|
passwordAction = 'updated'
|
||||||
|
} else if (clearPassword.value) {
|
||||||
|
payload.bind_password = ''
|
||||||
|
passwordAction = 'cleared'
|
||||||
|
}
|
||||||
|
|
||||||
|
await adminApi.updateLdapConfig(payload)
|
||||||
|
success('LDAP 配置保存成功')
|
||||||
|
|
||||||
|
if (passwordAction === 'cleared') {
|
||||||
|
hasPassword.value = false
|
||||||
|
clearPassword.value = false
|
||||||
|
} else if (passwordAction === 'updated') {
|
||||||
|
hasPassword.value = true
|
||||||
|
clearPassword.value = false
|
||||||
|
}
|
||||||
|
ldapConfig.value.bind_password = ''
|
||||||
|
} catch (err) {
|
||||||
|
error('保存 LDAP 配置失败')
|
||||||
|
console.error('保存 LDAP 配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
saveLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestConnection() {
|
||||||
|
if (clearPassword.value && !ldapConfig.value.bind_password) {
|
||||||
|
error('已标记清除绑定密码,请先保存或输入新的绑定密码再测试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
testLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: LdapConfigUpdateRequest = {
|
||||||
|
server_url: ldapConfig.value.server_url,
|
||||||
|
bind_dn: ldapConfig.value.bind_dn,
|
||||||
|
base_dn: ldapConfig.value.base_dn,
|
||||||
|
user_search_filter: ldapConfig.value.user_search_filter,
|
||||||
|
username_attr: ldapConfig.value.username_attr,
|
||||||
|
email_attr: ldapConfig.value.email_attr,
|
||||||
|
display_name_attr: ldapConfig.value.display_name_attr,
|
||||||
|
is_enabled: ldapConfig.value.is_enabled,
|
||||||
|
is_exclusive: ldapConfig.value.is_exclusive,
|
||||||
|
use_starttls: ldapConfig.value.use_starttls,
|
||||||
|
connect_timeout: ldapConfig.value.connect_timeout,
|
||||||
|
...(ldapConfig.value.bind_password && { bind_password: ldapConfig.value.bind_password }),
|
||||||
|
}
|
||||||
|
const response = await adminApi.testLdapConnection(payload)
|
||||||
|
if (response.success) {
|
||||||
|
success('LDAP 连接测试成功')
|
||||||
|
} else {
|
||||||
|
error(`LDAP 连接测试失败: ${response.message}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error('LDAP 连接测试失败')
|
||||||
|
console.error('LDAP 连接测试失败:', err)
|
||||||
|
} finally {
|
||||||
|
testLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearPassword() {
|
||||||
|
// 如果有输入内容,先清空输入框
|
||||||
|
if (ldapConfig.value.bind_password) {
|
||||||
|
ldapConfig.value.bind_password = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 标记要清除服务端密码(保存时生效)
|
||||||
|
if (hasPassword.value) {
|
||||||
|
clearPassword.value = true
|
||||||
|
hasPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -530,9 +530,6 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium text-sm truncate">
|
<p class="font-medium text-sm truncate">
|
||||||
{{ provider.display_name || provider.name }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
|
||||||
{{ provider.name }}
|
{{ provider.name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -645,10 +642,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium text-sm truncate">
|
<p class="font-medium text-sm truncate">
|
||||||
{{ provider.display_name }}
|
{{ provider.name }}
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
|
||||||
{{ provider.identifier }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -679,7 +673,7 @@
|
|||||||
<ProviderModelFormDialog
|
<ProviderModelFormDialog
|
||||||
:open="editProviderDialogOpen"
|
:open="editProviderDialogOpen"
|
||||||
:provider-id="editingProvider?.id || ''"
|
:provider-id="editingProvider?.id || ''"
|
||||||
:provider-name="editingProvider?.display_name || ''"
|
:provider-name="editingProvider?.name || ''"
|
||||||
:editing-model="editingProviderModel"
|
:editing-model="editingProviderModel"
|
||||||
@update:open="handleEditProviderDialogUpdate"
|
@update:open="handleEditProviderDialogUpdate"
|
||||||
@saved="handleEditProviderSaved"
|
@saved="handleEditProviderSaved"
|
||||||
@@ -939,7 +933,7 @@ async function batchAddSelectedProviders() {
|
|||||||
const errorMessages = result.errors
|
const errorMessages = result.errors
|
||||||
.map(e => {
|
.map(e => {
|
||||||
const provider = providerOptions.value.find(p => p.id === e.provider_id)
|
const provider = providerOptions.value.find(p => p.id === e.provider_id)
|
||||||
const providerName = provider?.display_name || provider?.name || e.provider_id
|
const providerName = provider?.name || e.provider_id
|
||||||
return `${providerName}: ${e.error}`
|
return `${providerName}: ${e.error}`
|
||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
@@ -977,7 +971,7 @@ async function batchRemoveSelectedProviders() {
|
|||||||
await deleteModel(providerId, provider.model_id)
|
await deleteModel(providerId, provider.model_id)
|
||||||
successCount++
|
successCount++
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errors.push(`${provider.display_name}: ${parseApiError(err, '删除失败')}`)
|
errors.push(`${provider.name}: ${parseApiError(err, '删除失败')}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,8 +1082,7 @@ async function loadModelProviders(_globalModelId: string) {
|
|||||||
selectedModelProviders.value = response.providers.map(p => ({
|
selectedModelProviders.value = response.providers.map(p => ({
|
||||||
id: p.provider_id,
|
id: p.provider_id,
|
||||||
model_id: p.model_id,
|
model_id: p.model_id,
|
||||||
display_name: p.provider_display_name || p.provider_name,
|
name: p.provider_name,
|
||||||
identifier: p.provider_name,
|
|
||||||
provider_type: 'API',
|
provider_type: 'API',
|
||||||
target_model: p.target_model,
|
target_model: p.target_model,
|
||||||
is_active: p.is_active,
|
is_active: p.is_active,
|
||||||
@@ -1219,7 +1212,7 @@ async function confirmDeleteProviderImplementation(provider: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = await confirmDanger(
|
const confirmed = await confirmDanger(
|
||||||
`确定要删除 ${provider.display_name} 的模型关联吗?\n\n模型: ${provider.target_model}\n\n此操作不可恢复!`,
|
`确定要删除 ${provider.name} 的模型关联吗?\n\n模型: ${provider.target_model}\n\n此操作不可恢复!`,
|
||||||
'删除关联提供商'
|
'删除关联提供商'
|
||||||
)
|
)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -1227,7 +1220,7 @@ async function confirmDeleteProviderImplementation(provider: any) {
|
|||||||
try {
|
try {
|
||||||
const { deleteModel } = await import('@/api/endpoints')
|
const { deleteModel } = await import('@/api/endpoints')
|
||||||
await deleteModel(provider.id, provider.model_id)
|
await deleteModel(provider.id, provider.model_id)
|
||||||
success(`已删除 ${provider.display_name} 的模型实现`)
|
success(`已删除 ${provider.name} 的模型实现`)
|
||||||
// 重新加载 Provider 列表
|
// 重新加载 Provider 列表
|
||||||
if (selectedModel.value) {
|
if (selectedModel.value) {
|
||||||
await loadModelProviders(selectedModel.value.id)
|
await loadModelProviders(selectedModel.value.id)
|
||||||
|
|||||||
@@ -134,10 +134,7 @@
|
|||||||
@click="handleRowClick($event, provider.id)"
|
@click="handleRowClick($event, provider.id)"
|
||||||
>
|
>
|
||||||
<TableCell class="py-3.5">
|
<TableCell class="py-3.5">
|
||||||
<div class="flex flex-col gap-0.5">
|
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
|
||||||
<span class="text-sm font-medium text-foreground">{{ provider.display_name }}</span>
|
|
||||||
<span class="text-xs text-muted-foreground/70 font-mono">{{ provider.name }}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-3.5">
|
<TableCell class="py-3.5">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -219,17 +216,10 @@
|
|||||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="rpmUsage(provider)"
|
v-else
|
||||||
class="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<span class="text-muted-foreground/70">RPM:</span>
|
|
||||||
<span class="font-medium text-foreground/80">{{ rpmUsage(provider) }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="provider.billing_type !== 'monthly_quota' && !rpmUsage(provider)"
|
|
||||||
class="text-muted-foreground/50"
|
class="text-muted-foreground/50"
|
||||||
>
|
>
|
||||||
无限制
|
按量付费
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -304,7 +294,7 @@
|
|||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium text-foreground truncate">{{ provider.display_name }}</span>
|
<span class="font-medium text-foreground truncate">{{ provider.name }}</span>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||||
class="text-xs shrink-0"
|
class="text-xs shrink-0"
|
||||||
@@ -312,7 +302,6 @@
|
|||||||
{{ provider.is_active ? '活跃' : '停用' }}
|
{{ provider.is_active ? '活跃' : '停用' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-muted-foreground/70 font-mono">{{ provider.name }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-0.5 shrink-0"
|
class="flex items-center gap-0.5 shrink-0"
|
||||||
@@ -383,20 +372,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第四行:配额/限流 -->
|
<!-- 第四行:配额 -->
|
||||||
<div
|
<div
|
||||||
v-if="provider.billing_type === 'monthly_quota' || rpmUsage(provider)"
|
v-if="provider.billing_type === 'monthly_quota'"
|
||||||
class="flex items-center gap-3 text-xs text-muted-foreground"
|
class="flex items-center gap-3 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span v-if="provider.billing_type === 'monthly_quota'">
|
<span>
|
||||||
配额: <span
|
配额: <span
|
||||||
class="font-semibold"
|
class="font-semibold"
|
||||||
:class="getQuotaUsedColorClass(provider)"
|
:class="getQuotaUsedColorClass(provider)"
|
||||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / ${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}
|
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / ${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="rpmUsage(provider)">
|
|
||||||
RPM: {{ rpmUsage(provider) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -509,7 +495,7 @@ const filteredProviders = computed(() => {
|
|||||||
if (searchQuery.value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||||
result = result.filter(p => {
|
result = result.filter(p => {
|
||||||
const searchableText = `${p.display_name} ${p.name}`.toLowerCase()
|
const searchableText = `${p.name}`.toLowerCase()
|
||||||
return keywords.every(keyword => searchableText.includes(keyword))
|
return keywords.every(keyword => searchableText.includes(keyword))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -525,7 +511,7 @@ const filteredProviders = computed(() => {
|
|||||||
return a.provider_priority - b.provider_priority
|
return a.provider_priority - b.provider_priority
|
||||||
}
|
}
|
||||||
// 3. 按名称排序
|
// 3. 按名称排序
|
||||||
return a.display_name.localeCompare(b.display_name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -586,7 +572,10 @@ function sortEndpoints(endpoints: any[]) {
|
|||||||
|
|
||||||
// 判断端点是否可用(有 key)
|
// 判断端点是否可用(有 key)
|
||||||
function isEndpointAvailable(endpoint: any, _provider: ProviderWithEndpointsSummary): boolean {
|
function isEndpointAvailable(endpoint: any, _provider: ProviderWithEndpointsSummary): boolean {
|
||||||
// 检查该端点是否有活跃的密钥
|
// 检查端点是否启用,以及是否有活跃的密钥
|
||||||
|
if (endpoint.is_active === false) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return (endpoint.active_keys ?? 0) > 0
|
return (endpoint.active_keys ?? 0) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,21 +628,6 @@ function getQuotaUsedColorClass(provider: ProviderWithEndpointsSummary): string
|
|||||||
return 'text-foreground'
|
return 'text-foreground'
|
||||||
}
|
}
|
||||||
|
|
||||||
function rpmUsage(provider: ProviderWithEndpointsSummary): string | null {
|
|
||||||
const rpmLimit = provider.rpm_limit
|
|
||||||
const rpmUsed = provider.rpm_used ?? 0
|
|
||||||
|
|
||||||
if (rpmLimit === null || rpmLimit === undefined) {
|
|
||||||
return rpmUsed > 0 ? `${rpmUsed}` : null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rpmLimit === 0) {
|
|
||||||
return '已完全禁止'
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${rpmUsed} / ${rpmLimit}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用复用的行点击逻辑
|
// 使用复用的行点击逻辑
|
||||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||||
|
|
||||||
@@ -706,7 +680,7 @@ function handleProviderAdded() {
|
|||||||
async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
||||||
const confirmed = await confirmDanger(
|
const confirmed = await confirmDanger(
|
||||||
'删除提供商',
|
'删除提供商',
|
||||||
`确定要删除提供商 "${provider.display_name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复!`
|
`确定要删除提供商 "${provider.name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复!`
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|||||||
@@ -464,6 +464,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
|
<!-- 系统版本信息 -->
|
||||||
|
<CardSection
|
||||||
|
title="系统信息"
|
||||||
|
description="当前系统版本和构建信息"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label class="text-sm font-medium text-muted-foreground">版本:</Label>
|
||||||
|
<span
|
||||||
|
v-if="systemVersion"
|
||||||
|
class="text-sm font-mono"
|
||||||
|
>
|
||||||
|
{{ systemVersion }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
加载中...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 导入配置对话框 -->
|
<!-- 导入配置对话框 -->
|
||||||
@@ -475,7 +499,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-if="importPreview"
|
v-if="importPreview"
|
||||||
class="p-3 bg-muted rounded-lg text-sm"
|
class="text-sm"
|
||||||
>
|
>
|
||||||
<p class="font-medium mb-2">
|
<p class="font-medium mb-2">
|
||||||
配置预览
|
配置预览
|
||||||
@@ -487,7 +511,7 @@
|
|||||||
端点: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.endpoints?.length || 0), 0) }} 个
|
端点: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.endpoints?.length || 0), 0) }} 个
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + p.endpoints?.reduce((s: number, e: any) => s + (e.keys?.length || 0), 0), 0) }} 个
|
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.api_keys?.length || 0), 0) }} 个
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -557,7 +581,7 @@
|
|||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
全局模型
|
全局模型
|
||||||
</p>
|
</p>
|
||||||
@@ -567,7 +591,7 @@
|
|||||||
跳过: {{ importResult.stats.global_models.skipped }}
|
跳过: {{ importResult.stats.global_models.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
提供商
|
提供商
|
||||||
</p>
|
</p>
|
||||||
@@ -577,7 +601,7 @@
|
|||||||
跳过: {{ importResult.stats.providers.skipped }}
|
跳过: {{ importResult.stats.providers.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
端点
|
端点
|
||||||
</p>
|
</p>
|
||||||
@@ -587,7 +611,7 @@
|
|||||||
跳过: {{ importResult.stats.endpoints.skipped }}
|
跳过: {{ importResult.stats.endpoints.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
API Keys
|
API Keys
|
||||||
</p>
|
</p>
|
||||||
@@ -596,7 +620,7 @@
|
|||||||
跳过: {{ importResult.stats.keys.skipped }}
|
跳过: {{ importResult.stats.keys.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg col-span-2">
|
<div class="col-span-2">
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
模型配置
|
模型配置
|
||||||
</p>
|
</p>
|
||||||
@@ -642,7 +666,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-if="importUsersPreview"
|
v-if="importUsersPreview"
|
||||||
class="p-3 bg-muted rounded-lg text-sm"
|
class="text-sm"
|
||||||
>
|
>
|
||||||
<p class="font-medium mb-2">
|
<p class="font-medium mb-2">
|
||||||
数据预览
|
数据预览
|
||||||
@@ -652,6 +676,9 @@
|
|||||||
<li>
|
<li>
|
||||||
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} 个
|
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} 个
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="importUsersPreview.standalone_keys?.length">
|
||||||
|
独立余额 Keys: {{ importUsersPreview.standalone_keys.length }} 个
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -720,7 +747,7 @@
|
|||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
用户
|
用户
|
||||||
</p>
|
</p>
|
||||||
@@ -730,7 +757,7 @@
|
|||||||
跳过: {{ importUsersResult.stats.users.skipped }}
|
跳过: {{ importUsersResult.stats.users.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
API Keys
|
API Keys
|
||||||
</p>
|
</p>
|
||||||
@@ -739,6 +766,18 @@
|
|||||||
跳过: {{ importUsersResult.stats.api_keys.skipped }}
|
跳过: {{ importUsersResult.stats.api_keys.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="importUsersResult.stats.standalone_keys"
|
||||||
|
class="col-span-2"
|
||||||
|
>
|
||||||
|
<p class="font-medium">
|
||||||
|
独立余额 Keys
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
创建: {{ importUsersResult.stats.standalone_keys.created }},
|
||||||
|
跳过: {{ importUsersResult.stats.standalone_keys.skipped }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -839,6 +878,9 @@ const importUsersResult = ref<UsersImportResponse | null>(null)
|
|||||||
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||||
const usersMergeModeSelectOpen = ref(false)
|
const usersMergeModeSelectOpen = ref(false)
|
||||||
|
|
||||||
|
// 系统版本信息
|
||||||
|
const systemVersion = ref<string>('')
|
||||||
|
|
||||||
const systemConfig = ref<SystemConfig>({
|
const systemConfig = ref<SystemConfig>({
|
||||||
// 基础配置
|
// 基础配置
|
||||||
default_user_quota_usd: 10.0,
|
default_user_quota_usd: 10.0,
|
||||||
@@ -890,9 +932,21 @@ const sensitiveHeadersStr = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSystemConfig()
|
await Promise.all([
|
||||||
|
loadSystemConfig(),
|
||||||
|
loadSystemVersion()
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadSystemVersion() {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getSystemVersion()
|
||||||
|
systemVersion.value = data.version
|
||||||
|
} catch (err) {
|
||||||
|
log.error('加载系统版本失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSystemConfig() {
|
async function loadSystemConfig() {
|
||||||
try {
|
try {
|
||||||
const configs = [
|
const configs = [
|
||||||
@@ -1090,7 +1144,7 @@ function handleConfigFileSelect(event: Event) {
|
|||||||
const data = JSON.parse(content) as ConfigExportData
|
const data = JSON.parse(content) as ConfigExportData
|
||||||
|
|
||||||
// 验证版本
|
// 验证版本
|
||||||
if (data.version !== '1.0') {
|
if (data.version !== '2.0') {
|
||||||
error(`不支持的配置版本: ${data.version}`)
|
error(`不支持的配置版本: ${data.version}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1178,12 +1232,6 @@ function handleUsersFileSelect(event: Event) {
|
|||||||
const content = e.target?.result as string
|
const content = e.target?.result as string
|
||||||
const data = JSON.parse(content) as UsersExportData
|
const data = JSON.parse(content) as UsersExportData
|
||||||
|
|
||||||
// 验证版本
|
|
||||||
if (data.version !== '1.0') {
|
|
||||||
error(`不支持的配置版本: ${data.version}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
importUsersPreview.value = data
|
importUsersPreview.value = data
|
||||||
usersMergeMode.value = 'skip'
|
usersMergeMode.value = 'skip'
|
||||||
importUsersDialogOpen.value = true
|
importUsersDialogOpen.value = true
|
||||||
|
|||||||
@@ -907,7 +907,7 @@ function editUser(user: any) {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
is_active: user.is_active,
|
is_active: user.is_active,
|
||||||
allowed_providers: user.allowed_providers || [],
|
allowed_providers: user.allowed_providers || [],
|
||||||
allowed_endpoints: user.allowed_endpoints || [],
|
allowed_api_formats: user.allowed_api_formats || [],
|
||||||
allowed_models: user.allowed_models || []
|
allowed_models: user.allowed_models || []
|
||||||
}
|
}
|
||||||
showUserFormDialog.value = true
|
showUserFormDialog.value = true
|
||||||
@@ -929,7 +929,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
|||||||
quota_usd: data.quota_usd,
|
quota_usd: data.quota_usd,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
allowed_providers: data.allowed_providers,
|
allowed_providers: data.allowed_providers,
|
||||||
allowed_endpoints: data.allowed_endpoints,
|
allowed_api_formats: data.allowed_api_formats,
|
||||||
allowed_models: data.allowed_models
|
allowed_models: data.allowed_models
|
||||||
}
|
}
|
||||||
if (data.password) {
|
if (data.password) {
|
||||||
@@ -946,7 +946,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
|||||||
quota_usd: data.quota_usd,
|
quota_usd: data.quota_usd,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
allowed_providers: data.allowed_providers,
|
allowed_providers: data.allowed_providers,
|
||||||
allowed_endpoints: data.allowed_endpoints,
|
allowed_api_formats: data.allowed_api_formats,
|
||||||
allowed_models: data.allowed_models
|
allowed_models: data.allowed_models
|
||||||
})
|
})
|
||||||
// 如果创建时指定为禁用,则更新状态
|
// 如果创建时指定为禁用,则更新状态
|
||||||
|
|||||||
@@ -20,10 +20,11 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="fixed top-0 left-0 right-0 z-50 border-b border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-xl transition-all">
|
<header class="sticky top-0 z-50 border-b border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-xl transition-all">
|
||||||
<div class="mx-auto max-w-7xl px-6 py-4">
|
<div class="h-16 flex items-center">
|
||||||
<div class="flex items-center justify-between">
|
<!-- Centered content container (max-w-7xl) -->
|
||||||
<!-- Logo & Brand -->
|
<div class="mx-auto max-w-7xl w-full px-6 flex items-center justify-between">
|
||||||
|
<!-- Left: Logo & Brand -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3 group/logo cursor-pointer"
|
class="flex items-center gap-3 group/logo cursor-pointer"
|
||||||
@click="scrollToSection(0)"
|
@click="scrollToSection(0)"
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center Navigation -->
|
<!-- Center: Navigation -->
|
||||||
<nav class="hidden md:flex items-center gap-2">
|
<nav class="hidden md:flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="(section, index) in sections"
|
v-for="(section, index) in sections"
|
||||||
@@ -59,8 +60,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Right Actions -->
|
<!-- Right: Login/Dashboard Button -->
|
||||||
<div class="flex items-center gap-3">
|
<RouterLink
|
||||||
|
v-if="authStore.isAuthenticated"
|
||||||
|
:to="dashboardPath"
|
||||||
|
class="min-w-[72px] text-center rounded-xl bg-[#191919] dark:bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-[#262625] dark:hover:bg-[#b86d52] whitespace-nowrap"
|
||||||
|
>
|
||||||
|
控制台
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="min-w-[72px] text-center rounded-xl bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[#cc785c]/30 transition hover:bg-[#d4a27f] whitespace-nowrap"
|
||||||
|
@click="showLoginDialog = true"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed right icons (px-8 to match dashboard) -->
|
||||||
|
<div class="absolute right-8 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
|
<!-- Theme Toggle -->
|
||||||
<button
|
<button
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||||
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
||||||
@@ -79,22 +98,16 @@
|
|||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- GitHub Link -->
|
||||||
<RouterLink
|
<a
|
||||||
v-if="authStore.isAuthenticated"
|
href="https://github.com/fawney19/Aether"
|
||||||
:to="dashboardPath"
|
target="_blank"
|
||||||
class="rounded-xl bg-[#191919] dark:bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-[#262625] dark:hover:bg-[#b86d52]"
|
rel="noopener noreferrer"
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||||
|
title="GitHub 仓库"
|
||||||
>
|
>
|
||||||
控制台
|
<GithubIcon class="h-4 w-4" />
|
||||||
</RouterLink>
|
</a>
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="rounded-xl bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[#cc785c]/30 transition hover:bg-[#d4a27f]"
|
|
||||||
@click="showLoginDialog = true"
|
|
||||||
>
|
|
||||||
登录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -336,31 +349,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="relative z-10 border-t border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-md py-8">
|
|
||||||
<div class="mx-auto max-w-7xl px-6">
|
|
||||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
|
||||||
<p class="text-sm text-[#91918d] dark:text-muted-foreground">
|
|
||||||
© 2025 Aether. 团队内部使用
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-6 text-sm text-[#91918d] dark:text-muted-foreground">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="transition hover:text-[#191919] dark:hover:text-white"
|
|
||||||
>使用条款</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="transition hover:text-[#191919] dark:hover:text-white"
|
|
||||||
>隐私政策</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="transition hover:text-[#191919] dark:hover:text-white"
|
|
||||||
>技术支持</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<LoginDialog v-model="showLoginDialog" />
|
<LoginDialog v-model="showLoginDialog" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -378,6 +366,7 @@ import {
|
|||||||
SunMoon,
|
SunMoon,
|
||||||
Terminal
|
Terminal
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useDarkMode } from '@/composables/useDarkMode'
|
import { useDarkMode } from '@/composables/useDarkMode'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
|
|
||||||
<!-- 主要统计卡片 -->
|
<!-- 主要统计卡片 -->
|
||||||
<div class="grid grid-cols-2 gap-3 sm:gap-4 xl:grid-cols-4">
|
<div class="grid grid-cols-2 gap-3 sm:gap-4 xl:grid-cols-4">
|
||||||
<template v-if="loading && stats.length === 0">
|
<!-- 加载中骨架屏 -->
|
||||||
|
<template v-if="loading">
|
||||||
<Card
|
<Card
|
||||||
v-for="i in 4"
|
v-for="i in 4"
|
||||||
:key="'skeleton-' + i"
|
:key="'skeleton-' + i"
|
||||||
@@ -27,9 +28,10 @@
|
|||||||
<Skeleton class="h-4 w-16" />
|
<Skeleton class="h-4 w-16" />
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- 有数据时显示统计卡片 -->
|
||||||
|
<template v-else-if="stats.length > 0">
|
||||||
<Card
|
<Card
|
||||||
v-for="(stat, index) in stats"
|
v-for="(stat, index) in stats"
|
||||||
v-else
|
|
||||||
:key="stat.name"
|
:key="stat.name"
|
||||||
class="relative overflow-hidden p-3 sm:p-5"
|
class="relative overflow-hidden p-3 sm:p-5"
|
||||||
:class="statCardBorders[index % statCardBorders.length]"
|
:class="statCardBorders[index % statCardBorders.length]"
|
||||||
@@ -83,6 +85,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</template>
|
||||||
|
<!-- 无数据时显示占位卡片 -->
|
||||||
|
<template v-else>
|
||||||
|
<Card
|
||||||
|
v-for="(placeholder, index) in emptyStatPlaceholders"
|
||||||
|
:key="'empty-' + index"
|
||||||
|
class="relative overflow-hidden p-3 sm:p-5"
|
||||||
|
:class="statCardBorders[index % statCardBorders.length]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute -right-4 -top-6 h-28 w-28 rounded-full blur-3xl opacity-20"
|
||||||
|
:class="statCardGlows[index % statCardGlows.length]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute top-3 right-3 sm:top-5 sm:right-5 rounded-xl sm:rounded-2xl border border-border bg-card/50 p-2 sm:p-3 shadow-inner backdrop-blur-sm"
|
||||||
|
:class="getStatIconColor(index)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="placeholder.icon"
|
||||||
|
class="h-4 w-4 sm:h-5 sm:w-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[9px] sm:text-[11px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.4em] text-muted-foreground pr-10 sm:pr-14">
|
||||||
|
{{ placeholder.name }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 sm:mt-4 text-xl sm:text-3xl font-semibold text-muted-foreground/50">
|
||||||
|
--
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 sm:mt-1 text-[10px] sm:text-sm text-muted-foreground/50">
|
||||||
|
暂无数据
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 管理员:系统健康摘要 -->
|
<!-- 管理员:系统健康摘要 -->
|
||||||
@@ -872,6 +909,24 @@ const iconMap: Record<string, any> = {
|
|||||||
Users, Activity, TrendingUp, DollarSign, Key, Hash, Database
|
Users, Activity, TrendingUp, DollarSign, Key, Hash, Database
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 空状态占位卡片
|
||||||
|
const emptyStatPlaceholders = computed(() => {
|
||||||
|
if (isAdmin.value) {
|
||||||
|
return [
|
||||||
|
{ name: '今日请求', icon: Activity },
|
||||||
|
{ name: '今日 Tokens', icon: Hash },
|
||||||
|
{ name: '活跃用户', icon: Users },
|
||||||
|
{ name: '今日费用', icon: DollarSign }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ name: '今日请求', icon: Activity },
|
||||||
|
{ name: '今日 Tokens', icon: Hash },
|
||||||
|
{ name: 'API Keys', icon: Key },
|
||||||
|
{ name: '今日费用', icon: DollarSign }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const totalStats = computed(() => {
|
const totalStats = computed(() => {
|
||||||
if (dailyStats.value.length === 0) {
|
if (dailyStats.value.length === 0) {
|
||||||
return { requests: 0, tokens: 0, cost: 0, avgResponseTime: 0 }
|
return { requests: 0, tokens: 0, cost: 0, avgResponseTime: 0 }
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<ActivityHeatmapCard
|
<ActivityHeatmapCard
|
||||||
:data="activityHeatmapData"
|
:data="activityHeatmapData"
|
||||||
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
||||||
|
:is-loading="isLoadingHeatmap"
|
||||||
|
:has-error="heatmapError"
|
||||||
/>
|
/>
|
||||||
<IntervalTimelineCard
|
<IntervalTimelineCard
|
||||||
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
|
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
:show-actual-cost="authStore.isAdmin"
|
:show-actual-cost="authStore.isAdmin"
|
||||||
:loading="isLoadingRecords"
|
:loading="isLoadingRecords"
|
||||||
:selected-period="selectedPeriod"
|
:selected-period="selectedPeriod"
|
||||||
|
:filter-search="filterSearch"
|
||||||
:filter-user="filterUser"
|
:filter-user="filterUser"
|
||||||
:filter-model="filterModel"
|
:filter-model="filterModel"
|
||||||
:filter-provider="filterProvider"
|
:filter-provider="filterProvider"
|
||||||
@@ -67,6 +70,7 @@
|
|||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
:auto-refresh="globalAutoRefresh"
|
:auto-refresh="globalAutoRefresh"
|
||||||
@update:selected-period="handlePeriodChange"
|
@update:selected-period="handlePeriodChange"
|
||||||
|
@update:filter-search="handleFilterSearchChange"
|
||||||
@update:filter-user="handleFilterUserChange"
|
@update:filter-user="handleFilterUserChange"
|
||||||
@update:filter-model="handleFilterModelChange"
|
@update:filter-model="handleFilterModelChange"
|
||||||
@update:filter-provider="handleFilterProviderChange"
|
@update:filter-provider="handleFilterProviderChange"
|
||||||
@@ -112,8 +116,11 @@ import {
|
|||||||
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
|
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
|
||||||
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
import type { ActivityHeatmap } from '@/types/activity'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { warning } = useToast()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
// 判断是否是管理员页面
|
// 判断是否是管理员页面
|
||||||
@@ -128,6 +135,7 @@ const pageSize = ref(20)
|
|||||||
const pageSizeOptions = [10, 20, 50, 100]
|
const pageSizeOptions = [10, 20, 50, 100]
|
||||||
|
|
||||||
// 筛选状态
|
// 筛选状态
|
||||||
|
const filterSearch = ref('')
|
||||||
const filterUser = ref('__all__')
|
const filterUser = ref('__all__')
|
||||||
const filterModel = ref('__all__')
|
const filterModel = ref('__all__')
|
||||||
const filterProvider = ref('__all__')
|
const filterProvider = ref('__all__')
|
||||||
@@ -144,13 +152,35 @@ const {
|
|||||||
currentRecords,
|
currentRecords,
|
||||||
totalRecords,
|
totalRecords,
|
||||||
enhancedModelStats,
|
enhancedModelStats,
|
||||||
activityHeatmapData,
|
|
||||||
availableModels,
|
availableModels,
|
||||||
availableProviders,
|
availableProviders,
|
||||||
loadStats,
|
loadStats,
|
||||||
loadRecords
|
loadRecords
|
||||||
} = useUsageData({ isAdminPage })
|
} = useUsageData({ isAdminPage })
|
||||||
|
|
||||||
|
// 热力图状态
|
||||||
|
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
|
||||||
|
const isLoadingHeatmap = ref(false)
|
||||||
|
const heatmapError = ref(false)
|
||||||
|
|
||||||
|
// 加载热力图数据
|
||||||
|
async function loadHeatmapData() {
|
||||||
|
isLoadingHeatmap.value = true
|
||||||
|
heatmapError.value = false
|
||||||
|
try {
|
||||||
|
if (isAdminPage.value) {
|
||||||
|
activityHeatmapData.value = await usageApi.getActivityHeatmap()
|
||||||
|
} else {
|
||||||
|
activityHeatmapData.value = await meApi.getActivityHeatmap()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('加载热力图数据失败:', error)
|
||||||
|
heatmapError.value = true
|
||||||
|
} finally {
|
||||||
|
isLoadingHeatmap.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 用户页面需要前端筛选
|
// 用户页面需要前端筛选
|
||||||
const filteredRecords = computed(() => {
|
const filteredRecords = computed(() => {
|
||||||
if (!isAdminPage.value) {
|
if (!isAdminPage.value) {
|
||||||
@@ -232,27 +262,40 @@ async function pollActiveRequests() {
|
|||||||
? await usageApi.getActiveRequests(activeRequestIds.value)
|
? await usageApi.getActiveRequests(activeRequestIds.value)
|
||||||
: await meApi.getActiveRequests(idsParam)
|
: await meApi.getActiveRequests(idsParam)
|
||||||
|
|
||||||
// 检查是否有状态变化
|
let shouldRefresh = false
|
||||||
let hasChanges = false
|
|
||||||
for (const update of requests) {
|
for (const update of requests) {
|
||||||
const record = currentRecords.value.find(r => r.id === update.id)
|
const record = currentRecords.value.find(r => r.id === update.id)
|
||||||
if (record && record.status !== update.status) {
|
if (!record) {
|
||||||
hasChanges = true
|
// 后端返回了未知的活跃请求,触发刷新以获取完整数据
|
||||||
// 如果状态变为 completed 或 failed,需要刷新获取完整数据
|
shouldRefresh = true
|
||||||
if (update.status === 'completed' || update.status === 'failed') {
|
continue
|
||||||
break
|
|
||||||
}
|
}
|
||||||
// 否则只更新状态和 token 信息
|
|
||||||
|
// 状态变化:completed/failed 需要刷新获取完整数据
|
||||||
|
if (record.status !== update.status) {
|
||||||
record.status = update.status
|
record.status = update.status
|
||||||
|
}
|
||||||
|
if (update.status === 'completed' || update.status === 'failed') {
|
||||||
|
shouldRefresh = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||||
record.input_tokens = update.input_tokens
|
record.input_tokens = update.input_tokens
|
||||||
record.output_tokens = update.output_tokens
|
record.output_tokens = update.output_tokens
|
||||||
record.cost = update.cost
|
record.cost = update.cost
|
||||||
record.response_time_ms = update.response_time_ms ?? undefined
|
record.response_time_ms = update.response_time_ms ?? undefined
|
||||||
|
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
||||||
|
// 管理员接口返回额外字段
|
||||||
|
if ('provider' in update && typeof update.provider === 'string') {
|
||||||
|
record.provider = update.provider
|
||||||
|
}
|
||||||
|
if ('api_key_name' in update) {
|
||||||
|
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有请求完成或失败,刷新整个列表获取完整数据
|
if (shouldRefresh) {
|
||||||
if (hasChanges && requests.some(r => r.status === 'completed' || r.status === 'failed')) {
|
|
||||||
await refreshData()
|
await refreshData()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -335,16 +378,34 @@ const selectedRequestId = ref<string | null>(null)
|
|||||||
// 初始化加载
|
// 初始化加载
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||||
await loadStats(dateRange)
|
|
||||||
|
|
||||||
// 管理员页面加载用户列表和第一页记录
|
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
|
||||||
|
const [statsResult, heatmapResult] = await Promise.allSettled([
|
||||||
|
loadStats(dateRange),
|
||||||
|
loadHeatmapData()
|
||||||
|
])
|
||||||
|
|
||||||
|
// 检查加载结果并通知用户
|
||||||
|
if (statsResult.status === 'rejected') {
|
||||||
|
log.error('加载统计数据失败:', statsResult.reason)
|
||||||
|
warning('统计数据加载失败,请刷新重试')
|
||||||
|
}
|
||||||
|
if (heatmapResult.status === 'rejected') {
|
||||||
|
log.error('加载热力图数据失败:', heatmapResult.reason)
|
||||||
|
// 热力图加载失败不提示,因为 UI 已显示占位符
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载记录和用户列表
|
||||||
if (isAdminPage.value) {
|
if (isAdminPage.value) {
|
||||||
// 并行加载用户列表和记录
|
// 管理员页面:并行加载用户列表和记录
|
||||||
const [users] = await Promise.all([
|
const [users] = await Promise.all([
|
||||||
usersApi.getAllUsers(),
|
usersApi.getAllUsers(),
|
||||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
])
|
])
|
||||||
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
||||||
|
} else {
|
||||||
|
// 用户页面:加载记录
|
||||||
|
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -355,34 +416,26 @@ async function handlePeriodChange(value: string) {
|
|||||||
|
|
||||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||||
await loadStats(dateRange)
|
await loadStats(dateRange)
|
||||||
|
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理分页变化
|
// 处理分页变化
|
||||||
async function handlePageChange(page: number) {
|
async function handlePageChange(page: number) {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
|
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理每页大小变化
|
// 处理每页大小变化
|
||||||
async function handlePageSizeChange(size: number) {
|
async function handlePageSizeChange(size: number) {
|
||||||
pageSize.value = size
|
pageSize.value = size
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前筛选参数
|
// 获取当前筛选参数
|
||||||
function getCurrentFilters() {
|
function getCurrentFilters() {
|
||||||
return {
|
return {
|
||||||
|
search: filterSearch.value.trim() || undefined,
|
||||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||||||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||||
@@ -391,6 +444,13 @@ function getCurrentFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理筛选变化
|
// 处理筛选变化
|
||||||
|
async function handleFilterSearchChange(value: string) {
|
||||||
|
filterSearch.value = value
|
||||||
|
currentPage.value = 1
|
||||||
|
|
||||||
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFilterUserChange(value: string) {
|
async function handleFilterUserChange(value: string) {
|
||||||
filterUser.value = value
|
filterUser.value = value
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
@@ -431,10 +491,7 @@ async function handleFilterStatusChange(value: string) {
|
|||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||||
await loadStats(dateRange)
|
await loadStats(dateRange)
|
||||||
|
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示请求详情
|
// 显示请求详情
|
||||||
|
|||||||
859
frontend/src/views/user/ManagementTokens.vue
Normal file
859
frontend/src/views/user/ManagementTokens.vue
Normal file
@@ -0,0 +1,859 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 pb-8">
|
||||||
|
<!-- 访问令牌表格 -->
|
||||||
|
<Card
|
||||||
|
variant="default"
|
||||||
|
class="overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- 标题和操作栏 -->
|
||||||
|
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm sm:text-base font-semibold">
|
||||||
|
访问令牌
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">
|
||||||
|
<template v-if="quota">
|
||||||
|
已创建 {{ quota.used }}/{{ quota.max }} 个令牌
|
||||||
|
<span
|
||||||
|
v-if="quota.used >= quota.max"
|
||||||
|
class="text-destructive font-medium"
|
||||||
|
>(已达上限)</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
用于程序化访问管理 API 的令牌
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- 新增按钮 -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="创建新令牌"
|
||||||
|
:disabled="quota ? quota.used >= quota.max : false"
|
||||||
|
@click="showCreateDialog = true"
|
||||||
|
>
|
||||||
|
<Plus class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- 刷新按钮 -->
|
||||||
|
<RefreshButton
|
||||||
|
:loading="loading"
|
||||||
|
@click="loadTokens"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="flex items-center justify-center py-12"
|
||||||
|
>
|
||||||
|
<LoadingState message="加载中..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-else-if="tokens.length === 0"
|
||||||
|
class="flex items-center justify-center py-12"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
title="暂无访问令牌"
|
||||||
|
description="创建你的第一个访问令牌开始使用管理 API"
|
||||||
|
:icon="KeyRound"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
class="shadow-lg shadow-primary/20"
|
||||||
|
@click="showCreateDialog = true"
|
||||||
|
>
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
创建访问令牌
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 桌面端表格 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="hidden md:block overflow-x-auto"
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||||
|
<TableHead class="min-w-[180px] h-12 font-semibold">
|
||||||
|
名称
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="min-w-[160px] h-12 font-semibold">
|
||||||
|
令牌
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="min-w-[80px] h-12 font-semibold text-center">
|
||||||
|
使用次数
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="min-w-[70px] h-12 font-semibold text-center">
|
||||||
|
状态
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="min-w-[100px] h-12 font-semibold">
|
||||||
|
时间
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="min-w-[100px] h-12 font-semibold text-center">
|
||||||
|
操作
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="token in paginatedTokens"
|
||||||
|
:key="token.id"
|
||||||
|
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<!-- 名称 -->
|
||||||
|
<TableCell class="py-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
class="text-sm font-semibold truncate"
|
||||||
|
:title="token.name"
|
||||||
|
>
|
||||||
|
{{ token.name }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="token.description"
|
||||||
|
class="text-xs text-muted-foreground mt-0.5 truncate"
|
||||||
|
:title="token.description"
|
||||||
|
>
|
||||||
|
{{ token.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<!-- Token 显示 -->
|
||||||
|
<TableCell class="py-4">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<code class="text-xs font-mono text-muted-foreground bg-muted/30 px-2 py-1 rounded">
|
||||||
|
{{ token.token_display }}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-6 w-6"
|
||||||
|
title="重新生成令牌"
|
||||||
|
@click="confirmRegenerate(token)"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<!-- 使用次数 -->
|
||||||
|
<TableCell class="py-4 text-center">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ formatNumber(token.usage_count || 0) }}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<!-- 状态 -->
|
||||||
|
<TableCell class="py-4 text-center">
|
||||||
|
<Badge
|
||||||
|
:variant="getStatusVariant(token)"
|
||||||
|
class="font-medium px-3 py-1"
|
||||||
|
>
|
||||||
|
{{ getStatusText(token) }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<!-- 时间 -->
|
||||||
|
<TableCell class="py-4 text-sm text-muted-foreground">
|
||||||
|
<div class="text-xs">
|
||||||
|
创建于 {{ formatDate(token.created_at) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs mt-1">
|
||||||
|
{{ token.last_used_at ? `最后使用 ${formatRelativeTime(token.last_used_at)}` : '从未使用' }}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<TableCell class="py-4">
|
||||||
|
<div class="flex justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="编辑"
|
||||||
|
@click="openEditDialog(token)"
|
||||||
|
>
|
||||||
|
<Pencil class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
:title="token.is_active ? '禁用' : '启用'"
|
||||||
|
@click="toggleToken(token)"
|
||||||
|
>
|
||||||
|
<Power class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="删除"
|
||||||
|
@click="confirmDelete(token)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端卡片列表 -->
|
||||||
|
<div
|
||||||
|
v-if="!loading && tokens.length > 0"
|
||||||
|
class="md:hidden space-y-3 p-4"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
v-for="token in paginatedTokens"
|
||||||
|
:key="token.id"
|
||||||
|
variant="default"
|
||||||
|
class="group hover:shadow-md hover:border-primary/30 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- 第一行:名称、状态、操作 -->
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground truncate">
|
||||||
|
{{ token.name }}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
:variant="getStatusVariant(token)"
|
||||||
|
class="text-xs px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{{ getStatusText(token) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="编辑"
|
||||||
|
@click="openEditDialog(token)"
|
||||||
|
>
|
||||||
|
<Pencil class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
:title="token.is_active ? '禁用' : '启用'"
|
||||||
|
@click="toggleToken(token)"
|
||||||
|
>
|
||||||
|
<Power class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="删除"
|
||||||
|
@click="confirmDelete(token)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token 显示 -->
|
||||||
|
<div class="flex items-center gap-2 text-xs mb-2">
|
||||||
|
<code class="font-mono text-muted-foreground">{{ token.token_display }}</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-5 w-5"
|
||||||
|
title="重新生成"
|
||||||
|
@click="confirmRegenerate(token)"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计信息 -->
|
||||||
|
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{{ formatNumber(token.usage_count || 0) }} 次使用</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{{ token.last_used_at ? formatRelativeTime(token.last_used_at) : '从未使用' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<Pagination
|
||||||
|
v-if="totalTokens > 0"
|
||||||
|
:current="currentPage"
|
||||||
|
:total="totalTokens"
|
||||||
|
:page-size="pageSize"
|
||||||
|
@update:current="currentPage = $event"
|
||||||
|
@update:page-size="handlePageSizeChange"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 创建/编辑 Token 对话框 -->
|
||||||
|
<Dialog
|
||||||
|
v-model="showCreateDialog"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="border-b border-border px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||||
|
<KeyRound class="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||||
|
{{ editingToken ? '编辑访问令牌' : '创建访问令牌' }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ editingToken ? '修改令牌配置' : '创建一个新的令牌用于访问管理 API' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 名称 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label
|
||||||
|
for="token-name"
|
||||||
|
class="text-sm font-semibold"
|
||||||
|
>名称 *</Label>
|
||||||
|
<Input
|
||||||
|
id="token-name"
|
||||||
|
v-model="formData.name"
|
||||||
|
placeholder="例如:CI/CD 自动化"
|
||||||
|
class="h-11 border-border/60"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label
|
||||||
|
for="token-description"
|
||||||
|
class="text-sm font-semibold"
|
||||||
|
>描述</Label>
|
||||||
|
<Input
|
||||||
|
id="token-description"
|
||||||
|
v-model="formData.description"
|
||||||
|
placeholder="用途说明(可选)"
|
||||||
|
class="h-11 border-border/60"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- IP 白名单 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label
|
||||||
|
for="token-ips"
|
||||||
|
class="text-sm font-semibold"
|
||||||
|
>IP 白名单</Label>
|
||||||
|
<Input
|
||||||
|
id="token-ips"
|
||||||
|
v-model="formData.allowedIpsText"
|
||||||
|
placeholder="例如:192.168.1.0/24, 10.0.0.1(逗号分隔,留空不限制)"
|
||||||
|
class="h-11 border-border/60"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
限制只能从指定 IP 地址使用此令牌,支持 CIDR 格式
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 过期时间 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label
|
||||||
|
for="token-expires"
|
||||||
|
class="text-sm font-semibold"
|
||||||
|
>过期时间</Label>
|
||||||
|
<Input
|
||||||
|
id="token-expires"
|
||||||
|
v-model="formData.expiresAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="h-11 border-border/60"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
留空表示永不过期
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="h-11 px-6"
|
||||||
|
@click="closeDialog"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="h-11 px-6 shadow-lg shadow-primary/20"
|
||||||
|
:disabled="saving || !isFormValid"
|
||||||
|
@click="saveToken"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="saving"
|
||||||
|
class="animate-spin h-4 w-4 mr-2"
|
||||||
|
/>
|
||||||
|
{{ saving ? '保存中...' : (editingToken ? '保存' : '创建') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 新 Token 创建成功对话框 -->
|
||||||
|
<Dialog
|
||||||
|
v-model="showTokenDialog"
|
||||||
|
size="lg"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="border-b border-border px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
|
||||||
|
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||||
|
{{ isRegenerating ? '令牌已重新生成' : '创建成功' }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
请妥善保管,此令牌只会显示一次
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">访问令牌</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
:value="newTokenValue"
|
||||||
|
readonly
|
||||||
|
class="flex-1 font-mono text-sm bg-muted/50 h-11"
|
||||||
|
@click="($event.target as HTMLInputElement)?.select()"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
class="h-11"
|
||||||
|
@click="copyToken(newTokenValue)"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<AlertTriangle class="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
此令牌只会显示一次,关闭后将无法再次查看,请妥善保管。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
class="h-10 px-5"
|
||||||
|
@click="showTokenDialog = false"
|
||||||
|
>
|
||||||
|
我已安全保存
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 删除确认对话框 -->
|
||||||
|
<AlertDialog
|
||||||
|
v-model="showDeleteDialog"
|
||||||
|
type="danger"
|
||||||
|
title="确认删除"
|
||||||
|
:description="`确定要删除令牌「${tokenToDelete?.name}」吗?此操作不可恢复。`"
|
||||||
|
confirm-text="删除"
|
||||||
|
:loading="deleting"
|
||||||
|
@confirm="deleteToken"
|
||||||
|
@cancel="showDeleteDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 重新生成确认对话框 -->
|
||||||
|
<AlertDialog
|
||||||
|
v-model="showRegenerateDialog"
|
||||||
|
type="warning"
|
||||||
|
title="确认重新生成"
|
||||||
|
:description="`重新生成后,原令牌将立即失效。确定要重新生成「${tokenToRegenerate?.name}」吗?`"
|
||||||
|
confirm-text="重新生成"
|
||||||
|
:loading="regenerating"
|
||||||
|
@confirm="regenerateToken"
|
||||||
|
@cancel="showRegenerateDialog = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
managementTokenApi,
|
||||||
|
type ManagementToken
|
||||||
|
} from '@/api/management-tokens'
|
||||||
|
import Card from '@/components/ui/card.vue'
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import Input from '@/components/ui/input.vue'
|
||||||
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import Badge from '@/components/ui/badge.vue'
|
||||||
|
import { Dialog, Pagination } from '@/components/ui'
|
||||||
|
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '@/components/ui'
|
||||||
|
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
KeyRound,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
Power,
|
||||||
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
|
AlertTriangle
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
|
const { success, error: showError } = useToast()
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const tokens = ref<ManagementToken[]>([])
|
||||||
|
const totalTokens = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const regenerating = ref(false)
|
||||||
|
|
||||||
|
// 配额信息
|
||||||
|
const quota = ref<{ used: number; max: number } | null>(null)
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const paginatedTokens = computed(() => tokens.value)
|
||||||
|
|
||||||
|
// 监听分页变化
|
||||||
|
watch([currentPage, pageSize], () => {
|
||||||
|
loadTokens()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handlePageSizeChange(newSize: number) {
|
||||||
|
pageSize.value = newSize
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const showTokenDialog = ref(false)
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const showRegenerateDialog = ref(false)
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const editingToken = ref<ManagementToken | null>(null)
|
||||||
|
const formData = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
allowedIpsText: '',
|
||||||
|
expiresAt: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const newTokenValue = ref('')
|
||||||
|
const isRegenerating = ref(false)
|
||||||
|
const tokenToDelete = ref<ManagementToken | null>(null)
|
||||||
|
const tokenToRegenerate = ref<ManagementToken | null>(null)
|
||||||
|
|
||||||
|
// 表单验证
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return formData.name.trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function getStatusVariant(token: ManagementToken): 'success' | 'secondary' | 'destructive' {
|
||||||
|
if (token.expires_at && isExpired(token.expires_at)) {
|
||||||
|
return 'destructive'
|
||||||
|
}
|
||||||
|
return token.is_active ? 'success' : 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(token: ManagementToken): string {
|
||||||
|
if (token.expires_at && isExpired(token.expires_at)) {
|
||||||
|
return '已过期'
|
||||||
|
}
|
||||||
|
return token.is_active ? '活跃' : '禁用'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(dateString: string): boolean {
|
||||||
|
return new Date(dateString) < new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
onMounted(() => {
|
||||||
|
loadTokens()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadTokens() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const skip = (currentPage.value - 1) * pageSize.value
|
||||||
|
const response = await managementTokenApi.listTokens({ skip, limit: pageSize.value })
|
||||||
|
|
||||||
|
tokens.value = response.items
|
||||||
|
totalTokens.value = response.total
|
||||||
|
|
||||||
|
if (response.quota) {
|
||||||
|
quota.value = response.quota
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前页超出范围,重置到第一页
|
||||||
|
if (tokens.value.length === 0 && currentPage.value > 1) {
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
log.error('加载 Management Tokens 失败:', err)
|
||||||
|
if (!err.response) {
|
||||||
|
showError('无法连接到服务器')
|
||||||
|
} else {
|
||||||
|
showError(`加载失败:${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开编辑对话框
|
||||||
|
function openEditDialog(token: ManagementToken) {
|
||||||
|
editingToken.value = token
|
||||||
|
formData.name = token.name
|
||||||
|
formData.description = token.description || ''
|
||||||
|
formData.allowedIpsText = (token.allowed_ips && token.allowed_ips.length > 0)
|
||||||
|
? token.allowed_ips.join(', ')
|
||||||
|
: ''
|
||||||
|
formData.expiresAt = token.expires_at
|
||||||
|
? toLocalDatetimeString(new Date(token.expires_at))
|
||||||
|
: ''
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
function closeDialog() {
|
||||||
|
showCreateDialog.value = false
|
||||||
|
editingToken.value = null
|
||||||
|
formData.name = ''
|
||||||
|
formData.description = ''
|
||||||
|
formData.allowedIpsText = ''
|
||||||
|
formData.expiresAt = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 Token
|
||||||
|
async function saveToken() {
|
||||||
|
if (!isFormValid.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const allowedIps = formData.allowedIpsText
|
||||||
|
.split(',')
|
||||||
|
.map(ip => ip.trim())
|
||||||
|
.filter(ip => ip)
|
||||||
|
|
||||||
|
// 将本地时间转换为 UTC ISO 字符串
|
||||||
|
const expiresAtUtc = formData.expiresAt
|
||||||
|
? new Date(formData.expiresAt).toISOString()
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (editingToken.value) {
|
||||||
|
// 更新
|
||||||
|
await managementTokenApi.updateToken(editingToken.value.id, {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description.trim() || null,
|
||||||
|
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
|
||||||
|
expires_at: expiresAtUtc
|
||||||
|
})
|
||||||
|
success('令牌更新成功')
|
||||||
|
} else {
|
||||||
|
// 创建
|
||||||
|
const result = await managementTokenApi.createToken({
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
allowed_ips: allowedIps.length > 0 ? allowedIps : undefined,
|
||||||
|
expires_at: expiresAtUtc
|
||||||
|
})
|
||||||
|
newTokenValue.value = result.token
|
||||||
|
isRegenerating.value = false
|
||||||
|
showTokenDialog.value = true
|
||||||
|
success('令牌创建成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog()
|
||||||
|
await loadTokens()
|
||||||
|
} catch (err: any) {
|
||||||
|
log.error('保存 Token 失败:', err)
|
||||||
|
const message = err.response?.data?.error?.message
|
||||||
|
|| err.response?.data?.detail
|
||||||
|
|| '保存失败'
|
||||||
|
showError(message)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换状态
|
||||||
|
async function toggleToken(token: ManagementToken) {
|
||||||
|
try {
|
||||||
|
const result = await managementTokenApi.toggleToken(token.id)
|
||||||
|
|
||||||
|
const index = tokens.value.findIndex(t => t.id === token.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
tokens.value[index] = result.data
|
||||||
|
}
|
||||||
|
success(result.data.is_active ? '令牌已启用' : '令牌已禁用')
|
||||||
|
} catch (err: any) {
|
||||||
|
log.error('切换状态失败:', err)
|
||||||
|
showError('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
function confirmDelete(token: ManagementToken) {
|
||||||
|
tokenToDelete.value = token
|
||||||
|
showDeleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteToken() {
|
||||||
|
if (!tokenToDelete.value) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await managementTokenApi.deleteToken(tokenToDelete.value.id)
|
||||||
|
|
||||||
|
showDeleteDialog.value = false
|
||||||
|
success('令牌已删除')
|
||||||
|
await loadTokens()
|
||||||
|
} catch (err: any) {
|
||||||
|
log.error('删除 Token 失败:', err)
|
||||||
|
showError('删除失败')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
tokenToDelete.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成
|
||||||
|
function confirmRegenerate(token: ManagementToken) {
|
||||||
|
tokenToRegenerate.value = token
|
||||||
|
showRegenerateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateToken() {
|
||||||
|
if (!tokenToRegenerate.value) return
|
||||||
|
|
||||||
|
regenerating.value = true
|
||||||
|
try {
|
||||||
|
const result = await managementTokenApi.regenerateToken(tokenToRegenerate.value.id)
|
||||||
|
newTokenValue.value = result.token
|
||||||
|
isRegenerating.value = true
|
||||||
|
showRegenerateDialog.value = false
|
||||||
|
showTokenDialog.value = true
|
||||||
|
await loadTokens()
|
||||||
|
success('令牌已重新生成')
|
||||||
|
} catch (err: any) {
|
||||||
|
log.error('重新生成失败:', err)
|
||||||
|
showError('重新生成失败')
|
||||||
|
} finally {
|
||||||
|
regenerating.value = false
|
||||||
|
tokenToRegenerate.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制 Token
|
||||||
|
async function copyToken(text: string) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
success('已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
const textArea = document.createElement('textarea')
|
||||||
|
textArea.value = text
|
||||||
|
textArea.style.position = 'fixed'
|
||||||
|
textArea.style.left = '-999999px'
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
success('已复制到剪贴板')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('复制失败:', err)
|
||||||
|
showError('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
return num.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDatetimeString(date: Date): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
if (diffMins < 1) return '刚刚'
|
||||||
|
if (diffMins < 60) return `${diffMins}分钟前`
|
||||||
|
if (diffHours < 24) return `${diffHours}小时前`
|
||||||
|
if (diffDays < 7) return `${diffDays}天前`
|
||||||
|
|
||||||
|
return formatDate(dateString)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -78,6 +78,20 @@ export default {
|
|||||||
md: "calc(var(--radius) - 2px)",
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: "calc(var(--radius) - 4px)",
|
||||||
},
|
},
|
||||||
|
keyframes: {
|
||||||
|
"collapsible-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-collapsible-content-height)" },
|
||||||
|
},
|
||||||
|
"collapsible-up": {
|
||||||
|
from: { height: "var(--radix-collapsible-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"collapsible-down": "collapsible-down 0.2s ease-out",
|
||||||
|
"collapsible-up": "collapsible-up 0.2s ease-out",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
|||||||
12
migrate.sh
12
migrate.sh
@@ -1,12 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# 数据库迁移脚本 - 在 Docker 容器内执行 Alembic 迁移
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
CONTAINER_NAME="aether-app"
|
|
||||||
|
|
||||||
echo "Running database migrations in container: $CONTAINER_NAME"
|
|
||||||
|
|
||||||
docker exec $CONTAINER_NAME alembic upgrade head
|
|
||||||
|
|
||||||
echo "Database migration completed successfully"
|
|
||||||
@@ -47,6 +47,7 @@ dependencies = [
|
|||||||
"redis>=5.0.0",
|
"redis>=5.0.0",
|
||||||
"prometheus-client>=0.20.0",
|
"prometheus-client>=0.20.0",
|
||||||
"apscheduler>=3.10.0",
|
"apscheduler>=3.10.0",
|
||||||
|
"ldap3>=2.9.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# file generated by setuptools-scm
|
|
||||||
# don't change, don't track in version control
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"__version__",
|
|
||||||
"__version_tuple__",
|
|
||||||
"version",
|
|
||||||
"version_tuple",
|
|
||||||
"__commit_id__",
|
|
||||||
"commit_id",
|
|
||||||
]
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from typing import Tuple
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
||||||
COMMIT_ID = Union[str, None]
|
|
||||||
else:
|
|
||||||
VERSION_TUPLE = object
|
|
||||||
COMMIT_ID = object
|
|
||||||
|
|
||||||
version: str
|
|
||||||
__version__: str
|
|
||||||
__version_tuple__: VERSION_TUPLE
|
|
||||||
version_tuple: VERSION_TUPLE
|
|
||||||
commit_id: COMMIT_ID
|
|
||||||
__commit_id__: COMMIT_ID
|
|
||||||
|
|
||||||
__version__ = version = '0.1.1.dev0+g393d4d13f.d20251213'
|
|
||||||
__version_tuple__ = version_tuple = (0, 1, 1, 'dev0', 'g393d4d13f.d20251213')
|
|
||||||
|
|
||||||
__commit_id__ = commit_id = None
|
|
||||||
@@ -5,6 +5,8 @@ from fastapi import APIRouter
|
|||||||
from .adaptive import router as adaptive_router
|
from .adaptive import router as adaptive_router
|
||||||
from .api_keys import router as api_keys_router
|
from .api_keys import router as api_keys_router
|
||||||
from .endpoints import router as endpoints_router
|
from .endpoints import router as endpoints_router
|
||||||
|
from .ldap import router as ldap_router
|
||||||
|
from .management_tokens import router as management_tokens_router
|
||||||
from .models import router as models_router
|
from .models import router as models_router
|
||||||
from .monitoring import router as monitoring_router
|
from .monitoring import router as monitoring_router
|
||||||
from .provider_query import router as provider_query_router
|
from .provider_query import router as provider_query_router
|
||||||
@@ -28,5 +30,7 @@ router.include_router(adaptive_router)
|
|||||||
router.include_router(models_router)
|
router.include_router(models_router)
|
||||||
router.include_router(security_router)
|
router.include_router(security_router)
|
||||||
router.include_router(provider_query_router)
|
router.include_router(provider_query_router)
|
||||||
|
router.include_router(ldap_router)
|
||||||
|
router.include_router(management_tokens_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
自适应并发管理 API 端点
|
自适应 RPM 管理 API 端点
|
||||||
|
|
||||||
设计原则:
|
设计原则:
|
||||||
- 自适应模式由 max_concurrent 字段决定:
|
- 自适应模式由 rpm_limit 字段决定:
|
||||||
- max_concurrent = NULL:启用自适应模式,系统自动学习并调整并发限制
|
- rpm_limit = NULL:启用自适应模式,系统自动学习并调整 RPM 限制
|
||||||
- max_concurrent = 数字:固定限制模式,使用用户指定的并发限制
|
- rpm_limit = 数字:固定限制模式,使用用户指定的 RPM 限制
|
||||||
- learned_max_concurrent:自适应模式下学习到的并发限制值
|
- learned_rpm_limit:自适应模式下学习到的 RPM 限制值
|
||||||
- adaptive_mode 是计算字段,基于 max_concurrent 是否为 NULL
|
- adaptive_mode 是计算字段,基于 rpm_limit 是否为 NULL
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -18,12 +18,13 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
|
from src.config.constants import RPMDefaults
|
||||||
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
|
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.database import ProviderAPIKey
|
from src.models.database import ProviderAPIKey
|
||||||
from src.services.rate_limit.adaptive_concurrency import get_adaptive_manager
|
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/adaptive", tags=["Adaptive Concurrency"])
|
router = APIRouter(prefix="/api/admin/adaptive", tags=["Adaptive RPM"])
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
@@ -35,19 +36,19 @@ class EnableAdaptiveRequest(BaseModel):
|
|||||||
|
|
||||||
enabled: bool = Field(..., description="是否启用自适应模式(true=自适应,false=固定限制)")
|
enabled: bool = Field(..., description="是否启用自适应模式(true=自适应,false=固定限制)")
|
||||||
fixed_limit: Optional[int] = Field(
|
fixed_limit: Optional[int] = Field(
|
||||||
None, ge=1, le=100, description="固定并发限制(仅当 enabled=false 时生效)"
|
None, ge=1, le=100, description="固定 RPM 限制(仅当 enabled=false 时生效,1-100)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveStatsResponse(BaseModel):
|
class AdaptiveStatsResponse(BaseModel):
|
||||||
"""自适应统计响应"""
|
"""自适应统计响应"""
|
||||||
|
|
||||||
adaptive_mode: bool = Field(..., description="是否为自适应模式(max_concurrent=NULL)")
|
adaptive_mode: bool = Field(..., description="是否为自适应模式(rpm_limit=NULL)")
|
||||||
max_concurrent: Optional[int] = Field(None, description="用户配置的固定限制(NULL=自适应)")
|
rpm_limit: Optional[int] = Field(None, description="用户配置的固定限制(NULL=自适应)")
|
||||||
effective_limit: Optional[int] = Field(
|
effective_limit: Optional[int] = Field(
|
||||||
None, description="当前有效限制(自适应使用学习值,固定使用配置值)"
|
None, description="当前有效限制(自适应使用学习值,固定使用配置值)"
|
||||||
)
|
)
|
||||||
learned_limit: Optional[int] = Field(None, description="学习到的并发限制")
|
learned_limit: Optional[int] = Field(None, description="学习到的 RPM 限制")
|
||||||
concurrent_429_count: int
|
concurrent_429_count: int
|
||||||
rpm_429_count: int
|
rpm_429_count: int
|
||||||
last_429_at: Optional[str]
|
last_429_at: Optional[str]
|
||||||
@@ -61,11 +62,12 @@ class KeyListItem(BaseModel):
|
|||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: Optional[str]
|
name: Optional[str]
|
||||||
endpoint_id: str
|
provider_id: str
|
||||||
is_adaptive: bool = Field(..., description="是否为自适应模式(max_concurrent=NULL)")
|
api_formats: List[str] = Field(default_factory=list)
|
||||||
max_concurrent: Optional[int] = Field(None, description="固定并发限制(NULL=自适应)")
|
is_adaptive: bool = Field(..., description="是否为自适应模式(rpm_limit=NULL)")
|
||||||
|
rpm_limit: Optional[int] = Field(None, description="固定 RPM 限制(NULL=自适应)")
|
||||||
effective_limit: Optional[int] = Field(None, description="当前有效限制")
|
effective_limit: Optional[int] = Field(None, description="当前有效限制")
|
||||||
learned_max_concurrent: Optional[int] = Field(None, description="学习到的并发限制")
|
learned_rpm_limit: Optional[int] = Field(None, description="学习到的 RPM 限制")
|
||||||
concurrent_429_count: int
|
concurrent_429_count: int
|
||||||
rpm_429_count: int
|
rpm_429_count: int
|
||||||
|
|
||||||
@@ -80,22 +82,22 @@ class KeyListItem(BaseModel):
|
|||||||
)
|
)
|
||||||
async def list_adaptive_keys(
|
async def list_adaptive_keys(
|
||||||
request: Request,
|
request: Request,
|
||||||
endpoint_id: Optional[str] = Query(None, description="按 Endpoint 过滤"),
|
provider_id: Optional[str] = Query(None, description="按 Provider 过滤"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取所有启用自适应模式的Key列表
|
获取所有启用自适应模式的Key列表
|
||||||
|
|
||||||
可选参数:
|
可选参数:
|
||||||
- endpoint_id: 按 Endpoint 过滤
|
- provider_id: 按 Provider 过滤
|
||||||
"""
|
"""
|
||||||
adapter = ListAdaptiveKeysAdapter(endpoint_id=endpoint_id)
|
adapter = ListAdaptiveKeysAdapter(provider_id=provider_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/keys/{key_id}/mode",
|
"/keys/{key_id}/mode",
|
||||||
summary="Toggle key's concurrency control mode",
|
summary="Toggle key's RPM control mode",
|
||||||
)
|
)
|
||||||
async def toggle_adaptive_mode(
|
async def toggle_adaptive_mode(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
@@ -103,10 +105,10 @@ async def toggle_adaptive_mode(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Toggle the concurrency control mode for a specific key
|
Toggle the RPM control mode for a specific key
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
- enabled: true=adaptive mode (max_concurrent=NULL), false=fixed limit mode
|
- enabled: true=adaptive mode (rpm_limit=NULL), false=fixed limit mode
|
||||||
- fixed_limit: fixed limit value (required when enabled=false)
|
- fixed_limit: fixed limit value (required when enabled=false)
|
||||||
"""
|
"""
|
||||||
adapter = ToggleAdaptiveModeAdapter(key_id=key_id)
|
adapter = ToggleAdaptiveModeAdapter(key_id=key_id)
|
||||||
@@ -124,7 +126,7 @@ async def get_adaptive_stats(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取指定Key的自适应并发统计信息
|
获取指定Key的自适应 RPM 统计信息
|
||||||
|
|
||||||
包括:
|
包括:
|
||||||
- 当前配置
|
- 当前配置
|
||||||
@@ -149,12 +151,12 @@ async def reset_adaptive_learning(
|
|||||||
Reset the adaptive learning state for a specific key
|
Reset the adaptive learning state for a specific key
|
||||||
|
|
||||||
Clears:
|
Clears:
|
||||||
- Learned concurrency limit (learned_max_concurrent)
|
- Learned RPM limit (learned_rpm_limit)
|
||||||
- 429 error counts
|
- 429 error counts
|
||||||
- Adjustment history
|
- Adjustment history
|
||||||
|
|
||||||
Does not change:
|
Does not change:
|
||||||
- max_concurrent config (determines adaptive mode)
|
- rpm_limit config (determines adaptive mode)
|
||||||
"""
|
"""
|
||||||
adapter = ResetAdaptiveLearningAdapter(key_id=key_id)
|
adapter = ResetAdaptiveLearningAdapter(key_id=key_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -162,40 +164,40 @@ async def reset_adaptive_learning(
|
|||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/keys/{key_id}/limit",
|
"/keys/{key_id}/limit",
|
||||||
summary="Set key to fixed concurrency limit mode",
|
summary="Set key to fixed RPM limit mode",
|
||||||
)
|
)
|
||||||
async def set_concurrent_limit(
|
async def set_rpm_limit(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(..., ge=1, le=100, description="Concurrency limit value"),
|
limit: int = Query(..., ge=1, le=100, description="RPM limit value (1-100)"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Set key to fixed concurrency limit mode
|
Set key to fixed RPM limit mode
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
- After setting this value, key switches to fixed limit mode and won't auto-adjust
|
- After setting this value, key switches to fixed limit mode and won't auto-adjust
|
||||||
- To restore adaptive mode, use PATCH /keys/{key_id}/mode
|
- To restore adaptive mode, use PATCH /keys/{key_id}/mode
|
||||||
"""
|
"""
|
||||||
adapter = SetConcurrentLimitAdapter(key_id=key_id, limit=limit)
|
adapter = SetRPMLimitAdapter(key_id=key_id, limit=limit)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/summary",
|
"/summary",
|
||||||
summary="获取自适应并发的全局统计",
|
summary="获取自适应 RPM 的全局统计",
|
||||||
)
|
)
|
||||||
async def get_adaptive_summary(
|
async def get_adaptive_summary(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取自适应并发的全局统计摘要
|
获取自适应 RPM 的全局统计摘要
|
||||||
|
|
||||||
包括:
|
包括:
|
||||||
- 启用自适应模式的Key数量
|
- 启用自适应模式的Key数量
|
||||||
- 总429错误数
|
- 总429错误数
|
||||||
- 并发限制调整次数
|
- RPM 限制调整次数
|
||||||
"""
|
"""
|
||||||
adapter = AdaptiveSummaryAdapter()
|
adapter = AdaptiveSummaryAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -206,26 +208,29 @@ async def get_adaptive_summary(
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ListAdaptiveKeysAdapter(AdminApiAdapter):
|
class ListAdaptiveKeysAdapter(AdminApiAdapter):
|
||||||
endpoint_id: Optional[str] = None
|
provider_id: Optional[str] = None
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
# 自适应模式:max_concurrent = NULL
|
# 自适应模式:rpm_limit = NULL
|
||||||
query = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.max_concurrent.is_(None))
|
query = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.rpm_limit.is_(None))
|
||||||
if self.endpoint_id:
|
if self.provider_id:
|
||||||
query = query.filter(ProviderAPIKey.endpoint_id == self.endpoint_id)
|
query = query.filter(ProviderAPIKey.provider_id == self.provider_id)
|
||||||
|
|
||||||
keys = query.all()
|
keys = query.all()
|
||||||
return [
|
return [
|
||||||
KeyListItem(
|
KeyListItem(
|
||||||
id=key.id,
|
id=key.id,
|
||||||
name=key.name,
|
name=key.name,
|
||||||
endpoint_id=key.endpoint_id,
|
provider_id=key.provider_id,
|
||||||
is_adaptive=key.max_concurrent is None,
|
api_formats=key.api_formats or [],
|
||||||
max_concurrent=key.max_concurrent,
|
is_adaptive=key.rpm_limit is None,
|
||||||
|
rpm_limit=key.rpm_limit,
|
||||||
effective_limit=(
|
effective_limit=(
|
||||||
key.learned_max_concurrent if key.max_concurrent is None else key.max_concurrent
|
(key.learned_rpm_limit if key.learned_rpm_limit is not None else RPMDefaults.INITIAL_LIMIT)
|
||||||
|
if key.rpm_limit is None
|
||||||
|
else key.rpm_limit
|
||||||
),
|
),
|
||||||
learned_max_concurrent=key.learned_max_concurrent,
|
learned_rpm_limit=key.learned_rpm_limit,
|
||||||
concurrent_429_count=key.concurrent_429_count or 0,
|
concurrent_429_count=key.concurrent_429_count or 0,
|
||||||
rpm_429_count=key.rpm_429_count or 0,
|
rpm_429_count=key.rpm_429_count or 0,
|
||||||
)
|
)
|
||||||
@@ -252,28 +257,32 @@ class ToggleAdaptiveModeAdapter(AdminApiAdapter):
|
|||||||
raise InvalidRequestException("请求数据验证失败")
|
raise InvalidRequestException("请求数据验证失败")
|
||||||
|
|
||||||
if body.enabled:
|
if body.enabled:
|
||||||
# 启用自适应模式:将 max_concurrent 设为 NULL
|
# 启用自适应模式:将 rpm_limit 设为 NULL
|
||||||
key.max_concurrent = None
|
key.rpm_limit = None
|
||||||
message = "已切换为自适应模式,系统将自动学习并调整并发限制"
|
message = "已切换为自适应模式,系统将自动学习并调整 RPM 限制"
|
||||||
else:
|
else:
|
||||||
# 禁用自适应模式:设置固定限制
|
# 禁用自适应模式:设置固定限制
|
||||||
if body.fixed_limit is None:
|
if body.fixed_limit is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="禁用自适应模式时必须提供 fixed_limit 参数"
|
status_code=400, detail="禁用自适应模式时必须提供 fixed_limit 参数"
|
||||||
)
|
)
|
||||||
key.max_concurrent = body.fixed_limit
|
key.rpm_limit = body.fixed_limit
|
||||||
message = f"已切换为固定限制模式,并发限制设为 {body.fixed_limit}"
|
message = f"已切换为固定限制模式,RPM 限制设为 {body.fixed_limit}"
|
||||||
|
|
||||||
context.db.commit()
|
context.db.commit()
|
||||||
context.db.refresh(key)
|
context.db.refresh(key)
|
||||||
|
|
||||||
is_adaptive = key.max_concurrent is None
|
is_adaptive = key.rpm_limit is None
|
||||||
return {
|
return {
|
||||||
"message": message,
|
"message": message,
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
"is_adaptive": is_adaptive,
|
"is_adaptive": is_adaptive,
|
||||||
"max_concurrent": key.max_concurrent,
|
"rpm_limit": key.rpm_limit,
|
||||||
"effective_limit": key.learned_max_concurrent if is_adaptive else key.max_concurrent,
|
"effective_limit": (
|
||||||
|
(key.learned_rpm_limit if key.learned_rpm_limit is not None else RPMDefaults.INITIAL_LIMIT)
|
||||||
|
if is_adaptive
|
||||||
|
else key.rpm_limit
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -286,13 +295,13 @@ class GetAdaptiveStatsAdapter(AdminApiAdapter):
|
|||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=404, detail="Key not found")
|
raise HTTPException(status_code=404, detail="Key not found")
|
||||||
|
|
||||||
adaptive_manager = get_adaptive_manager()
|
adaptive_manager = get_adaptive_rpm_manager()
|
||||||
stats = adaptive_manager.get_adjustment_stats(key)
|
stats = adaptive_manager.get_adjustment_stats(key)
|
||||||
|
|
||||||
# 转换字段名以匹配响应模型
|
# 转换字段名以匹配响应模型
|
||||||
return AdaptiveStatsResponse(
|
return AdaptiveStatsResponse(
|
||||||
adaptive_mode=stats["adaptive_mode"],
|
adaptive_mode=stats["adaptive_mode"],
|
||||||
max_concurrent=stats["max_concurrent"],
|
rpm_limit=stats["rpm_limit"],
|
||||||
effective_limit=stats["effective_limit"],
|
effective_limit=stats["effective_limit"],
|
||||||
learned_limit=stats["learned_limit"],
|
learned_limit=stats["learned_limit"],
|
||||||
concurrent_429_count=stats["concurrent_429_count"],
|
concurrent_429_count=stats["concurrent_429_count"],
|
||||||
@@ -313,13 +322,13 @@ class ResetAdaptiveLearningAdapter(AdminApiAdapter):
|
|||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=404, detail="Key not found")
|
raise HTTPException(status_code=404, detail="Key not found")
|
||||||
|
|
||||||
adaptive_manager = get_adaptive_manager()
|
adaptive_manager = get_adaptive_rpm_manager()
|
||||||
adaptive_manager.reset_learning(context.db, key)
|
adaptive_manager.reset_learning(context.db, key)
|
||||||
return {"message": "学习状态已重置", "key_id": key.id}
|
return {"message": "学习状态已重置", "key_id": key.id}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SetConcurrentLimitAdapter(AdminApiAdapter):
|
class SetRPMLimitAdapter(AdminApiAdapter):
|
||||||
key_id: str
|
key_id: str
|
||||||
limit: int
|
limit: int
|
||||||
|
|
||||||
@@ -328,25 +337,25 @@ class SetConcurrentLimitAdapter(AdminApiAdapter):
|
|||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=404, detail="Key not found")
|
raise HTTPException(status_code=404, detail="Key not found")
|
||||||
|
|
||||||
was_adaptive = key.max_concurrent is None
|
was_adaptive = key.rpm_limit is None
|
||||||
key.max_concurrent = self.limit
|
key.rpm_limit = self.limit
|
||||||
context.db.commit()
|
context.db.commit()
|
||||||
context.db.refresh(key)
|
context.db.refresh(key)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": f"已设置为固定限制模式,并发限制为 {self.limit}",
|
"message": f"已设置为固定限制模式,RPM 限制为 {self.limit}",
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
"is_adaptive": False,
|
"is_adaptive": False,
|
||||||
"max_concurrent": key.max_concurrent,
|
"rpm_limit": key.rpm_limit,
|
||||||
"previous_mode": "adaptive" if was_adaptive else "fixed",
|
"previous_mode": "adaptive" if was_adaptive else "fixed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveSummaryAdapter(AdminApiAdapter):
|
class AdaptiveSummaryAdapter(AdminApiAdapter):
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
# 自适应模式:max_concurrent = NULL
|
# 自适应模式:rpm_limit = NULL
|
||||||
adaptive_keys = (
|
adaptive_keys = (
|
||||||
context.db.query(ProviderAPIKey).filter(ProviderAPIKey.max_concurrent.is_(None)).all()
|
context.db.query(ProviderAPIKey).filter(ProviderAPIKey.rpm_limit.is_(None)).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
total_keys = len(adaptive_keys)
|
total_keys = len(adaptive_keys)
|
||||||
|
|||||||
@@ -3,22 +3,64 @@
|
|||||||
独立余额Key:不关联用户配额,有独立余额限制,用于给非注册用户使用。
|
独立余额Key:不关联用户配额,有独立余额限制,用于给非注册用户使用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
from src.core.exceptions import NotFoundException
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.api import CreateApiKeyRequest
|
from src.models.api import CreateApiKeyRequest
|
||||||
from src.models.database import ApiKey, User
|
from src.models.database import ApiKey
|
||||||
from src.services.user.apikey import ApiKeyService
|
from src.services.user.apikey import ApiKeyService
|
||||||
|
|
||||||
|
|
||||||
|
# 应用时区配置,默认为 Asia/Shanghai
|
||||||
|
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_expiry_date(date_str: Optional[str]) -> Optional[datetime]:
|
||||||
|
"""解析过期日期字符串为 datetime 对象。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_str: 日期字符串,支持 "YYYY-MM-DD" 或 ISO 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime 对象(当天 23:59:59.999999,应用时区),或 None 如果输入为空
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestException: 日期格式无效
|
||||||
|
"""
|
||||||
|
if not date_str or not date_str.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
date_str = date_str.strip()
|
||||||
|
|
||||||
|
# 尝试 YYYY-MM-DD 格式
|
||||||
|
try:
|
||||||
|
parsed_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
# 设置为当天结束时间 (23:59:59.999999,应用时区)
|
||||||
|
return parsed_date.replace(
|
||||||
|
hour=23, minute=59, second=59, microsecond=999999, tzinfo=APP_TIMEZONE
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 尝试完整 ISO 格式
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise InvalidRequestException(f"无效的日期格式: {date_str},请使用 YYYY-MM-DD 格式")
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"])
|
router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"])
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
@@ -31,7 +73,26 @@ async def list_standalone_api_keys(
|
|||||||
is_active: Optional[bool] = None,
|
is_active: Optional[bool] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""列出所有独立余额API Keys"""
|
"""
|
||||||
|
列出所有独立余额 API Keys
|
||||||
|
|
||||||
|
获取系统中所有独立余额 API Key 的列表。独立余额 Key 不关联用户配额,
|
||||||
|
有独立的余额限制,主要用于给非注册用户使用。
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `skip`: 跳过的记录数(分页偏移量),默认 0
|
||||||
|
- `limit`: 返回的记录数(分页限制),默认 100,最大 500
|
||||||
|
- `is_active`: 可选,根据启用状态筛选(true/false)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, current_balance_usd,
|
||||||
|
balance_used_usd, total_requests, total_cost_usd, rate_limit, allowed_providers,
|
||||||
|
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
|
||||||
|
auto_delete_on_expiry 等字段
|
||||||
|
- `total`: 符合条件的总记录数
|
||||||
|
- `limit`: 当前分页限制
|
||||||
|
- `skip`: 当前分页偏移量
|
||||||
|
"""
|
||||||
adapter = AdminListStandaloneKeysAdapter(skip=skip, limit=limit, is_active=is_active)
|
adapter = AdminListStandaloneKeysAdapter(skip=skip, limit=limit, is_active=is_active)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -42,7 +103,35 @@ async def create_standalone_api_key(
|
|||||||
key_data: CreateApiKeyRequest,
|
key_data: CreateApiKeyRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""创建独立余额API Key(必须设置余额限制)"""
|
"""
|
||||||
|
创建独立余额 API Key
|
||||||
|
|
||||||
|
创建一个新的独立余额 API Key。独立余额 Key 必须设置初始余额限制。
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `name`: API Key 的名称
|
||||||
|
- `initial_balance_usd`: 必需,初始余额(美元),必须大于 0
|
||||||
|
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||||
|
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||||
|
- `allowed_models`: 可选,允许使用的模型列表
|
||||||
|
- `rate_limit`: 可选,速率限制配置(请求数/秒)
|
||||||
|
- `expire_days`: 可选,过期天数(兼容旧版)
|
||||||
|
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days)
|
||||||
|
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: API Key ID
|
||||||
|
- `key`: 完整的 API Key(仅在创建时返回一次)
|
||||||
|
- `name`: API Key 名称
|
||||||
|
- `key_display`: 脱敏显示的 Key
|
||||||
|
- `is_standalone`: 是否为独立余额 Key(始终为 true)
|
||||||
|
- `current_balance_usd`: 当前余额
|
||||||
|
- `balance_used_usd`: 已使用余额
|
||||||
|
- `rate_limit`: 速率限制配置
|
||||||
|
- `expires_at`: 过期时间
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
- `message`: 提示信息
|
||||||
|
"""
|
||||||
adapter = AdminCreateStandaloneKeyAdapter(key_data=key_data)
|
adapter = AdminCreateStandaloneKeyAdapter(key_data=key_data)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -51,20 +140,72 @@ async def create_standalone_api_key(
|
|||||||
async def update_api_key(
|
async def update_api_key(
|
||||||
key_id: str, request: Request, key_data: CreateApiKeyRequest, db: Session = Depends(get_db)
|
key_id: str, request: Request, key_data: CreateApiKeyRequest, db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""更新独立余额Key(可修改名称、过期时间、余额限制等)"""
|
"""
|
||||||
|
更新独立余额 API Key
|
||||||
|
|
||||||
|
更新指定 ID 的独立余额 API Key 的配置信息。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `name`: 可选,API Key 的名称
|
||||||
|
- `rate_limit`: 可选,速率限制配置(null 表示无限制)
|
||||||
|
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||||
|
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||||
|
- `allowed_models`: 可选,允许使用的模型列表
|
||||||
|
- `expire_days`: 可选,过期天数(兼容旧版)
|
||||||
|
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days,null 或空字符串表示永不过期)
|
||||||
|
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: API Key ID
|
||||||
|
- `name`: API Key 名称
|
||||||
|
- `key_display`: 脱敏显示的 Key
|
||||||
|
- `is_active`: 是否启用
|
||||||
|
- `current_balance_usd`: 当前余额
|
||||||
|
- `balance_used_usd`: 已使用余额
|
||||||
|
- `rate_limit`: 速率限制配置
|
||||||
|
- `expires_at`: 过期时间
|
||||||
|
- `updated_at`: 更新时间
|
||||||
|
- `message`: 提示信息
|
||||||
|
"""
|
||||||
adapter = AdminUpdateApiKeyAdapter(key_id=key_id, key_data=key_data)
|
adapter = AdminUpdateApiKeyAdapter(key_id=key_id, key_data=key_data)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{key_id}")
|
@router.patch("/{key_id}")
|
||||||
async def toggle_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
|
async def toggle_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
"""Toggle API key active status (PATCH with is_active in body)"""
|
"""
|
||||||
|
切换 API Key 启用状态
|
||||||
|
|
||||||
|
切换指定 API Key 的启用/禁用状态。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: API Key ID
|
||||||
|
- `is_active`: 新的启用状态
|
||||||
|
- `message`: 提示信息
|
||||||
|
"""
|
||||||
adapter = AdminToggleApiKeyAdapter(key_id=key_id)
|
adapter = AdminToggleApiKeyAdapter(key_id=key_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key_id}")
|
@router.delete("/{key_id}")
|
||||||
async def delete_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
|
async def delete_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
删除 API Key
|
||||||
|
|
||||||
|
删除指定的 API Key。此操作不可逆。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 提示信息
|
||||||
|
"""
|
||||||
adapter = AdminDeleteApiKeyAdapter(key_id=key_id)
|
adapter = AdminDeleteApiKeyAdapter(key_id=key_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -75,7 +216,24 @@ async def add_balance_to_key(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Adjust balance for standalone API key (positive to add, negative to deduct)"""
|
"""
|
||||||
|
调整独立余额 API Key 的余额
|
||||||
|
|
||||||
|
为指定的独立余额 API Key 增加或扣除余额。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `amount_usd`: 调整金额(美元),正数为充值,负数为扣除
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: API Key ID
|
||||||
|
- `name`: API Key 名称
|
||||||
|
- `current_balance_usd`: 调整后的当前余额
|
||||||
|
- `balance_used_usd`: 已使用余额
|
||||||
|
- `message`: 提示信息
|
||||||
|
"""
|
||||||
# 从请求体获取调整金额
|
# 从请求体获取调整金额
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
amount_usd = body.get("amount_usd")
|
amount_usd = body.get("amount_usd")
|
||||||
@@ -120,7 +278,24 @@ async def get_api_key_detail(
|
|||||||
include_key: bool = Query(False, description="Include full decrypted key in response"),
|
include_key: bool = Query(False, description="Include full decrypted key in response"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get API key detail, optionally include full key"""
|
"""
|
||||||
|
获取 API Key 详情
|
||||||
|
|
||||||
|
获取指定 API Key 的详细信息。可选择是否返回完整的解密密钥。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `include_key`: 是否包含完整的解密密钥,默认 false
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 当 include_key=false 时,返回基本信息:id, user_id, name, key_display, is_active,
|
||||||
|
is_standalone, current_balance_usd, balance_used_usd, total_requests, total_cost_usd,
|
||||||
|
rate_limit, allowed_providers, allowed_api_formats, allowed_models, last_used_at,
|
||||||
|
expires_at, created_at, updated_at
|
||||||
|
- 当 include_key=true 时,返回完整密钥:key
|
||||||
|
"""
|
||||||
if include_key:
|
if include_key:
|
||||||
adapter = AdminGetFullKeyAdapter(key_id=key_id)
|
adapter = AdminGetFullKeyAdapter(key_id=key_id)
|
||||||
else:
|
else:
|
||||||
@@ -215,6 +390,9 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
|||||||
# 独立Key需要关联到管理员用户(从context获取)
|
# 独立Key需要关联到管理员用户(从context获取)
|
||||||
admin_user_id = context.user.id
|
admin_user_id = context.user.id
|
||||||
|
|
||||||
|
# 解析过期时间(优先使用 expires_at,其次使用 expire_days)
|
||||||
|
expires_at_dt = parse_expiry_date(self.key_data.expires_at)
|
||||||
|
|
||||||
# 创建独立Key
|
# 创建独立Key
|
||||||
api_key, plain_key = ApiKeyService.create_api_key(
|
api_key, plain_key = ApiKeyService.create_api_key(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -224,7 +402,8 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
|||||||
allowed_api_formats=self.key_data.allowed_api_formats,
|
allowed_api_formats=self.key_data.allowed_api_formats,
|
||||||
allowed_models=self.key_data.allowed_models,
|
allowed_models=self.key_data.allowed_models,
|
||||||
rate_limit=self.key_data.rate_limit, # None 表示不限制
|
rate_limit=self.key_data.rate_limit, # None 表示不限制
|
||||||
expire_days=self.key_data.expire_days,
|
expire_days=self.key_data.expire_days, # 兼容旧版
|
||||||
|
expires_at=expires_at_dt, # 优先使用
|
||||||
initial_balance_usd=self.key_data.initial_balance_usd,
|
initial_balance_usd=self.key_data.initial_balance_usd,
|
||||||
is_standalone=True, # 标记为独立Key
|
is_standalone=True, # 标记为独立Key
|
||||||
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
|
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
|
||||||
@@ -270,7 +449,8 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
|||||||
update_data = {}
|
update_data = {}
|
||||||
if self.key_data.name is not None:
|
if self.key_data.name is not None:
|
||||||
update_data["name"] = self.key_data.name
|
update_data["name"] = self.key_data.name
|
||||||
if self.key_data.rate_limit is not None:
|
# rate_limit: 显式传递时更新(包括 null 表示无限制)
|
||||||
|
if "rate_limit" in self.key_data.model_fields_set:
|
||||||
update_data["rate_limit"] = self.key_data.rate_limit
|
update_data["rate_limit"] = self.key_data.rate_limit
|
||||||
if (
|
if (
|
||||||
hasattr(self.key_data, "auto_delete_on_expiry")
|
hasattr(self.key_data, "auto_delete_on_expiry")
|
||||||
@@ -287,18 +467,20 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
|||||||
update_data["allowed_models"] = self.key_data.allowed_models
|
update_data["allowed_models"] = self.key_data.allowed_models
|
||||||
|
|
||||||
# 处理过期时间
|
# 处理过期时间
|
||||||
if self.key_data.expire_days is not None:
|
# 优先使用 expires_at(如果显式传递且有值)
|
||||||
if self.key_data.expire_days > 0:
|
if self.key_data.expires_at and self.key_data.expires_at.strip():
|
||||||
from datetime import timedelta
|
update_data["expires_at"] = parse_expiry_date(self.key_data.expires_at)
|
||||||
|
elif "expires_at" in self.key_data.model_fields_set:
|
||||||
|
# expires_at 明确传递为 null 或空字符串,设为永不过期
|
||||||
|
update_data["expires_at"] = None
|
||||||
|
# 兼容旧版 expire_days
|
||||||
|
elif "expire_days" in self.key_data.model_fields_set:
|
||||||
|
if self.key_data.expire_days is not None and self.key_data.expire_days > 0:
|
||||||
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
|
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
|
||||||
days=self.key_data.expire_days
|
days=self.key_data.expire_days
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# expire_days = 0 或负数表示永不过期
|
# expire_days = None/0/负数 表示永不过期
|
||||||
update_data["expires_at"] = None
|
|
||||||
elif hasattr(self.key_data, "expire_days") and self.key_data.expire_days is None:
|
|
||||||
# 明确传递 None,设为永不过期
|
|
||||||
update_data["expires_at"] = None
|
update_data["expires_at"] = None
|
||||||
|
|
||||||
# 使用 ApiKeyService 更新
|
# 使用 ApiKeyService 更新
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from .health import router as health_router
|
|||||||
from .keys import router as keys_router
|
from .keys import router as keys_router
|
||||||
from .routes import router as routes_router
|
from .routes import router as routes_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/endpoints", tags=["Endpoint Management"])
|
router = APIRouter(prefix="/api/admin/endpoints", tags=["Admin - Endpoints"])
|
||||||
|
|
||||||
# Endpoint CRUD
|
# Endpoint CRUD
|
||||||
router.include_router(routes_router)
|
router.include_router(routes_router)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Endpoint 并发控制管理 API
|
Key RPM 限制管理 API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -12,47 +11,56 @@ from src.api.base.admin_adapter import AdminApiAdapter
|
|||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
from src.core.exceptions import NotFoundException
|
from src.core.exceptions import NotFoundException
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.database import ProviderAPIKey, ProviderEndpoint
|
from src.models.database import ProviderAPIKey
|
||||||
from src.models.endpoint_models import (
|
from src.models.endpoint_models import KeyRpmStatusResponse
|
||||||
ConcurrencyStatusResponse,
|
|
||||||
ResetConcurrencyRequest,
|
|
||||||
)
|
|
||||||
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
|
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
|
||||||
|
|
||||||
router = APIRouter(tags=["Concurrency Control"])
|
router = APIRouter(tags=["RPM Control"])
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/concurrency/endpoint/{endpoint_id}", response_model=ConcurrencyStatusResponse)
|
@router.get("/rpm/key/{key_id}", response_model=KeyRpmStatusResponse)
|
||||||
async def get_endpoint_concurrency(
|
async def get_key_rpm(
|
||||||
endpoint_id: str,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> ConcurrencyStatusResponse:
|
|
||||||
"""获取 Endpoint 当前并发状态"""
|
|
||||||
adapter = AdminEndpointConcurrencyAdapter(endpoint_id=endpoint_id)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/concurrency/key/{key_id}", response_model=ConcurrencyStatusResponse)
|
|
||||||
async def get_key_concurrency(
|
|
||||||
key_id: str,
|
key_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> ConcurrencyStatusResponse:
|
) -> KeyRpmStatusResponse:
|
||||||
"""获取 Key 当前并发状态"""
|
"""
|
||||||
adapter = AdminKeyConcurrencyAdapter(key_id=key_id)
|
获取 Key 当前 RPM 状态
|
||||||
|
|
||||||
|
查询指定 API Key 的实时 RPM 使用情况,包括当前 RPM 计数和最大 RPM 限制。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
- `current_rpm`: 当前 RPM 计数
|
||||||
|
- `rpm_limit`: RPM 限制
|
||||||
|
"""
|
||||||
|
adapter = AdminKeyRpmAdapter(key_id=key_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/concurrency")
|
@router.delete("/rpm/key/{key_id}")
|
||||||
async def reset_concurrency(
|
async def reset_key_rpm(
|
||||||
request: ResetConcurrencyRequest,
|
key_id: str,
|
||||||
http_request: Request,
|
http_request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Reset concurrency counters (admin function, use with caution)"""
|
"""
|
||||||
adapter = AdminResetConcurrencyAdapter(endpoint_id=request.endpoint_id, key_id=request.key_id)
|
重置 Key RPM 计数器
|
||||||
|
|
||||||
|
重置指定 API Key 的 RPM 计数器,用于解决计数不准确的问题。
|
||||||
|
管理员功能,请谨慎使用。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
"""
|
||||||
|
adapter = AdminResetKeyRpmAdapter(key_id=key_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=http_request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=http_request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,31 +68,7 @@ async def reset_concurrency(
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminEndpointConcurrencyAdapter(AdminApiAdapter):
|
class AdminKeyRpmAdapter(AdminApiAdapter):
|
||||||
endpoint_id: str
|
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
|
||||||
db = context.db
|
|
||||||
endpoint = (
|
|
||||||
db.query(ProviderEndpoint).filter(ProviderEndpoint.id == self.endpoint_id).first()
|
|
||||||
)
|
|
||||||
if not endpoint:
|
|
||||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
|
||||||
|
|
||||||
concurrency_manager = await get_concurrency_manager()
|
|
||||||
endpoint_count, _ = await concurrency_manager.get_current_concurrency(
|
|
||||||
endpoint_id=self.endpoint_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return ConcurrencyStatusResponse(
|
|
||||||
endpoint_id=self.endpoint_id,
|
|
||||||
endpoint_current_concurrency=endpoint_count,
|
|
||||||
endpoint_max_concurrent=endpoint.max_concurrent,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AdminKeyConcurrencyAdapter(AdminApiAdapter):
|
|
||||||
key_id: str
|
key_id: str
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
@@ -94,23 +78,20 @@ class AdminKeyConcurrencyAdapter(AdminApiAdapter):
|
|||||||
raise NotFoundException(f"Key {self.key_id} 不存在")
|
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||||
|
|
||||||
concurrency_manager = await get_concurrency_manager()
|
concurrency_manager = await get_concurrency_manager()
|
||||||
_, key_count = await concurrency_manager.get_current_concurrency(key_id=self.key_id)
|
key_count = await concurrency_manager.get_key_rpm_count(key_id=self.key_id)
|
||||||
|
|
||||||
return ConcurrencyStatusResponse(
|
return KeyRpmStatusResponse(
|
||||||
key_id=self.key_id,
|
key_id=self.key_id,
|
||||||
key_current_concurrency=key_count,
|
current_rpm=key_count,
|
||||||
key_max_concurrent=key.max_concurrent,
|
rpm_limit=key.rpm_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminResetConcurrencyAdapter(AdminApiAdapter):
|
class AdminResetKeyRpmAdapter(AdminApiAdapter):
|
||||||
endpoint_id: Optional[str]
|
key_id: str
|
||||||
key_id: Optional[str]
|
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
concurrency_manager = await get_concurrency_manager()
|
concurrency_manager = await get_concurrency_manager()
|
||||||
await concurrency_manager.reset_concurrency(
|
await concurrency_manager.reset_key_rpm(key_id=self.key_id)
|
||||||
endpoint_id=self.endpoint_id, key_id=self.key_id
|
return {"message": "RPM 计数已重置"}
|
||||||
)
|
|
||||||
return {"message": "并发计数已重置"}
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Endpoint 健康监控 API
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -36,7 +36,20 @@ async def get_health_summary(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> HealthSummaryResponse:
|
) -> HealthSummaryResponse:
|
||||||
"""获取健康状态摘要"""
|
"""
|
||||||
|
获取健康状态摘要
|
||||||
|
|
||||||
|
获取系统整体健康状态摘要,包括所有 Provider、Endpoint 和 Key 的健康状态统计。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `total_providers`: Provider 总数
|
||||||
|
- `active_providers`: 活跃 Provider 数量
|
||||||
|
- `total_endpoints`: Endpoint 总数
|
||||||
|
- `active_endpoints`: 活跃 Endpoint 数量
|
||||||
|
- `total_keys`: Key 总数
|
||||||
|
- `active_keys`: 活跃 Key 数量
|
||||||
|
- `circuit_breaker_open_keys`: 熔断的 Key 数量
|
||||||
|
"""
|
||||||
adapter = AdminHealthSummaryAdapter()
|
adapter = AdminHealthSummaryAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -50,9 +63,21 @@ async def get_endpoint_health_status(
|
|||||||
"""
|
"""
|
||||||
获取端点健康状态(简化视图,与用户端点统一)
|
获取端点健康状态(简化视图,与用户端点统一)
|
||||||
|
|
||||||
|
获取按 API 格式聚合的端点健康状态时间线,基于 Usage 表统计,
|
||||||
|
返回 50 个时间段的聚合状态,适用于快速查看整体健康趋势。
|
||||||
|
|
||||||
与 /health/api-formats 的区别:
|
与 /health/api-formats 的区别:
|
||||||
- /health/status: 返回聚合的时间线状态(50个时间段),基于 Usage 表
|
- /health/status: 返回聚合的时间线状态(50个时间段),基于 Usage 表
|
||||||
- /health/api-formats: 返回详细的事件列表,基于 RequestCandidate 表
|
- /health/api-formats: 返回详细的事件列表,基于 RequestCandidate 表
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `lookback_hours`: 回溯的小时数(1-72),默认 6
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `api_format`: API 格式名称
|
||||||
|
- `timeline`: 时间线数据(50个时间段)
|
||||||
|
- `time_range_start`: 时间范围起始
|
||||||
|
- `time_range_end`: 时间范围结束
|
||||||
"""
|
"""
|
||||||
adapter = AdminEndpointHealthStatusAdapter(lookback_hours=lookback_hours)
|
adapter = AdminEndpointHealthStatusAdapter(lookback_hours=lookback_hours)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -65,7 +90,33 @@ async def get_api_format_health_monitor(
|
|||||||
per_format_limit: int = Query(60, ge=10, le=200, description="每个 API 格式的事件数量"),
|
per_format_limit: int = Query(60, ge=10, le=200, description="每个 API 格式的事件数量"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> ApiFormatHealthMonitorResponse:
|
) -> ApiFormatHealthMonitorResponse:
|
||||||
"""获取按 API 格式聚合的健康监控时间线(详细事件列表)"""
|
"""
|
||||||
|
获取按 API 格式聚合的健康监控时间线(详细事件列表)
|
||||||
|
|
||||||
|
获取每个 API 格式的详细健康监控数据,包括请求事件列表、成功率统计、
|
||||||
|
时间线数据等,基于 RequestCandidate 表查询,适用于详细分析。
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `lookback_hours`: 回溯的小时数(1-72),默认 6
|
||||||
|
- `per_format_limit`: 每个 API 格式返回的事件数量(10-200),默认 60
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `generated_at`: 数据生成时间
|
||||||
|
- `formats`: API 格式健康监控数据列表
|
||||||
|
- `api_format`: API 格式名称
|
||||||
|
- `total_attempts`: 总请求数
|
||||||
|
- `success_count`: 成功请求数
|
||||||
|
- `failed_count`: 失败请求数
|
||||||
|
- `skipped_count`: 跳过请求数
|
||||||
|
- `success_rate`: 成功率
|
||||||
|
- `provider_count`: Provider 数量
|
||||||
|
- `key_count`: Key 数量
|
||||||
|
- `last_event_at`: 最后事件时间
|
||||||
|
- `events`: 事件列表
|
||||||
|
- `timeline`: 时间线数据
|
||||||
|
- `time_range_start`: 时间范围起始
|
||||||
|
- `time_range_end`: 时间范围结束
|
||||||
|
"""
|
||||||
adapter = AdminApiFormatHealthMonitorAdapter(
|
adapter = AdminApiFormatHealthMonitorAdapter(
|
||||||
lookback_hours=lookback_hours,
|
lookback_hours=lookback_hours,
|
||||||
per_format_limit=per_format_limit,
|
per_format_limit=per_format_limit,
|
||||||
@@ -77,10 +128,32 @@ async def get_api_format_health_monitor(
|
|||||||
async def get_key_health(
|
async def get_key_health(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
api_format: Optional[str] = Query(None, description="API 格式(可选,如 CLAUDE、OPENAI)"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> HealthStatusResponse:
|
) -> HealthStatusResponse:
|
||||||
"""获取 Key 健康状态"""
|
"""
|
||||||
adapter = AdminKeyHealthAdapter(key_id=key_id)
|
获取 Key 健康状态
|
||||||
|
|
||||||
|
获取指定 API Key 的健康状态详情,包括健康分数、连续失败次数、
|
||||||
|
熔断器状态等信息。支持按 API 格式查询。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `api_format`: 可选,指定 API 格式(如 CLAUDE、OPENAI)。
|
||||||
|
- 指定时返回该格式的健康度详情
|
||||||
|
- 不指定时返回所有格式的健康度摘要
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
- `key_health_score`: 健康分数(0.0-1.0)
|
||||||
|
- `key_is_active`: 是否活跃
|
||||||
|
- `key_statistics`: 统计信息
|
||||||
|
- `health_by_format`: 按格式的健康度数据(无 api_format 参数时)
|
||||||
|
- `circuit_breaker_open`: 熔断器是否打开(有 api_format 参数时)
|
||||||
|
"""
|
||||||
|
adapter = AdminKeyHealthAdapter(key_id=key_id, api_format=api_format)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,18 +161,31 @@ async def get_key_health(
|
|||||||
async def recover_key_health(
|
async def recover_key_health(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
api_format: Optional[str] = Query(None, description="API 格式(可选,不指定则恢复所有格式)"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Recover key health status
|
恢复 Key 健康状态
|
||||||
|
|
||||||
Resets health_score to 1.0, closes circuit breaker,
|
手动恢复指定 Key 的健康状态,将健康分数重置为 1.0,关闭熔断器,
|
||||||
cancels auto-disable, and resets all failure counts.
|
取消自动禁用,并重置所有失败计数。支持按 API 格式恢复。
|
||||||
|
|
||||||
Parameters:
|
**路径参数**:
|
||||||
- key_id: Key ID (path parameter)
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `api_format`: 可选,指定 API 格式(如 CLAUDE、OPENAI)
|
||||||
|
- 指定时仅恢复该格式的健康度
|
||||||
|
- 不指定时恢复所有格式
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `details`: 详细信息
|
||||||
|
- `health_score`: 健康分数
|
||||||
|
- `circuit_breaker_open`: 熔断器状态
|
||||||
|
- `is_active`: 是否活跃
|
||||||
"""
|
"""
|
||||||
adapter = AdminRecoverKeyHealthAdapter(key_id=key_id)
|
adapter = AdminRecoverKeyHealthAdapter(key_id=key_id, api_format=api_format)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,12 +195,21 @@ async def recover_all_keys_health(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Batch recover all circuit-broken keys
|
批量恢复所有熔断 Key 的健康状态
|
||||||
|
|
||||||
Finds all keys with circuit_breaker_open=True and:
|
查找所有处于熔断状态的 Key(circuit_breaker_open=True),
|
||||||
1. Resets health_score to 1.0
|
并批量执行以下操作:
|
||||||
2. Closes circuit breaker
|
1. 将健康分数重置为 1.0
|
||||||
3. Resets failure counts
|
2. 关闭熔断器
|
||||||
|
3. 重置失败计数
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `recovered_count`: 恢复的 Key 数量
|
||||||
|
- `recovered_keys`: 恢复的 Key 列表
|
||||||
|
- `key_id`: Key ID
|
||||||
|
- `key_name`: Key 名称
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
"""
|
"""
|
||||||
adapter = AdminRecoverAllKeysHealthAdapter()
|
adapter = AdminRecoverAllKeysHealthAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -190,34 +285,9 @@ class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
|
|||||||
)
|
)
|
||||||
all_formats[api_format] = provider_count
|
all_formats[api_format] = provider_count
|
||||||
|
|
||||||
# 1.1 获取所有活跃的 API 格式及其 API Key 数量
|
# 1.1 建立每个 API 格式对应的 Endpoint ID 列表(用于时间线生成),并收集活跃的 provider+format 组合
|
||||||
active_keys = (
|
|
||||||
db.query(
|
|
||||||
ProviderEndpoint.api_format,
|
|
||||||
func.count(ProviderAPIKey.id).label("key_count"),
|
|
||||||
)
|
|
||||||
.join(ProviderAPIKey, ProviderEndpoint.id == ProviderAPIKey.endpoint_id)
|
|
||||||
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
|
||||||
.filter(
|
|
||||||
ProviderEndpoint.is_active.is_(True),
|
|
||||||
Provider.is_active.is_(True),
|
|
||||||
ProviderAPIKey.is_active.is_(True),
|
|
||||||
)
|
|
||||||
.group_by(ProviderEndpoint.api_format)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 构建所有格式的 key_count 映射
|
|
||||||
key_counts: Dict[str, int] = {}
|
|
||||||
for api_format_enum, key_count in active_keys:
|
|
||||||
api_format = (
|
|
||||||
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
|
|
||||||
)
|
|
||||||
key_counts[api_format] = key_count
|
|
||||||
|
|
||||||
# 1.2 建立每个 API 格式对应的 Endpoint ID 列表,供 Usage 时间线生成使用
|
|
||||||
endpoint_rows = (
|
endpoint_rows = (
|
||||||
db.query(ProviderEndpoint.api_format, ProviderEndpoint.id)
|
db.query(ProviderEndpoint.api_format, ProviderEndpoint.id, ProviderEndpoint.provider_id)
|
||||||
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||||
.filter(
|
.filter(
|
||||||
ProviderEndpoint.is_active.is_(True),
|
ProviderEndpoint.is_active.is_(True),
|
||||||
@@ -226,11 +296,32 @@ class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
endpoint_map: Dict[str, List[str]] = defaultdict(list)
|
endpoint_map: Dict[str, List[str]] = defaultdict(list)
|
||||||
for api_format_enum, endpoint_id in endpoint_rows:
|
active_provider_formats: set[tuple[str, str]] = set()
|
||||||
|
for api_format_enum, endpoint_id, provider_id in endpoint_rows:
|
||||||
api_format = (
|
api_format = (
|
||||||
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
|
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
|
||||||
)
|
)
|
||||||
endpoint_map[api_format].append(endpoint_id)
|
endpoint_map[api_format].append(endpoint_id)
|
||||||
|
active_provider_formats.add((str(provider_id), api_format))
|
||||||
|
|
||||||
|
# 1.2 统计每个 API 格式可用的活跃 Key 数量(Key 属于 Provider,通过 api_formats 关联格式)
|
||||||
|
key_counts: Dict[str, int] = {}
|
||||||
|
if active_provider_formats:
|
||||||
|
active_provider_keys = (
|
||||||
|
db.query(ProviderAPIKey.provider_id, ProviderAPIKey.api_formats)
|
||||||
|
.join(Provider, ProviderAPIKey.provider_id == Provider.id)
|
||||||
|
.filter(
|
||||||
|
Provider.is_active.is_(True),
|
||||||
|
ProviderAPIKey.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for provider_id, api_formats in active_provider_keys:
|
||||||
|
pid = str(provider_id)
|
||||||
|
for fmt in (api_formats or []):
|
||||||
|
if (pid, fmt) not in active_provider_formats:
|
||||||
|
continue
|
||||||
|
key_counts[fmt] = key_counts.get(fmt, 0) + 1
|
||||||
|
|
||||||
# 2. 统计窗口内每个 API 格式的请求状态分布(真实统计)
|
# 2. 统计窗口内每个 API 格式的请求状态分布(真实统计)
|
||||||
# 只统计最终状态:success, failed, skipped
|
# 只统计最终状态:success, failed, skipped
|
||||||
@@ -371,28 +462,45 @@ class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class AdminKeyHealthAdapter(AdminApiAdapter):
|
class AdminKeyHealthAdapter(AdminApiAdapter):
|
||||||
key_id: str
|
key_id: str
|
||||||
|
api_format: Optional[str] = None
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
health_data = health_monitor.get_key_health(context.db, self.key_id)
|
health_data = health_monitor.get_key_health(context.db, self.key_id, self.api_format)
|
||||||
if not health_data:
|
if not health_data:
|
||||||
raise NotFoundException(f"Key {self.key_id} 不存在")
|
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||||
|
|
||||||
return HealthStatusResponse(
|
# 构建响应
|
||||||
key_id=health_data["key_id"],
|
response_data = {
|
||||||
key_health_score=health_data["health_score"],
|
"key_id": health_data["key_id"],
|
||||||
key_consecutive_failures=health_data["consecutive_failures"],
|
"key_is_active": health_data["is_active"],
|
||||||
key_last_failure_at=health_data["last_failure_at"],
|
"key_statistics": health_data.get("statistics"),
|
||||||
key_is_active=health_data["is_active"],
|
"key_health_score": health_data.get("health_score", 1.0),
|
||||||
key_statistics=health_data["statistics"],
|
}
|
||||||
circuit_breaker_open=health_data["circuit_breaker_open"],
|
|
||||||
circuit_breaker_open_at=health_data["circuit_breaker_open_at"],
|
if self.api_format:
|
||||||
next_probe_at=health_data["next_probe_at"],
|
# 单格式查询
|
||||||
)
|
response_data["api_format"] = self.api_format
|
||||||
|
response_data["key_consecutive_failures"] = health_data.get("consecutive_failures")
|
||||||
|
response_data["key_last_failure_at"] = health_data.get("last_failure_at")
|
||||||
|
circuit = health_data.get("circuit_breaker", {})
|
||||||
|
response_data["circuit_breaker_open"] = circuit.get("open", False)
|
||||||
|
response_data["circuit_breaker_open_at"] = circuit.get("open_at")
|
||||||
|
response_data["next_probe_at"] = circuit.get("next_probe_at")
|
||||||
|
response_data["half_open_until"] = circuit.get("half_open_until")
|
||||||
|
response_data["half_open_successes"] = circuit.get("half_open_successes", 0)
|
||||||
|
response_data["half_open_failures"] = circuit.get("half_open_failures", 0)
|
||||||
|
else:
|
||||||
|
# 全格式查询
|
||||||
|
response_data["any_circuit_open"] = health_data.get("any_circuit_open", False)
|
||||||
|
response_data["health_by_format"] = health_data.get("health_by_format")
|
||||||
|
|
||||||
|
return HealthStatusResponse(**response_data)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminRecoverKeyHealthAdapter(AdminApiAdapter):
|
class AdminRecoverKeyHealthAdapter(AdminApiAdapter):
|
||||||
key_id: str
|
key_id: str
|
||||||
|
api_format: Optional[str] = None
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
@@ -400,22 +508,32 @@ class AdminRecoverKeyHealthAdapter(AdminApiAdapter):
|
|||||||
if not key:
|
if not key:
|
||||||
raise NotFoundException(f"Key {self.key_id} 不存在")
|
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||||
|
|
||||||
key.health_score = 1.0
|
# 使用 health_monitor.reset_health 重置健康度
|
||||||
key.consecutive_failures = 0
|
success = health_monitor.reset_health(db, key_id=self.key_id, api_format=self.api_format)
|
||||||
key.last_failure_at = None
|
if not success:
|
||||||
key.circuit_breaker_open = False
|
raise Exception("重置健康度失败")
|
||||||
key.circuit_breaker_open_at = None
|
|
||||||
key.next_probe_at = None
|
# 如果 Key 被禁用,重新启用
|
||||||
if not key.is_active:
|
if not key.is_active:
|
||||||
key.is_active = True
|
key.is_active = True # type: ignore[assignment]
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
admin_name = context.user.username if context.user else "admin"
|
if self.api_format:
|
||||||
logger.info(f"管理员恢复Key健康状态: {self.key_id} (health_score: 1.0, circuit_breaker: closed)")
|
logger.info(f"管理员恢复Key健康状态: {self.key_id}/{self.api_format}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Key已完全恢复",
|
"message": f"Key 的 {self.api_format} 格式已恢复",
|
||||||
|
"details": {
|
||||||
|
"api_format": self.api_format,
|
||||||
|
"health_score": 1.0,
|
||||||
|
"circuit_breaker_open": False,
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.info(f"管理员恢复Key健康状态: {self.key_id} (所有格式)")
|
||||||
|
return {
|
||||||
|
"message": "Key 所有格式已恢复",
|
||||||
"details": {
|
"details": {
|
||||||
"health_score": 1.0,
|
"health_score": 1.0,
|
||||||
"circuit_breaker_open": False,
|
"circuit_breaker_open": False,
|
||||||
@@ -430,10 +548,17 @@ class AdminRecoverAllKeysHealthAdapter(AdminApiAdapter):
|
|||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
|
|
||||||
# 查找所有熔断的 Key
|
# 查找所有有熔断格式的 Key(检查 circuit_breaker_by_format JSON 字段)
|
||||||
circuit_open_keys = (
|
all_keys = db.query(ProviderAPIKey).all()
|
||||||
db.query(ProviderAPIKey).filter(ProviderAPIKey.circuit_breaker_open == True).all()
|
|
||||||
)
|
# 筛选出有任何格式熔断的 Key
|
||||||
|
circuit_open_keys = []
|
||||||
|
for key in all_keys:
|
||||||
|
circuit_by_format = key.circuit_breaker_by_format or {}
|
||||||
|
for fmt, circuit_data in circuit_by_format.items():
|
||||||
|
if circuit_data.get("open"):
|
||||||
|
circuit_open_keys.append(key)
|
||||||
|
break
|
||||||
|
|
||||||
if not circuit_open_keys:
|
if not circuit_open_keys:
|
||||||
return {
|
return {
|
||||||
@@ -444,17 +569,15 @@ class AdminRecoverAllKeysHealthAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
recovered_keys = []
|
recovered_keys = []
|
||||||
for key in circuit_open_keys:
|
for key in circuit_open_keys:
|
||||||
key.health_score = 1.0
|
# 重置所有格式的健康度
|
||||||
key.consecutive_failures = 0
|
key.health_by_format = {} # type: ignore[assignment]
|
||||||
key.last_failure_at = None
|
key.circuit_breaker_by_format = {} # type: ignore[assignment]
|
||||||
key.circuit_breaker_open = False
|
|
||||||
key.circuit_breaker_open_at = None
|
|
||||||
key.next_probe_at = None
|
|
||||||
recovered_keys.append(
|
recovered_keys.append(
|
||||||
{
|
{
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
"key_name": key.name,
|
"key_name": key.name,
|
||||||
"endpoint_id": key.endpoint_id,
|
"provider_id": key.provider_id,
|
||||||
|
"api_formats": key.api_formats,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -466,7 +589,6 @@ class AdminRecoverAllKeysHealthAdapter(AdminApiAdapter):
|
|||||||
HealthMonitor._open_circuit_keys = 0
|
HealthMonitor._open_circuit_keys = 0
|
||||||
health_open_circuits.set(0)
|
health_open_circuits.set(0)
|
||||||
|
|
||||||
admin_name = context.user.username if context.user else "admin"
|
|
||||||
logger.info(f"管理员批量恢复 {len(recovered_keys)} 个 Key 的健康状态")
|
logger.info(f"管理员批量恢复 {len(recovered_keys)} 个 Key 的健康状态")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Endpoint API Keys 管理
|
Provider API Keys 管理
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
@@ -12,52 +12,23 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
|
from src.config.constants import RPMDefaults
|
||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
from src.core.key_capabilities import get_capability
|
from src.core.key_capabilities import get_capability
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||||
|
from src.services.cache.provider_cache import ProviderCacheService
|
||||||
from src.models.endpoint_models import (
|
from src.models.endpoint_models import (
|
||||||
BatchUpdateKeyPriorityRequest,
|
|
||||||
EndpointAPIKeyCreate,
|
EndpointAPIKeyCreate,
|
||||||
EndpointAPIKeyResponse,
|
EndpointAPIKeyResponse,
|
||||||
EndpointAPIKeyUpdate,
|
EndpointAPIKeyUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["Endpoint Keys"])
|
router = APIRouter(tags=["Provider Keys"])
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{endpoint_id}/keys", response_model=List[EndpointAPIKeyResponse])
|
|
||||||
async def list_endpoint_keys(
|
|
||||||
endpoint_id: str,
|
|
||||||
request: Request,
|
|
||||||
skip: int = Query(0, ge=0, description="跳过的记录数"),
|
|
||||||
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> List[EndpointAPIKeyResponse]:
|
|
||||||
"""获取 Endpoint 的所有 Keys"""
|
|
||||||
adapter = AdminListEndpointKeysAdapter(
|
|
||||||
endpoint_id=endpoint_id,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{endpoint_id}/keys", response_model=EndpointAPIKeyResponse)
|
|
||||||
async def add_endpoint_key(
|
|
||||||
endpoint_id: str,
|
|
||||||
key_data: EndpointAPIKeyCreate,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> EndpointAPIKeyResponse:
|
|
||||||
"""为 Endpoint 添加 Key"""
|
|
||||||
adapter = AdminCreateEndpointKeyAdapter(endpoint_id=endpoint_id, key_data=key_data)
|
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/keys/{key_id}", response_model=EndpointAPIKeyResponse)
|
@router.put("/keys/{key_id}", response_model=EndpointAPIKeyResponse)
|
||||||
async def update_endpoint_key(
|
async def update_endpoint_key(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
@@ -65,7 +36,29 @@ async def update_endpoint_key(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> EndpointAPIKeyResponse:
|
) -> EndpointAPIKeyResponse:
|
||||||
"""更新 Endpoint Key"""
|
"""
|
||||||
|
更新 Provider Key
|
||||||
|
|
||||||
|
更新指定 Key 的配置,支持修改并发限制、速率倍数、优先级、
|
||||||
|
配额限制、能力限制等。支持部分更新。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: Key ID
|
||||||
|
|
||||||
|
**请求体字段**(均为可选):
|
||||||
|
- `api_key`: 新的 API Key 原文
|
||||||
|
- `name`: Key 名称
|
||||||
|
- `note`: 备注
|
||||||
|
- `rate_multiplier`: 速率倍数
|
||||||
|
- `internal_priority`: 内部优先级
|
||||||
|
- `rpm_limit`: RPM 限制(设置为 null 可切换到自适应模式)
|
||||||
|
- `allowed_models`: 允许的模型列表
|
||||||
|
- `capabilities`: 能力配置
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 包含更新后的完整 Key 信息
|
||||||
|
"""
|
||||||
adapter = AdminUpdateEndpointKeyAdapter(key_id=key_id, key_data=key_data)
|
adapter = AdminUpdateEndpointKeyAdapter(key_id=key_id, key_data=key_data)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -75,7 +68,31 @@ async def get_keys_grouped_by_format(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""获取按 API 格式分组的所有 Keys(用于全局优先级管理)"""
|
"""
|
||||||
|
获取按 API 格式分组的所有 Keys
|
||||||
|
|
||||||
|
获取所有活跃的 Key,按 API 格式分组返回,用于全局优先级管理。
|
||||||
|
每个 Key 包含基本信息、健康度指标、能力标签等。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 返回一个字典,键为 API 格式,值为该格式下的 Key 列表
|
||||||
|
- 每个 Key 包含:
|
||||||
|
- `id`: Key ID
|
||||||
|
- `name`: Key 名称
|
||||||
|
- `api_key_masked`: 脱敏后的 API Key
|
||||||
|
- `internal_priority`: 内部优先级
|
||||||
|
- `global_priority`: 全局优先级
|
||||||
|
- `rate_multiplier`: 速率倍数
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `circuit_breaker_open`: 熔断器状态
|
||||||
|
- `provider_name`: Provider 名称
|
||||||
|
- `endpoint_base_url`: Endpoint 基础 URL
|
||||||
|
- `api_format`: API 格式
|
||||||
|
- `capabilities`: 能力简称列表
|
||||||
|
- `success_rate`: 成功率
|
||||||
|
- `avg_response_time_ms`: 平均响应时间
|
||||||
|
- `request_count`: 请求总数
|
||||||
|
"""
|
||||||
adapter = AdminGetKeysGroupedByFormatAdapter()
|
adapter = AdminGetKeysGroupedByFormatAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -86,7 +103,18 @@ async def reveal_endpoint_key(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""获取完整的 API Key(用于查看和复制)"""
|
"""
|
||||||
|
获取完整的 API Key
|
||||||
|
|
||||||
|
解密并返回指定 Key 的完整原文,用于查看和复制。
|
||||||
|
此操作会被记录到审计日志。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `api_key`: 完整的 API Key 原文
|
||||||
|
"""
|
||||||
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
|
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -97,151 +125,81 @@ async def delete_endpoint_key(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""删除 Endpoint Key"""
|
"""
|
||||||
|
删除 Provider Key
|
||||||
|
|
||||||
|
删除指定的 API Key。此操作不可逆,请谨慎使用。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
"""
|
||||||
adapter = AdminDeleteEndpointKeyAdapter(key_id=key_id)
|
adapter = AdminDeleteEndpointKeyAdapter(key_id=key_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{endpoint_id}/keys/batch-priority")
|
# ========== Provider Keys API ==========
|
||||||
async def batch_update_key_priority(
|
|
||||||
endpoint_id: str,
|
|
||||||
|
@router.get("/providers/{provider_id}/keys", response_model=List[EndpointAPIKeyResponse])
|
||||||
|
async def list_provider_keys(
|
||||||
|
provider_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
priority_data: BatchUpdateKeyPriorityRequest,
|
skip: int = Query(0, ge=0, description="跳过的记录数"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> List[EndpointAPIKeyResponse]:
|
||||||
"""批量更新 Endpoint 下 Keys 的优先级(用于拖动排序)"""
|
"""
|
||||||
adapter = AdminBatchUpdateKeyPriorityAdapter(endpoint_id=endpoint_id, priority_data=priority_data)
|
获取 Provider 的所有 Keys
|
||||||
|
|
||||||
|
获取指定 Provider 下的所有 API Key 列表,支持多 API 格式。
|
||||||
|
结果按优先级和创建时间排序。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `skip`: 跳过的记录数,用于分页(默认 0)
|
||||||
|
- `limit`: 返回的最大记录数(1-1000,默认 100)
|
||||||
|
"""
|
||||||
|
adapter = AdminListProviderKeysAdapter(
|
||||||
|
provider_id=provider_id,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers/{provider_id}/keys", response_model=EndpointAPIKeyResponse)
|
||||||
|
async def add_provider_key(
|
||||||
|
provider_id: str,
|
||||||
|
key_data: EndpointAPIKeyCreate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> EndpointAPIKeyResponse:
|
||||||
|
"""
|
||||||
|
为 Provider 添加 Key
|
||||||
|
|
||||||
|
为指定 Provider 添加新的 API Key,支持配置多个 API 格式。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `api_formats`: 支持的 API 格式列表(必填)
|
||||||
|
- `api_key`: API Key 原文(将被加密存储)
|
||||||
|
- `name`: Key 名称
|
||||||
|
- 其他配置字段同 Key
|
||||||
|
"""
|
||||||
|
adapter = AdminCreateProviderKeyAdapter(provider_id=provider_id, key_data=key_data)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
# -------- Adapters --------
|
# -------- Adapters --------
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AdminListEndpointKeysAdapter(AdminApiAdapter):
|
|
||||||
endpoint_id: str
|
|
||||||
skip: int
|
|
||||||
limit: int
|
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
|
||||||
db = context.db
|
|
||||||
endpoint = (
|
|
||||||
db.query(ProviderEndpoint).filter(ProviderEndpoint.id == self.endpoint_id).first()
|
|
||||||
)
|
|
||||||
if not endpoint:
|
|
||||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
|
||||||
|
|
||||||
keys = (
|
|
||||||
db.query(ProviderAPIKey)
|
|
||||||
.filter(ProviderAPIKey.endpoint_id == self.endpoint_id)
|
|
||||||
.order_by(ProviderAPIKey.internal_priority.asc(), ProviderAPIKey.created_at.asc())
|
|
||||||
.offset(self.skip)
|
|
||||||
.limit(self.limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
result: List[EndpointAPIKeyResponse] = []
|
|
||||||
for key in keys:
|
|
||||||
try:
|
|
||||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
|
||||||
masked_key = f"{decrypted_key[:8]}***{decrypted_key[-4:]}"
|
|
||||||
except Exception:
|
|
||||||
masked_key = "***ERROR***"
|
|
||||||
|
|
||||||
success_rate = key.success_count / key.request_count if key.request_count > 0 else 0.0
|
|
||||||
avg_response_time_ms = (
|
|
||||||
key.total_response_time_ms / key.success_count if key.success_count > 0 else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
is_adaptive = key.max_concurrent is None
|
|
||||||
key_dict = key.__dict__.copy()
|
|
||||||
key_dict.pop("_sa_instance_state", None)
|
|
||||||
key_dict.update(
|
|
||||||
{
|
|
||||||
"api_key_masked": masked_key,
|
|
||||||
"api_key_plain": None,
|
|
||||||
"success_rate": success_rate,
|
|
||||||
"avg_response_time_ms": round(avg_response_time_ms, 2),
|
|
||||||
"is_adaptive": is_adaptive,
|
|
||||||
"effective_limit": (
|
|
||||||
key.learned_max_concurrent if is_adaptive else key.max_concurrent
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result.append(EndpointAPIKeyResponse(**key_dict))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AdminCreateEndpointKeyAdapter(AdminApiAdapter):
|
|
||||||
endpoint_id: str
|
|
||||||
key_data: EndpointAPIKeyCreate
|
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
|
||||||
db = context.db
|
|
||||||
endpoint = (
|
|
||||||
db.query(ProviderEndpoint).filter(ProviderEndpoint.id == self.endpoint_id).first()
|
|
||||||
)
|
|
||||||
if not endpoint:
|
|
||||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
|
||||||
|
|
||||||
if self.key_data.endpoint_id != self.endpoint_id:
|
|
||||||
raise InvalidRequestException("endpoint_id 不匹配")
|
|
||||||
|
|
||||||
encrypted_key = crypto_service.encrypt(self.key_data.api_key)
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
# max_concurrent=NULL 表示自适应模式,数字表示固定限制
|
|
||||||
new_key = ProviderAPIKey(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
endpoint_id=self.endpoint_id,
|
|
||||||
api_key=encrypted_key,
|
|
||||||
name=self.key_data.name,
|
|
||||||
note=self.key_data.note,
|
|
||||||
rate_multiplier=self.key_data.rate_multiplier,
|
|
||||||
internal_priority=self.key_data.internal_priority,
|
|
||||||
max_concurrent=self.key_data.max_concurrent, # NULL=自适应模式
|
|
||||||
rate_limit=self.key_data.rate_limit,
|
|
||||||
daily_limit=self.key_data.daily_limit,
|
|
||||||
monthly_limit=self.key_data.monthly_limit,
|
|
||||||
allowed_models=self.key_data.allowed_models if self.key_data.allowed_models else None,
|
|
||||||
capabilities=self.key_data.capabilities if self.key_data.capabilities else None,
|
|
||||||
request_count=0,
|
|
||||||
success_count=0,
|
|
||||||
error_count=0,
|
|
||||||
total_response_time_ms=0,
|
|
||||||
is_active=True,
|
|
||||||
last_used_at=None,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(new_key)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(new_key)
|
|
||||||
|
|
||||||
logger.info(f"[OK] 添加 Key: Endpoint={self.endpoint_id}, Key=***{self.key_data.api_key[-4:]}, ID={new_key.id}")
|
|
||||||
|
|
||||||
masked_key = f"{self.key_data.api_key[:8]}***{self.key_data.api_key[-4:]}"
|
|
||||||
is_adaptive = new_key.max_concurrent is None
|
|
||||||
response_dict = new_key.__dict__.copy()
|
|
||||||
response_dict.pop("_sa_instance_state", None)
|
|
||||||
response_dict.update(
|
|
||||||
{
|
|
||||||
"api_key_masked": masked_key,
|
|
||||||
"api_key_plain": self.key_data.api_key,
|
|
||||||
"success_rate": 0.0,
|
|
||||||
"avg_response_time_ms": 0.0,
|
|
||||||
"is_adaptive": is_adaptive,
|
|
||||||
"effective_limit": (
|
|
||||||
new_key.learned_max_concurrent if is_adaptive else new_key.max_concurrent
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return EndpointAPIKeyResponse(**response_dict)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||||
key_id: str
|
key_id: str
|
||||||
@@ -257,14 +215,21 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
|||||||
if "api_key" in update_data:
|
if "api_key" in update_data:
|
||||||
update_data["api_key"] = crypto_service.encrypt(update_data["api_key"])
|
update_data["api_key"] = crypto_service.encrypt(update_data["api_key"])
|
||||||
|
|
||||||
# 特殊处理 max_concurrent:需要区分"未提供"和"显式设置为 null"
|
# 特殊处理 rpm_limit:需要区分"未提供"和"显式设置为 null"
|
||||||
# 当 max_concurrent 被显式设置时(在 model_fields_set 中),即使值为 None 也应该更新
|
if "rpm_limit" in self.key_data.model_fields_set:
|
||||||
if "max_concurrent" in self.key_data.model_fields_set:
|
update_data["rpm_limit"] = self.key_data.rpm_limit
|
||||||
update_data["max_concurrent"] = self.key_data.max_concurrent
|
if self.key_data.rpm_limit is None:
|
||||||
# 切换到自适应模式时,清空学习到的并发限制,让系统重新学习
|
update_data["learned_rpm_limit"] = None
|
||||||
if self.key_data.max_concurrent is None:
|
logger.info("Key %s 切换为自适应 RPM 模式", self.key_id)
|
||||||
update_data["learned_max_concurrent"] = None
|
|
||||||
logger.info("Key %s 切换为自适应并发模式", self.key_id)
|
# 统一处理 allowed_models:空列表/空字典 -> None(表示不限制)
|
||||||
|
if "allowed_models" in update_data:
|
||||||
|
am = update_data["allowed_models"]
|
||||||
|
if am is not None and (
|
||||||
|
(isinstance(am, list) and len(am) == 0)
|
||||||
|
or (isinstance(am, dict) and len(am) == 0)
|
||||||
|
):
|
||||||
|
update_data["allowed_models"] = None
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(key, field, value)
|
setattr(key, field, value)
|
||||||
@@ -273,35 +238,13 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(key)
|
db.refresh(key)
|
||||||
|
|
||||||
|
# 任何字段更新都清除缓存,确保缓存一致性
|
||||||
|
# 包括 is_active、allowed_models、capabilities 等影响权限和行为的字段
|
||||||
|
await ProviderCacheService.invalidate_provider_api_key_cache(self.key_id)
|
||||||
|
|
||||||
logger.info("[OK] 更新 Key: ID=%s, Updates=%s", self.key_id, list(update_data.keys()))
|
logger.info("[OK] 更新 Key: ID=%s, Updates=%s", self.key_id, list(update_data.keys()))
|
||||||
|
|
||||||
try:
|
return _build_key_response(key)
|
||||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
|
||||||
masked_key = f"{decrypted_key[:8]}***{decrypted_key[-4:]}"
|
|
||||||
except Exception:
|
|
||||||
masked_key = "***ERROR***"
|
|
||||||
|
|
||||||
success_rate = key.success_count / key.request_count if key.request_count > 0 else 0.0
|
|
||||||
avg_response_time_ms = (
|
|
||||||
key.total_response_time_ms / key.success_count if key.success_count > 0 else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
is_adaptive = key.max_concurrent is None
|
|
||||||
response_dict = key.__dict__.copy()
|
|
||||||
response_dict.pop("_sa_instance_state", None)
|
|
||||||
response_dict.update(
|
|
||||||
{
|
|
||||||
"api_key_masked": masked_key,
|
|
||||||
"api_key_plain": None,
|
|
||||||
"success_rate": success_rate,
|
|
||||||
"avg_response_time_ms": round(avg_response_time_ms, 2),
|
|
||||||
"is_adaptive": is_adaptive,
|
|
||||||
"effective_limit": (
|
|
||||||
key.learned_max_concurrent if is_adaptive else key.max_concurrent
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return EndpointAPIKeyResponse(**response_dict)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -338,7 +281,7 @@ class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
|||||||
if not key:
|
if not key:
|
||||||
raise NotFoundException(f"Key {self.key_id} 不存在")
|
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||||
|
|
||||||
endpoint_id = key.endpoint_id
|
provider_id = key.provider_id
|
||||||
try:
|
try:
|
||||||
db.delete(key)
|
db.delete(key)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -347,7 +290,7 @@ class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
|||||||
logger.error(f"删除 Key 失败: ID={self.key_id}, Error={exc}")
|
logger.error(f"删除 Key 失败: ID={self.key_id}, Error={exc}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.warning(f"[DELETE] 删除 Key: ID={self.key_id}, Endpoint={endpoint_id}")
|
logger.warning(f"[DELETE] 删除 Key: ID={self.key_id}, Provider={provider_id}")
|
||||||
return {"message": f"Key {self.key_id} 已删除"}
|
return {"message": f"Key {self.key_id} 已删除"}
|
||||||
|
|
||||||
|
|
||||||
@@ -355,31 +298,51 @@ class AdminGetKeysGroupedByFormatAdapter(AdminApiAdapter):
|
|||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
|
|
||||||
|
# Key 属于 Provider:按 key.api_formats 分组展示
|
||||||
keys = (
|
keys = (
|
||||||
db.query(ProviderAPIKey, ProviderEndpoint, Provider)
|
db.query(ProviderAPIKey, Provider)
|
||||||
.join(ProviderEndpoint, ProviderAPIKey.endpoint_id == ProviderEndpoint.id)
|
.join(Provider, ProviderAPIKey.provider_id == Provider.id)
|
||||||
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
|
||||||
.filter(
|
.filter(
|
||||||
ProviderAPIKey.is_active.is_(True),
|
ProviderAPIKey.is_active.is_(True),
|
||||||
ProviderEndpoint.is_active.is_(True),
|
|
||||||
Provider.is_active.is_(True),
|
Provider.is_active.is_(True),
|
||||||
)
|
)
|
||||||
.order_by(
|
.order_by(
|
||||||
ProviderAPIKey.global_priority.asc().nullslast(), ProviderAPIKey.internal_priority.asc()
|
ProviderAPIKey.global_priority.asc().nullslast(),
|
||||||
|
ProviderAPIKey.internal_priority.asc(),
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
provider_ids = {str(provider.id) for _key, provider in keys}
|
||||||
|
endpoints = (
|
||||||
|
db.query(
|
||||||
|
ProviderEndpoint.provider_id,
|
||||||
|
ProviderEndpoint.api_format,
|
||||||
|
ProviderEndpoint.base_url,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
ProviderEndpoint.provider_id.in_(provider_ids),
|
||||||
|
ProviderEndpoint.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
endpoint_base_url_map: Dict[tuple[str, str], str] = {}
|
||||||
|
for provider_id, api_format, base_url in endpoints:
|
||||||
|
fmt = api_format.value if hasattr(api_format, "value") else str(api_format)
|
||||||
|
endpoint_base_url_map[(str(provider_id), fmt)] = base_url
|
||||||
|
|
||||||
grouped: Dict[str, List[dict]] = {}
|
grouped: Dict[str, List[dict]] = {}
|
||||||
for key, endpoint, provider in keys:
|
for key, provider in keys:
|
||||||
api_format = endpoint.api_format
|
api_formats = key.api_formats or []
|
||||||
if api_format not in grouped:
|
|
||||||
grouped[api_format] = []
|
if not api_formats:
|
||||||
|
continue # 跳过没有 API 格式的 Key
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||||
masked_key = f"{decrypted_key[:8]}***{decrypted_key[-4:]}"
|
masked_key = f"{decrypted_key[:8]}***{decrypted_key[-4:]}"
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(f"解密 Key 失败: key_id={key.id}, error={e}")
|
||||||
masked_key = "***ERROR***"
|
masked_key = "***ERROR***"
|
||||||
|
|
||||||
# 计算健康度指标
|
# 计算健康度指标
|
||||||
@@ -398,8 +361,8 @@ class AdminGetKeysGroupedByFormatAdapter(AdminApiAdapter):
|
|||||||
cap_def = get_capability(cap_name)
|
cap_def = get_capability(cap_name)
|
||||||
caps_list.append(cap_def.short_name if cap_def else cap_name)
|
caps_list.append(cap_def.short_name if cap_def else cap_name)
|
||||||
|
|
||||||
grouped[api_format].append(
|
# 构建 Key 信息(基础数据)
|
||||||
{
|
key_info = {
|
||||||
"id": key.id,
|
"id": key.id,
|
||||||
"name": key.name,
|
"name": key.name,
|
||||||
"api_key_masked": masked_key,
|
"api_key_masked": masked_key,
|
||||||
@@ -407,63 +370,200 @@ class AdminGetKeysGroupedByFormatAdapter(AdminApiAdapter):
|
|||||||
"global_priority": key.global_priority,
|
"global_priority": key.global_priority,
|
||||||
"rate_multiplier": key.rate_multiplier,
|
"rate_multiplier": key.rate_multiplier,
|
||||||
"is_active": key.is_active,
|
"is_active": key.is_active,
|
||||||
"circuit_breaker_open": key.circuit_breaker_open,
|
"provider_name": provider.name,
|
||||||
"provider_name": provider.display_name or provider.name,
|
"api_formats": api_formats,
|
||||||
"endpoint_base_url": endpoint.base_url,
|
|
||||||
"api_format": api_format,
|
|
||||||
"capabilities": caps_list,
|
"capabilities": caps_list,
|
||||||
"success_rate": success_rate,
|
"success_rate": success_rate,
|
||||||
"avg_response_time_ms": avg_response_time_ms,
|
"avg_response_time_ms": avg_response_time_ms,
|
||||||
"request_count": key.request_count,
|
"request_count": key.request_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 将 Key 添加到每个支持的格式分组中,并附加格式特定的健康度数据
|
||||||
|
health_by_format = key.health_by_format or {}
|
||||||
|
circuit_by_format = key.circuit_breaker_by_format or {}
|
||||||
|
provider_id = str(provider.id)
|
||||||
|
for api_format in api_formats:
|
||||||
|
if api_format not in grouped:
|
||||||
|
grouped[api_format] = []
|
||||||
|
# 为每个格式创建副本,设置当前格式
|
||||||
|
format_key_info = key_info.copy()
|
||||||
|
format_key_info["api_format"] = api_format
|
||||||
|
format_key_info["endpoint_base_url"] = endpoint_base_url_map.get(
|
||||||
|
(provider_id, api_format)
|
||||||
)
|
)
|
||||||
|
# 添加格式特定的健康度数据
|
||||||
|
format_health = health_by_format.get(api_format, {})
|
||||||
|
format_circuit = circuit_by_format.get(api_format, {})
|
||||||
|
format_key_info["health_score"] = float(format_health.get("health_score") or 1.0)
|
||||||
|
format_key_info["circuit_breaker_open"] = bool(format_circuit.get("open", False))
|
||||||
|
grouped[api_format].append(format_key_info)
|
||||||
|
|
||||||
# 直接返回分组对象,供前端使用
|
# 直接返回分组对象,供前端使用
|
||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Adapters ==========
|
||||||
|
|
||||||
|
|
||||||
|
def _build_key_response(
|
||||||
|
key: ProviderAPIKey, api_key_plain: str | None = None
|
||||||
|
) -> EndpointAPIKeyResponse:
|
||||||
|
"""构建 Key 响应对象的辅助函数"""
|
||||||
|
try:
|
||||||
|
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||||
|
masked_key = f"{decrypted_key[:8]}***{decrypted_key[-4:]}"
|
||||||
|
except Exception:
|
||||||
|
masked_key = "***ERROR***"
|
||||||
|
|
||||||
|
success_rate = key.success_count / key.request_count if key.request_count > 0 else 0.0
|
||||||
|
avg_response_time_ms = (
|
||||||
|
key.total_response_time_ms / key.success_count if key.success_count > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
is_adaptive = key.rpm_limit is None
|
||||||
|
key_dict = key.__dict__.copy()
|
||||||
|
key_dict.pop("_sa_instance_state", None)
|
||||||
|
|
||||||
|
# 从 health_by_format 计算汇总字段(便于列表展示)
|
||||||
|
health_by_format = key.health_by_format or {}
|
||||||
|
circuit_by_format = key.circuit_breaker_by_format or {}
|
||||||
|
|
||||||
|
# 计算整体健康度(取所有格式中的最低值)
|
||||||
|
if health_by_format:
|
||||||
|
health_scores = [
|
||||||
|
float(h.get("health_score") or 1.0) for h in health_by_format.values()
|
||||||
|
]
|
||||||
|
min_health_score = min(health_scores) if health_scores else 1.0
|
||||||
|
# 取最大的连续失败次数
|
||||||
|
max_consecutive = max(
|
||||||
|
(int(h.get("consecutive_failures") or 0) for h in health_by_format.values()),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
# 取最近的失败时间
|
||||||
|
failure_times = [
|
||||||
|
h.get("last_failure_at")
|
||||||
|
for h in health_by_format.values()
|
||||||
|
if h.get("last_failure_at")
|
||||||
|
]
|
||||||
|
last_failure = max(failure_times) if failure_times else None
|
||||||
|
else:
|
||||||
|
min_health_score = 1.0
|
||||||
|
max_consecutive = 0
|
||||||
|
last_failure = None
|
||||||
|
|
||||||
|
# 检查是否有任何格式的熔断器打开
|
||||||
|
any_circuit_open = any(c.get("open", False) for c in circuit_by_format.values())
|
||||||
|
|
||||||
|
key_dict.update(
|
||||||
|
{
|
||||||
|
"api_key_masked": masked_key,
|
||||||
|
"api_key_plain": api_key_plain,
|
||||||
|
"success_rate": success_rate,
|
||||||
|
"avg_response_time_ms": round(avg_response_time_ms, 2),
|
||||||
|
"is_adaptive": is_adaptive,
|
||||||
|
"effective_limit": (
|
||||||
|
(key.learned_rpm_limit if key.learned_rpm_limit is not None else RPMDefaults.INITIAL_LIMIT)
|
||||||
|
if is_adaptive
|
||||||
|
else key.rpm_limit
|
||||||
|
),
|
||||||
|
# 汇总字段
|
||||||
|
"health_score": min_health_score,
|
||||||
|
"consecutive_failures": max_consecutive,
|
||||||
|
"last_failure_at": last_failure,
|
||||||
|
"circuit_breaker_open": any_circuit_open,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 防御性:确保 api_formats 存在(历史数据可能为空/缺失)
|
||||||
|
if "api_formats" not in key_dict or key_dict["api_formats"] is None:
|
||||||
|
key_dict["api_formats"] = []
|
||||||
|
|
||||||
|
return EndpointAPIKeyResponse(**key_dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminBatchUpdateKeyPriorityAdapter(AdminApiAdapter):
|
class AdminListProviderKeysAdapter(AdminApiAdapter):
|
||||||
endpoint_id: str
|
"""获取 Provider 的所有 Keys"""
|
||||||
priority_data: BatchUpdateKeyPriorityRequest
|
|
||||||
|
provider_id: str
|
||||||
|
skip: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
endpoint = (
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||||
db.query(ProviderEndpoint).filter(ProviderEndpoint.id == self.endpoint_id).first()
|
if not provider:
|
||||||
)
|
raise NotFoundException(f"Provider {self.provider_id} 不存在")
|
||||||
if not endpoint:
|
|
||||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
|
||||||
|
|
||||||
# 获取所有需要更新的 Key ID
|
|
||||||
key_ids = [item.key_id for item in self.priority_data.priorities]
|
|
||||||
|
|
||||||
# 验证所有 Key 都属于该 Endpoint
|
|
||||||
keys = (
|
keys = (
|
||||||
db.query(ProviderAPIKey)
|
db.query(ProviderAPIKey)
|
||||||
.filter(
|
.filter(ProviderAPIKey.provider_id == self.provider_id)
|
||||||
ProviderAPIKey.id.in_(key_ids),
|
.order_by(ProviderAPIKey.internal_priority.asc(), ProviderAPIKey.created_at.asc())
|
||||||
ProviderAPIKey.endpoint_id == self.endpoint_id,
|
.offset(self.skip)
|
||||||
)
|
.limit(self.limit)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(keys) != len(key_ids):
|
return [_build_key_response(key) for key in keys]
|
||||||
found_ids = {k.id for k in keys}
|
|
||||||
missing_ids = set(key_ids) - found_ids
|
|
||||||
raise InvalidRequestException(f"Keys 不属于该 Endpoint 或不存在: {missing_ids}")
|
|
||||||
|
|
||||||
# 批量更新优先级
|
|
||||||
key_map = {k.id: k for k in keys}
|
|
||||||
updated_count = 0
|
|
||||||
for item in self.priority_data.priorities:
|
|
||||||
key = key_map.get(item.key_id)
|
|
||||||
if key and key.internal_priority != item.internal_priority:
|
|
||||||
key.internal_priority = item.internal_priority
|
|
||||||
key.updated_at = datetime.now(timezone.utc)
|
|
||||||
updated_count += 1
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminCreateProviderKeyAdapter(AdminApiAdapter):
|
||||||
|
"""为 Provider 添加 Key"""
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
key_data: EndpointAPIKeyCreate
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||||
|
if not provider:
|
||||||
|
raise NotFoundException(f"Provider {self.provider_id} 不存在")
|
||||||
|
|
||||||
|
# 验证 api_formats 必填
|
||||||
|
if not self.key_data.api_formats:
|
||||||
|
raise InvalidRequestException("api_formats 为必填字段")
|
||||||
|
|
||||||
|
# 允许同一个 API Key 在同一 Provider 下添加多次
|
||||||
|
# 用户可以为不同的 API 格式创建独立的配置记录,便于分开管理
|
||||||
|
|
||||||
|
encrypted_key = crypto_service.encrypt(self.key_data.api_key)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
new_key = ProviderAPIKey(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
api_formats=self.key_data.api_formats,
|
||||||
|
api_key=encrypted_key,
|
||||||
|
name=self.key_data.name,
|
||||||
|
note=self.key_data.note,
|
||||||
|
rate_multiplier=self.key_data.rate_multiplier,
|
||||||
|
rate_multipliers=self.key_data.rate_multipliers, # 按 API 格式的成本倍率
|
||||||
|
internal_priority=self.key_data.internal_priority,
|
||||||
|
rpm_limit=self.key_data.rpm_limit,
|
||||||
|
allowed_models=self.key_data.allowed_models if self.key_data.allowed_models else None,
|
||||||
|
capabilities=self.key_data.capabilities if self.key_data.capabilities else None,
|
||||||
|
cache_ttl_minutes=self.key_data.cache_ttl_minutes,
|
||||||
|
max_probe_interval_minutes=self.key_data.max_probe_interval_minutes,
|
||||||
|
request_count=0,
|
||||||
|
success_count=0,
|
||||||
|
error_count=0,
|
||||||
|
total_response_time_ms=0,
|
||||||
|
health_by_format={}, # 按格式存储健康度
|
||||||
|
circuit_breaker_by_format={}, # 按格式存储熔断器状态
|
||||||
|
is_active=True,
|
||||||
|
last_used_at=None,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_key)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
db.refresh(new_key)
|
||||||
|
|
||||||
logger.info(f"[OK] 批量更新 Key 优先级: Endpoint={self.endpoint_id}, Updated={updated_count}/{len(key_ids)}")
|
logger.info(
|
||||||
return {"message": f"已更新 {updated_count} 个 Key 的优先级", "updated_count": updated_count}
|
f"[OK] 添加 Key: Provider={self.provider_id}, "
|
||||||
|
f"Formats={self.key_data.api_formats}, Key=***{self.key_data.api_key[-4:]}, ID={new_key.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_key_response(new_key, api_key_plain=self.key_data.api_key)
|
||||||
|
|||||||
@@ -45,7 +45,34 @@ async def list_provider_endpoints(
|
|||||||
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> List[ProviderEndpointResponse]:
|
) -> List[ProviderEndpointResponse]:
|
||||||
"""获取指定 Provider 的所有 Endpoints"""
|
"""
|
||||||
|
获取指定 Provider 的所有 Endpoints
|
||||||
|
|
||||||
|
获取指定 Provider 下的所有 Endpoint 列表,包括配置、统计信息等。
|
||||||
|
结果按创建时间倒序排列。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `skip`: 跳过的记录数,用于分页(默认 0)
|
||||||
|
- `limit`: 返回的最大记录数(1-1000,默认 100)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: Endpoint ID
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `provider_name`: Provider 名称
|
||||||
|
- `api_format`: API 格式
|
||||||
|
- `base_url`: 基础 URL
|
||||||
|
- `custom_path`: 自定义路径
|
||||||
|
- `timeout`: 超时时间(秒)
|
||||||
|
- `max_retries`: 最大重试次数
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `total_keys`: Key 总数
|
||||||
|
- `active_keys`: 活跃 Key 数量
|
||||||
|
- `proxy`: 代理配置(密码已脱敏)
|
||||||
|
- 其他配置字段
|
||||||
|
"""
|
||||||
adapter = AdminListProviderEndpointsAdapter(
|
adapter = AdminListProviderEndpointsAdapter(
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
@@ -61,7 +88,29 @@ async def create_provider_endpoint(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> ProviderEndpointResponse:
|
) -> ProviderEndpointResponse:
|
||||||
"""为 Provider 创建新的 Endpoint"""
|
"""
|
||||||
|
为 Provider 创建新的 Endpoint
|
||||||
|
|
||||||
|
为指定 Provider 创建新的 Endpoint,每个 Provider 的每种 API 格式
|
||||||
|
只能创建一个 Endpoint。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `provider_id`: Provider ID(必须与路径参数一致)
|
||||||
|
- `api_format`: API 格式(如 claude、openai、gemini 等)
|
||||||
|
- `base_url`: 基础 URL
|
||||||
|
- `custom_path`: 自定义路径(可选)
|
||||||
|
- `headers`: 自定义请求头(可选)
|
||||||
|
- `timeout`: 超时时间(秒,默认 300)
|
||||||
|
- `max_retries`: 最大重试次数(默认 2)
|
||||||
|
- `config`: 额外配置(可选)
|
||||||
|
- `proxy`: 代理配置(可选)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 包含完整的 Endpoint 信息
|
||||||
|
"""
|
||||||
adapter = AdminCreateProviderEndpointAdapter(
|
adapter = AdminCreateProviderEndpointAdapter(
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
endpoint_data=endpoint_data,
|
endpoint_data=endpoint_data,
|
||||||
@@ -75,7 +124,29 @@ async def get_endpoint(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> ProviderEndpointResponse:
|
) -> ProviderEndpointResponse:
|
||||||
"""获取 Endpoint 详情"""
|
"""
|
||||||
|
获取 Endpoint 详情
|
||||||
|
|
||||||
|
获取指定 Endpoint 的详细信息,包括配置、统计信息等。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: Endpoint ID
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `provider_name`: Provider 名称
|
||||||
|
- `api_format`: API 格式
|
||||||
|
- `base_url`: 基础 URL
|
||||||
|
- `custom_path`: 自定义路径
|
||||||
|
- `timeout`: 超时时间(秒)
|
||||||
|
- `max_retries`: 最大重试次数
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `total_keys`: Key 总数
|
||||||
|
- `active_keys`: 活跃 Key 数量
|
||||||
|
- `proxy`: 代理配置(密码已脱敏)
|
||||||
|
- 其他配置字段
|
||||||
|
"""
|
||||||
adapter = AdminGetProviderEndpointAdapter(endpoint_id=endpoint_id)
|
adapter = AdminGetProviderEndpointAdapter(endpoint_id=endpoint_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -87,7 +158,27 @@ async def update_endpoint(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> ProviderEndpointResponse:
|
) -> ProviderEndpointResponse:
|
||||||
"""更新 Endpoint"""
|
"""
|
||||||
|
更新 Endpoint
|
||||||
|
|
||||||
|
更新指定 Endpoint 的配置。支持部分更新。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
|
||||||
|
**请求体字段**(均为可选):
|
||||||
|
- `base_url`: 基础 URL
|
||||||
|
- `custom_path`: 自定义路径
|
||||||
|
- `headers`: 自定义请求头
|
||||||
|
- `timeout`: 超时时间(秒)
|
||||||
|
- `max_retries`: 最大重试次数
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `config`: 额外配置
|
||||||
|
- `proxy`: 代理配置(设置为 null 可清除代理)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 包含更新后的完整 Endpoint 信息
|
||||||
|
"""
|
||||||
adapter = AdminUpdateProviderEndpointAdapter(
|
adapter = AdminUpdateProviderEndpointAdapter(
|
||||||
endpoint_id=endpoint_id,
|
endpoint_id=endpoint_id,
|
||||||
endpoint_data=endpoint_data,
|
endpoint_data=endpoint_data,
|
||||||
@@ -101,7 +192,19 @@ async def delete_endpoint(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""删除 Endpoint(级联删除所有关联的 Keys)"""
|
"""
|
||||||
|
删除 Endpoint
|
||||||
|
|
||||||
|
删除指定的 Endpoint,会影响该 Provider 在该 API 格式下的路由能力。
|
||||||
|
Key 不会被删除,但包含该 API 格式的 Key 将无法被调度使用(直到重新创建该格式的 Endpoint)。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `affected_keys_count`: 受影响的 Key 数量(包含该 API 格式)
|
||||||
|
"""
|
||||||
adapter = AdminDeleteProviderEndpointAdapter(endpoint_id=endpoint_id)
|
adapter = AdminDeleteProviderEndpointAdapter(endpoint_id=endpoint_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -130,39 +233,33 @@ class AdminListProviderEndpointsAdapter(AdminApiAdapter):
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
endpoint_ids = [ep.id for ep in endpoints]
|
# Key 是 Provider 级别资源:按 key.api_formats 归类到各 Endpoint.api_format 下
|
||||||
total_keys_map = {}
|
keys = (
|
||||||
active_keys_map = {}
|
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
|
||||||
if endpoint_ids:
|
.filter(ProviderAPIKey.provider_id == self.provider_id)
|
||||||
total_rows = (
|
|
||||||
db.query(ProviderAPIKey.endpoint_id, func.count(ProviderAPIKey.id).label("total"))
|
|
||||||
.filter(ProviderAPIKey.endpoint_id.in_(endpoint_ids))
|
|
||||||
.group_by(ProviderAPIKey.endpoint_id)
|
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
total_keys_map = {row.endpoint_id: row.total for row in total_rows}
|
total_keys_map: dict[str, int] = {}
|
||||||
|
active_keys_map: dict[str, int] = {}
|
||||||
active_rows = (
|
for api_formats, is_active in keys:
|
||||||
db.query(ProviderAPIKey.endpoint_id, func.count(ProviderAPIKey.id).label("active"))
|
for fmt in (api_formats or []):
|
||||||
.filter(
|
total_keys_map[fmt] = total_keys_map.get(fmt, 0) + 1
|
||||||
and_(
|
if is_active:
|
||||||
ProviderAPIKey.endpoint_id.in_(endpoint_ids),
|
active_keys_map[fmt] = active_keys_map.get(fmt, 0) + 1
|
||||||
ProviderAPIKey.is_active.is_(True),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.group_by(ProviderAPIKey.endpoint_id)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
active_keys_map = {row.endpoint_id: row.active for row in active_rows}
|
|
||||||
|
|
||||||
result: List[ProviderEndpointResponse] = []
|
result: List[ProviderEndpointResponse] = []
|
||||||
for endpoint in endpoints:
|
for endpoint in endpoints:
|
||||||
|
endpoint_format = (
|
||||||
|
endpoint.api_format
|
||||||
|
if isinstance(endpoint.api_format, str)
|
||||||
|
else endpoint.api_format.value
|
||||||
|
)
|
||||||
endpoint_dict = {
|
endpoint_dict = {
|
||||||
**endpoint.__dict__,
|
**endpoint.__dict__,
|
||||||
"provider_name": provider.name,
|
"provider_name": provider.name,
|
||||||
"api_format": endpoint.api_format,
|
"api_format": endpoint.api_format,
|
||||||
"total_keys": total_keys_map.get(endpoint.id, 0),
|
"total_keys": total_keys_map.get(endpoint_format, 0),
|
||||||
"active_keys": active_keys_map.get(endpoint.id, 0),
|
"active_keys": active_keys_map.get(endpoint_format, 0),
|
||||||
"proxy": mask_proxy_password(endpoint.proxy),
|
"proxy": mask_proxy_password(endpoint.proxy),
|
||||||
}
|
}
|
||||||
endpoint_dict.pop("_sa_instance_state", None)
|
endpoint_dict.pop("_sa_instance_state", None)
|
||||||
@@ -206,11 +303,10 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
provider_id=self.provider_id,
|
provider_id=self.provider_id,
|
||||||
api_format=self.endpoint_data.api_format,
|
api_format=self.endpoint_data.api_format,
|
||||||
base_url=self.endpoint_data.base_url,
|
base_url=self.endpoint_data.base_url,
|
||||||
|
custom_path=self.endpoint_data.custom_path,
|
||||||
headers=self.endpoint_data.headers,
|
headers=self.endpoint_data.headers,
|
||||||
timeout=self.endpoint_data.timeout,
|
timeout=self.endpoint_data.timeout,
|
||||||
max_retries=self.endpoint_data.max_retries,
|
max_retries=self.endpoint_data.max_retries,
|
||||||
max_concurrent=self.endpoint_data.max_concurrent,
|
|
||||||
rate_limit=self.endpoint_data.rate_limit,
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
config=self.endpoint_data.config,
|
config=self.endpoint_data.config,
|
||||||
proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None,
|
proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None,
|
||||||
@@ -255,19 +351,23 @@ class AdminGetProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||||
|
|
||||||
endpoint_obj, provider = endpoint
|
endpoint_obj, provider = endpoint
|
||||||
total_keys = (
|
endpoint_format = (
|
||||||
db.query(ProviderAPIKey).filter(ProviderAPIKey.endpoint_id == self.endpoint_id).count()
|
endpoint_obj.api_format
|
||||||
|
if isinstance(endpoint_obj.api_format, str)
|
||||||
|
else endpoint_obj.api_format.value
|
||||||
)
|
)
|
||||||
active_keys = (
|
keys = (
|
||||||
db.query(ProviderAPIKey)
|
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
|
||||||
.filter(
|
.filter(ProviderAPIKey.provider_id == endpoint_obj.provider_id)
|
||||||
and_(
|
.all()
|
||||||
ProviderAPIKey.endpoint_id == self.endpoint_id,
|
|
||||||
ProviderAPIKey.is_active.is_(True),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
total_keys = 0
|
||||||
|
active_keys = 0
|
||||||
|
for api_formats, is_active in keys:
|
||||||
|
if endpoint_format in (api_formats or []):
|
||||||
|
total_keys += 1
|
||||||
|
if is_active:
|
||||||
|
active_keys += 1
|
||||||
|
|
||||||
endpoint_dict = {
|
endpoint_dict = {
|
||||||
k: v
|
k: v
|
||||||
@@ -319,19 +419,21 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
|
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
|
||||||
logger.info(f"[OK] 更新 Endpoint: ID={self.endpoint_id}, Updates={list(update_data.keys())}")
|
logger.info(f"[OK] 更新 Endpoint: ID={self.endpoint_id}, Updates={list(update_data.keys())}")
|
||||||
|
|
||||||
total_keys = (
|
endpoint_format = (
|
||||||
db.query(ProviderAPIKey).filter(ProviderAPIKey.endpoint_id == self.endpoint_id).count()
|
endpoint.api_format if isinstance(endpoint.api_format, str) else endpoint.api_format.value
|
||||||
)
|
)
|
||||||
active_keys = (
|
keys = (
|
||||||
db.query(ProviderAPIKey)
|
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
|
||||||
.filter(
|
.filter(ProviderAPIKey.provider_id == endpoint.provider_id)
|
||||||
and_(
|
.all()
|
||||||
ProviderAPIKey.endpoint_id == self.endpoint_id,
|
|
||||||
ProviderAPIKey.is_active.is_(True),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
total_keys = 0
|
||||||
|
active_keys = 0
|
||||||
|
for api_formats, is_active in keys:
|
||||||
|
if endpoint_format in (api_formats or []):
|
||||||
|
total_keys += 1
|
||||||
|
if is_active:
|
||||||
|
active_keys += 1
|
||||||
|
|
||||||
endpoint_dict = {
|
endpoint_dict = {
|
||||||
k: v
|
k: v
|
||||||
@@ -360,12 +462,26 @@ class AdminDeleteProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
if not endpoint:
|
if not endpoint:
|
||||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||||
|
|
||||||
keys_count = (
|
endpoint_format = (
|
||||||
db.query(ProviderAPIKey).filter(ProviderAPIKey.endpoint_id == self.endpoint_id).count()
|
endpoint.api_format if isinstance(endpoint.api_format, str) else endpoint.api_format.value
|
||||||
|
)
|
||||||
|
keys = (
|
||||||
|
db.query(ProviderAPIKey.api_formats)
|
||||||
|
.filter(ProviderAPIKey.provider_id == endpoint.provider_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
affected_keys_count = sum(
|
||||||
|
1 for (api_formats,) in keys if endpoint_format in (api_formats or [])
|
||||||
)
|
)
|
||||||
db.delete(endpoint)
|
db.delete(endpoint)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
logger.warning(f"[DELETE] 删除 Endpoint: ID={self.endpoint_id}, 同时删除了 {keys_count} 个 Keys")
|
logger.warning(
|
||||||
|
f"[DELETE] 删除 Endpoint: ID={self.endpoint_id}, Format={endpoint_format}, "
|
||||||
|
f"AffectedKeys={affected_keys_count}"
|
||||||
|
)
|
||||||
|
|
||||||
return {"message": f"Endpoint {self.endpoint_id} 已删除", "deleted_keys_count": keys_count}
|
return {
|
||||||
|
"message": f"Endpoint {self.endpoint_id} 已删除",
|
||||||
|
"affected_keys_count": affected_keys_count,
|
||||||
|
}
|
||||||
|
|||||||
501
src/api/admin/ldap.py
Normal file
501
src/api/admin/ldap.py
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
"""LDAP配置管理API端点。"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
from src.core.enums import AuthSource
|
||||||
|
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import AuditEventType, LDAPConfig, User, UserRole
|
||||||
|
from src.services.system.audit import AuditService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/ldap", tags=["Admin - LDAP"])
|
||||||
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
# bcrypt 哈希格式正则:$2a$, $2b$, $2y$ + 2位cost + $ + 53字符(22位salt + 31位hash)
|
||||||
|
BCRYPT_HASH_PATTERN = re.compile(r"^\$2[aby]\$\d{2}\$.{53}$")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Request/Response Models ==========
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfigResponse(BaseModel):
|
||||||
|
"""LDAP配置响应(不返回密码)"""
|
||||||
|
|
||||||
|
server_url: Optional[str] = None
|
||||||
|
bind_dn: Optional[str] = None
|
||||||
|
base_dn: Optional[str] = None
|
||||||
|
has_bind_password: bool = False
|
||||||
|
user_search_filter: str
|
||||||
|
username_attr: str
|
||||||
|
email_attr: str
|
||||||
|
display_name_attr: str
|
||||||
|
is_enabled: bool
|
||||||
|
is_exclusive: bool
|
||||||
|
use_starttls: bool
|
||||||
|
connect_timeout: int
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfigUpdate(BaseModel):
|
||||||
|
"""LDAP配置更新请求"""
|
||||||
|
|
||||||
|
server_url: str = Field(..., min_length=1, max_length=255)
|
||||||
|
bind_dn: str = Field(..., min_length=1, max_length=255)
|
||||||
|
# 允许空字符串表示"清除密码";非空时自动 strip 并校验不能为空
|
||||||
|
bind_password: Optional[str] = Field(None, max_length=1024)
|
||||||
|
base_dn: str = Field(..., min_length=1, max_length=255)
|
||||||
|
user_search_filter: str = Field(default="(uid={username})", max_length=500)
|
||||||
|
username_attr: str = Field(default="uid", max_length=50)
|
||||||
|
email_attr: str = Field(default="mail", max_length=50)
|
||||||
|
display_name_attr: str = Field(default="cn", max_length=50)
|
||||||
|
is_enabled: bool = False
|
||||||
|
is_exclusive: bool = False
|
||||||
|
use_starttls: bool = False
|
||||||
|
connect_timeout: int = Field(default=10, ge=1, le=60) # 单次操作超时,跨国网络建议 15-30 秒
|
||||||
|
|
||||||
|
@field_validator("bind_password")
|
||||||
|
@classmethod
|
||||||
|
def validate_bind_password(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is None or v == "":
|
||||||
|
return v
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("绑定密码不能为空")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("user_search_filter")
|
||||||
|
@classmethod
|
||||||
|
def validate_search_filter(cls, v: str) -> str:
|
||||||
|
if "{username}" not in v:
|
||||||
|
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||||
|
# 验证括号匹配和嵌套正确性
|
||||||
|
depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
if depth != 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
# 限制过滤器复杂度,防止构造复杂查询
|
||||||
|
# 检查嵌套层数而非括号总数
|
||||||
|
depth = 0
|
||||||
|
max_depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
max_depth = max(max_depth, depth)
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if max_depth > 5:
|
||||||
|
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||||
|
if len(v) > 200:
|
||||||
|
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPTestResponse(BaseModel):
|
||||||
|
"""LDAP连接测试响应"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfigTest(BaseModel):
|
||||||
|
"""LDAP配置测试请求(全部可选,用于临时覆盖)"""
|
||||||
|
|
||||||
|
server_url: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
bind_dn: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
bind_password: Optional[str] = Field(None, min_length=1)
|
||||||
|
base_dn: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
user_search_filter: Optional[str] = Field(None, max_length=500)
|
||||||
|
username_attr: Optional[str] = Field(None, max_length=50)
|
||||||
|
email_attr: Optional[str] = Field(None, max_length=50)
|
||||||
|
display_name_attr: Optional[str] = Field(None, max_length=50)
|
||||||
|
is_enabled: Optional[bool] = None
|
||||||
|
is_exclusive: Optional[bool] = None
|
||||||
|
use_starttls: Optional[bool] = None
|
||||||
|
connect_timeout: Optional[int] = Field(None, ge=1, le=60)
|
||||||
|
|
||||||
|
@field_validator("user_search_filter")
|
||||||
|
@classmethod
|
||||||
|
def validate_search_filter(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if "{username}" not in v:
|
||||||
|
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||||
|
# 验证括号匹配和嵌套正确性
|
||||||
|
depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
if depth != 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
# 限制过滤器复杂度(检查嵌套层数而非括号总数)
|
||||||
|
depth = 0
|
||||||
|
max_depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
max_depth = max(max_depth, depth)
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if max_depth > 5:
|
||||||
|
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||||
|
if len(v) > 200:
|
||||||
|
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ========== API Endpoints ==========
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""
|
||||||
|
获取 LDAP 配置
|
||||||
|
|
||||||
|
获取系统当前的 LDAP 认证配置信息,用于管理界面显示和编辑。
|
||||||
|
密码字段不会返回原文,仅返回是否已设置的标志。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `server_url`: LDAP 服务器地址(如:ldap://ldap.example.com:389)
|
||||||
|
- `bind_dn`: 绑定 DN(如:cn=admin,dc=example,dc=com)
|
||||||
|
- `base_dn`: 搜索基准 DN(如:ou=users,dc=example,dc=com)
|
||||||
|
- `has_bind_password`: 是否已设置绑定密码(布尔值)
|
||||||
|
- `user_search_filter`: 用户搜索过滤器(默认:(uid={username}))
|
||||||
|
- `username_attr`: 用户名属性(默认:uid)
|
||||||
|
- `email_attr`: 邮箱属性(默认:mail)
|
||||||
|
- `display_name_attr`: 显示名称属性(默认:cn)
|
||||||
|
- `is_enabled`: 是否启用 LDAP 认证
|
||||||
|
- `is_exclusive`: 是否仅允许 LDAP 登录(独占模式)
|
||||||
|
- `use_starttls`: 是否使用 STARTTLS 加密连接
|
||||||
|
- `connect_timeout`: 连接超时时间(秒,1-60)
|
||||||
|
"""
|
||||||
|
adapter = AdminGetLDAPConfigAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
async def update_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""
|
||||||
|
更新 LDAP 配置
|
||||||
|
|
||||||
|
更新系统的 LDAP 认证配置。支持完整配置更新,包括连接参数、
|
||||||
|
搜索过滤器、属性映射等。提供多重安全校验,防止误锁定管理员。
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `server_url`: LDAP 服务器地址(必填,1-255字符)
|
||||||
|
- `bind_dn`: 绑定 DN(必填,1-255字符)
|
||||||
|
- `bind_password`: 绑定密码(可选,设为空字符串可清除密码)
|
||||||
|
- `base_dn`: 搜索基准 DN(必填,1-255字符)
|
||||||
|
- `user_search_filter`: 用户搜索过滤器(必须包含 {username} 占位符,默认:(uid={username}))
|
||||||
|
- `username_attr`: 用户名属性(默认:uid)
|
||||||
|
- `email_attr`: 邮箱属性(默认:mail)
|
||||||
|
- `display_name_attr`: 显示名称属性(默认:cn)
|
||||||
|
- `is_enabled`: 是否启用 LDAP 认证
|
||||||
|
- `is_exclusive`: 是否仅允许 LDAP 登录(需先启用 LDAP)
|
||||||
|
- `use_starttls`: 是否使用 STARTTLS 加密连接
|
||||||
|
- `connect_timeout`: 连接超时时间(秒,1-60,默认 10)
|
||||||
|
|
||||||
|
**安全校验**:
|
||||||
|
- 启用 LDAP 时必须设置有效的绑定密码
|
||||||
|
- 启用独占模式前会检查是否有至少 1 个有效的本地管理员账户
|
||||||
|
- 独占模式要求先启用 LDAP 认证
|
||||||
|
- 搜索过滤器必须包含 {username} 占位符且括号匹配
|
||||||
|
- 搜索过滤器嵌套层数不超过 5 层,长度不超过 200 字符
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
"""
|
||||||
|
adapter = AdminUpdateLDAPConfigAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_ldap_connection(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""
|
||||||
|
测试 LDAP 连接
|
||||||
|
|
||||||
|
在保存配置前测试 LDAP 服务器连接是否正常。支持使用已保存的配置,
|
||||||
|
也支持通过请求体覆盖任意配置项进行临时测试,而不影响已保存的配置。
|
||||||
|
|
||||||
|
**请求体字段**(均为可选,用于临时覆盖):
|
||||||
|
- `server_url`: LDAP 服务器地址(覆盖已保存的配置)
|
||||||
|
- `bind_dn`: 绑定 DN(覆盖已保存的配置)
|
||||||
|
- `bind_password`: 绑定密码(覆盖已保存的密码)
|
||||||
|
- `base_dn`: 搜索基准 DN(覆盖已保存的配置)
|
||||||
|
- `user_search_filter`: 用户搜索过滤器(覆盖已保存的配置)
|
||||||
|
- `username_attr`: 用户名属性(覆盖已保存的配置)
|
||||||
|
- `email_attr`: 邮箱属性(覆盖已保存的配置)
|
||||||
|
- `display_name_attr`: 显示名称属性(覆盖已保存的配置)
|
||||||
|
- `use_starttls`: 是否使用 STARTTLS(覆盖已保存的配置)
|
||||||
|
- `connect_timeout`: 连接超时时间(覆盖已保存的配置)
|
||||||
|
|
||||||
|
**测试逻辑**:
|
||||||
|
- 未提供的字段使用已保存的配置值
|
||||||
|
- `bind_password` 优先使用请求体中的值,否则使用已保存的加密密码
|
||||||
|
- 测试时会尝试连接 LDAP 服务器并验证绑定 DN
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `success`: 测试是否成功(布尔值)
|
||||||
|
- `message`: 测试结果消息(成功或失败原因)
|
||||||
|
"""
|
||||||
|
adapter = AdminTestLDAPConnectionAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Adapters ==========
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGetLDAPConfigAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
config = db.query(LDAPConfig).first()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
return LDAPConfigResponse(
|
||||||
|
server_url=None,
|
||||||
|
bind_dn=None,
|
||||||
|
base_dn=None,
|
||||||
|
has_bind_password=False,
|
||||||
|
user_search_filter="(uid={username})",
|
||||||
|
username_attr="uid",
|
||||||
|
email_attr="mail",
|
||||||
|
display_name_attr="cn",
|
||||||
|
is_enabled=False,
|
||||||
|
is_exclusive=False,
|
||||||
|
use_starttls=False,
|
||||||
|
connect_timeout=10,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
return LDAPConfigResponse(
|
||||||
|
server_url=config.server_url,
|
||||||
|
bind_dn=config.bind_dn,
|
||||||
|
base_dn=config.base_dn,
|
||||||
|
has_bind_password=bool(config.bind_password_encrypted),
|
||||||
|
user_search_filter=config.user_search_filter,
|
||||||
|
username_attr=config.username_attr,
|
||||||
|
email_attr=config.email_attr,
|
||||||
|
display_name_attr=config.display_name_attr,
|
||||||
|
is_enabled=config.is_enabled,
|
||||||
|
is_exclusive=config.is_exclusive,
|
||||||
|
use_starttls=config.use_starttls,
|
||||||
|
connect_timeout=config.connect_timeout,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUpdateLDAPConfigAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context) -> Dict[str, str]: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_update = LDAPConfigUpdate.model_validate(payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
errors = e.errors()
|
||||||
|
if errors:
|
||||||
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||||
|
raise InvalidRequestException("请求数据验证失败")
|
||||||
|
|
||||||
|
# 使用行级锁防止并发修改导致的竞态条件
|
||||||
|
config = db.query(LDAPConfig).with_for_update().first()
|
||||||
|
is_new_config = config is None
|
||||||
|
|
||||||
|
if is_new_config:
|
||||||
|
# 首次创建配置时必须提供密码
|
||||||
|
if not config_update.bind_password:
|
||||||
|
raise InvalidRequestException("首次配置 LDAP 时必须设置绑定密码")
|
||||||
|
config = LDAPConfig()
|
||||||
|
db.add(config)
|
||||||
|
|
||||||
|
# 需要启用 LDAP 且未提交新密码时,验证已保存密码可解密(避免开启后不可用)
|
||||||
|
if config_update.is_enabled and config_update.bind_password is None:
|
||||||
|
try:
|
||||||
|
if not config.get_bind_password():
|
||||||
|
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||||
|
except InvalidRequestException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise InvalidRequestException("绑定密码解密失败,请重新设置绑定密码")
|
||||||
|
|
||||||
|
# 计算更新后的密码状态(用于校验是否可启用/独占)
|
||||||
|
if config_update.bind_password is None:
|
||||||
|
will_have_password = bool(config.bind_password_encrypted)
|
||||||
|
elif config_update.bind_password == "":
|
||||||
|
will_have_password = False
|
||||||
|
else:
|
||||||
|
will_have_password = True
|
||||||
|
|
||||||
|
# 独占模式必须启用 LDAP 且必须有绑定密码(防止误锁定)
|
||||||
|
if config_update.is_exclusive and not config_update.is_enabled:
|
||||||
|
raise InvalidRequestException("仅允许 LDAP 登录 需要先启用 LDAP 认证")
|
||||||
|
if config_update.is_enabled and not will_have_password:
|
||||||
|
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||||
|
if config_update.is_exclusive and not will_have_password:
|
||||||
|
raise InvalidRequestException("仅允许 LDAP 登录 需要先设置绑定密码")
|
||||||
|
|
||||||
|
config.server_url = config_update.server_url
|
||||||
|
config.bind_dn = config_update.bind_dn
|
||||||
|
config.base_dn = config_update.base_dn
|
||||||
|
config.user_search_filter = config_update.user_search_filter
|
||||||
|
config.username_attr = config_update.username_attr
|
||||||
|
config.email_attr = config_update.email_attr
|
||||||
|
config.display_name_attr = config_update.display_name_attr
|
||||||
|
config.is_enabled = config_update.is_enabled
|
||||||
|
config.is_exclusive = config_update.is_exclusive
|
||||||
|
config.use_starttls = config_update.use_starttls
|
||||||
|
config.connect_timeout = config_update.connect_timeout
|
||||||
|
|
||||||
|
# 启用独占模式前检查是否有足够的本地管理员(防止锁定)
|
||||||
|
# 使用 with_for_update() 阻塞锁防止竞态条件(移除 nowait 确保并发安全)
|
||||||
|
if config_update.is_enabled and config_update.is_exclusive:
|
||||||
|
local_admins = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(
|
||||||
|
User.role == UserRole.ADMIN,
|
||||||
|
User.auth_source == AuthSource.LOCAL,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
User.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# 验证至少有一个管理员有有效的密码哈希(可以登录)
|
||||||
|
# 使用严格的 bcrypt 格式校验:$2a$/$2b$/$2y$ + 2位cost + $ + 53字符
|
||||||
|
valid_admin_count = sum(
|
||||||
|
1
|
||||||
|
for admin in local_admins
|
||||||
|
if admin.password_hash
|
||||||
|
and isinstance(admin.password_hash, str)
|
||||||
|
and BCRYPT_HASH_PATTERN.match(admin.password_hash)
|
||||||
|
)
|
||||||
|
if valid_admin_count < 1:
|
||||||
|
raise InvalidRequestException(
|
||||||
|
"启用 LDAP 独占模式前,必须至少保留 1 个有效的本地管理员账户(含有效密码)作为紧急恢复通道"
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_update.bind_password is not None:
|
||||||
|
if config_update.bind_password == "":
|
||||||
|
# 显式清除密码(设置为 NULL)
|
||||||
|
config.bind_password_encrypted = None
|
||||||
|
password_changed = "cleared"
|
||||||
|
else:
|
||||||
|
config.bind_password_encrypted = crypto_service.encrypt(config_update.bind_password)
|
||||||
|
password_changed = "updated"
|
||||||
|
else:
|
||||||
|
password_changed = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 记录审计日志
|
||||||
|
AuditService.log_event(
|
||||||
|
db=db,
|
||||||
|
event_type=AuditEventType.CONFIG_CHANGED,
|
||||||
|
description=f"LDAP 配置已更新 (enabled={config_update.is_enabled}, exclusive={config_update.is_exclusive})",
|
||||||
|
user_id=str(context.user.id) if context.user else None,
|
||||||
|
metadata={
|
||||||
|
"server_url": config_update.server_url,
|
||||||
|
"is_enabled": config_update.is_enabled,
|
||||||
|
"is_exclusive": config_update.is_exclusive,
|
||||||
|
"password_changed": password_changed,
|
||||||
|
"is_new_config": is_new_config,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "LDAP配置更新成功"}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTestLDAPConnectionAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
|
||||||
|
from src.services.auth.ldap import LDAPService
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
if context.json_body is not None:
|
||||||
|
payload = context.json_body
|
||||||
|
elif not context.raw_body:
|
||||||
|
payload = {}
|
||||||
|
else:
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
saved_config = db.query(LDAPConfig).first()
|
||||||
|
|
||||||
|
try:
|
||||||
|
overrides = LDAPConfigTest.model_validate(payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
errors = e.errors()
|
||||||
|
if errors:
|
||||||
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||||
|
raise InvalidRequestException("请求数据验证失败")
|
||||||
|
|
||||||
|
config_data: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
if saved_config:
|
||||||
|
config_data = {
|
||||||
|
"server_url": saved_config.server_url,
|
||||||
|
"bind_dn": saved_config.bind_dn,
|
||||||
|
"base_dn": saved_config.base_dn,
|
||||||
|
"user_search_filter": saved_config.user_search_filter,
|
||||||
|
"username_attr": saved_config.username_attr,
|
||||||
|
"email_attr": saved_config.email_attr,
|
||||||
|
"display_name_attr": saved_config.display_name_attr,
|
||||||
|
"use_starttls": saved_config.use_starttls,
|
||||||
|
"connect_timeout": saved_config.connect_timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 应用前端传入的覆盖值
|
||||||
|
for field in [
|
||||||
|
"server_url",
|
||||||
|
"bind_dn",
|
||||||
|
"base_dn",
|
||||||
|
"user_search_filter",
|
||||||
|
"username_attr",
|
||||||
|
"email_attr",
|
||||||
|
"display_name_attr",
|
||||||
|
"use_starttls",
|
||||||
|
"is_enabled",
|
||||||
|
"is_exclusive",
|
||||||
|
"connect_timeout",
|
||||||
|
]:
|
||||||
|
value = getattr(overrides, field)
|
||||||
|
if value is not None:
|
||||||
|
config_data[field] = value
|
||||||
|
|
||||||
|
# bind_password 优先使用 overrides;否则使用已保存的密码(允许保存密码无法解密时依然用 overrides 测试)
|
||||||
|
if overrides.bind_password is not None:
|
||||||
|
config_data["bind_password"] = overrides.bind_password
|
||||||
|
elif saved_config and saved_config.bind_password_encrypted:
|
||||||
|
try:
|
||||||
|
config_data["bind_password"] = crypto_service.decrypt(
|
||||||
|
saved_config.bind_password_encrypted
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"绑定密码解密失败: {type(e).__name__}: {e}")
|
||||||
|
return LDAPTestResponse(
|
||||||
|
success=False, message="绑定密码解密失败,请检查配置或重新设置密码"
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
# 必填字段检查
|
||||||
|
required_fields = ["server_url", "bind_dn", "base_dn", "bind_password"]
|
||||||
|
missing = [f for f in required_fields if not config_data.get(f)]
|
||||||
|
if missing:
|
||||||
|
return LDAPTestResponse(
|
||||||
|
success=False, message=f"缺少必要字段: {', '.join(missing)}"
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
success, message = LDAPService.test_connection_with_config(config_data)
|
||||||
|
return LDAPTestResponse(success=success, message=message).model_dump()
|
||||||
10
src/api/admin/management_tokens/__init__.py
Normal file
10
src/api/admin/management_tokens/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""Management Token 管理员路由模块"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .routes import router as management_tokens_router
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(management_tokens_router)
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
300
src/api/admin/management_tokens/routes.py
Normal file
300
src/api/admin/management_tokens/routes.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""管理员 Management Token 管理端点"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
|
from src.core.exceptions import NotFoundException
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import AuditEventType, ManagementToken, User
|
||||||
|
from src.services.management_token import ManagementTokenService, token_to_dict
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/management-tokens", tags=["Admin - Management Tokens"])
|
||||||
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
|
# ============== 安全基类 ==============
|
||||||
|
|
||||||
|
|
||||||
|
class AdminManagementTokenApiAdapter(AdminApiAdapter):
|
||||||
|
"""管理员 Management Token 管理 API 的基类
|
||||||
|
|
||||||
|
安全限制:禁止使用 Management Token 调用这些接口。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authorize(self, context: ApiRequestContext) -> None:
|
||||||
|
# 先调用父类的认证和权限检查
|
||||||
|
super().authorize(context)
|
||||||
|
|
||||||
|
# 禁止使用 Management Token 调用 management-tokens 相关接口
|
||||||
|
if context.management_token is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="不允许使用 Management Token 管理其他 Token,请使用 Web 界面或 JWT 认证",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============== 路由 ==============
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_all_management_tokens(
|
||||||
|
request: Request,
|
||||||
|
user_id: Optional[str] = Query(None, description="筛选用户 ID"),
|
||||||
|
is_active: Optional[bool] = Query(None, description="筛选激活状态"),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""列出所有 Management Tokens(管理员)
|
||||||
|
|
||||||
|
管理员查看所有用户的 Management Tokens,支持筛选和分页。
|
||||||
|
|
||||||
|
**查询参数**
|
||||||
|
- user_id (Optional[str]): 筛选指定用户 ID 的 tokens
|
||||||
|
- is_active (Optional[bool]): 筛选激活状态(true/false)
|
||||||
|
- skip (int): 分页偏移量,默认 0
|
||||||
|
- limit (int): 每页数量,范围 1-100,默认 50
|
||||||
|
|
||||||
|
**返回字段**
|
||||||
|
- items (List[dict]): Token 列表
|
||||||
|
- id (str): Token ID
|
||||||
|
- user_id (str): 所属用户 ID
|
||||||
|
- user (dict): 用户信息(包含 id, username, email 等)
|
||||||
|
- name (str): Token 名称
|
||||||
|
- description (Optional[str]): 描述
|
||||||
|
- token_hash (str): Token 哈希值(不返回明文)
|
||||||
|
- is_active (bool): 是否激活
|
||||||
|
- allowed_ips (Optional[List[str]]): IP 白名单
|
||||||
|
- expires_at (Optional[str]): 过期时间(ISO 8601 格式)
|
||||||
|
- last_used_at (Optional[str]): 最后使用时间
|
||||||
|
- created_at (str): 创建时间
|
||||||
|
- updated_at (str): 更新时间
|
||||||
|
- total (int): 总数量
|
||||||
|
- skip (int): 当前偏移量
|
||||||
|
- limit (int): 当前每页数量
|
||||||
|
"""
|
||||||
|
adapter = AdminListManagementTokensAdapter(
|
||||||
|
user_id=user_id, is_active=is_active, skip=skip, limit=limit
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{token_id}")
|
||||||
|
async def get_management_token(
|
||||||
|
token_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取 Management Token 详情(管理员)
|
||||||
|
|
||||||
|
管理员查看任意 Management Token 的详细信息。
|
||||||
|
|
||||||
|
**路径参数**
|
||||||
|
- token_id (str): Token ID
|
||||||
|
|
||||||
|
**返回字段**
|
||||||
|
- id (str): Token ID
|
||||||
|
- user_id (str): 所属用户 ID
|
||||||
|
- user (dict): 用户信息(包含 id, username, email 等)
|
||||||
|
- name (str): Token 名称
|
||||||
|
- description (Optional[str]): 描述
|
||||||
|
- token_hash (str): Token 哈希值(不返回明文)
|
||||||
|
- is_active (bool): 是否激活
|
||||||
|
- allowed_ips (Optional[List[str]]): IP 白名单
|
||||||
|
- expires_at (Optional[str]): 过期时间(ISO 8601 格式)
|
||||||
|
- last_used_at (Optional[str]): 最后使用时间
|
||||||
|
- created_at (str): 创建时间
|
||||||
|
- updated_at (str): 更新时间
|
||||||
|
"""
|
||||||
|
adapter = AdminGetManagementTokenAdapter(token_id=token_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{token_id}")
|
||||||
|
async def delete_management_token(
|
||||||
|
token_id: str, request: Request, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除任意 Management Token(管理员)
|
||||||
|
|
||||||
|
管理员可以删除任意用户的 Management Token。
|
||||||
|
|
||||||
|
**路径参数**
|
||||||
|
- token_id (str): 要删除的 Token ID
|
||||||
|
|
||||||
|
**返回字段**
|
||||||
|
- message (str): 操作结果消息
|
||||||
|
"""
|
||||||
|
adapter = AdminDeleteManagementTokenAdapter(token_id=token_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{token_id}/status")
|
||||||
|
async def toggle_management_token(
|
||||||
|
token_id: str, request: Request, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""切换任意 Management Token 状态(管理员)
|
||||||
|
|
||||||
|
管理员可以启用/禁用任意用户的 Management Token。
|
||||||
|
|
||||||
|
**路径参数**
|
||||||
|
- token_id (str): Token ID
|
||||||
|
|
||||||
|
**返回字段**
|
||||||
|
- message (str): 操作结果消息("Token 已启用" 或 "Token 已禁用")
|
||||||
|
- data (dict): 更新后的 Token 信息
|
||||||
|
- id (str): Token ID
|
||||||
|
- user_id (str): 所属用户 ID
|
||||||
|
- user (dict): 用户信息
|
||||||
|
- name (str): Token 名称
|
||||||
|
- description (Optional[str]): 描述
|
||||||
|
- token_hash (str): Token 哈希值
|
||||||
|
- is_active (bool): 是否激活(已切换后的状态)
|
||||||
|
- allowed_ips (Optional[List[str]]): IP 白名单
|
||||||
|
- expires_at (Optional[str]): 过期时间
|
||||||
|
- last_used_at (Optional[str]): 最后使用时间
|
||||||
|
- created_at (str): 创建时间
|
||||||
|
- updated_at (str): 更新时间
|
||||||
|
"""
|
||||||
|
adapter = AdminToggleManagementTokenAdapter(token_id=token_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# ============== 适配器 ==============
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminListManagementTokensAdapter(AdminManagementTokenApiAdapter):
|
||||||
|
"""列出所有 Management Tokens"""
|
||||||
|
|
||||||
|
name: str = "admin_list_management_tokens"
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
skip: int = 0
|
||||||
|
limit: int = 50
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext):
|
||||||
|
# 构建查询
|
||||||
|
query = context.db.query(ManagementToken)
|
||||||
|
|
||||||
|
if self.user_id:
|
||||||
|
query = query.filter(ManagementToken.user_id == self.user_id)
|
||||||
|
if self.is_active is not None:
|
||||||
|
query = query.filter(ManagementToken.is_active == self.is_active)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
tokens = (
|
||||||
|
query.order_by(ManagementToken.created_at.desc())
|
||||||
|
.offset(self.skip)
|
||||||
|
.limit(self.limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 预加载用户信息
|
||||||
|
user_ids = list(set(t.user_id for t in tokens))
|
||||||
|
users = {u.id: u for u in context.db.query(User).filter(User.id.in_(user_ids)).all()}
|
||||||
|
for token in tokens:
|
||||||
|
token.user = users.get(token.user_id)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"items": [token_to_dict(t, include_user=True) for t in tokens],
|
||||||
|
"total": total,
|
||||||
|
"skip": self.skip,
|
||||||
|
"limit": self.limit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminGetManagementTokenAdapter(AdminManagementTokenApiAdapter):
|
||||||
|
"""获取 Management Token 详情"""
|
||||||
|
|
||||||
|
name: str = "admin_get_management_token"
|
||||||
|
token_id: str = ""
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext):
|
||||||
|
token = ManagementTokenService.get_token_by_id(
|
||||||
|
db=context.db, token_id=self.token_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise NotFoundException("Management Token 不存在")
|
||||||
|
|
||||||
|
# 加载用户信息
|
||||||
|
token.user = context.db.query(User).filter(User.id == token.user_id).first()
|
||||||
|
|
||||||
|
return JSONResponse(content=token_to_dict(token, include_user=True))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminDeleteManagementTokenAdapter(AdminManagementTokenApiAdapter):
|
||||||
|
"""删除 Management Token"""
|
||||||
|
|
||||||
|
name: str = "admin_delete_management_token"
|
||||||
|
token_id: str = ""
|
||||||
|
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_DELETED
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext):
|
||||||
|
# 先获取 token 信息用于审计
|
||||||
|
token = ManagementTokenService.get_token_by_id(
|
||||||
|
db=context.db, token_id=self.token_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise NotFoundException("Management Token 不存在")
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
token_id=token.id,
|
||||||
|
token_name=token.name,
|
||||||
|
owner_user_id=token.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
success = ManagementTokenService.delete_token(
|
||||||
|
db=context.db, token_id=self.token_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise NotFoundException("Management Token 不存在")
|
||||||
|
|
||||||
|
return JSONResponse(content={"message": "删除成功"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminToggleManagementTokenAdapter(AdminManagementTokenApiAdapter):
|
||||||
|
"""切换 Management Token 状态"""
|
||||||
|
|
||||||
|
name: str = "admin_toggle_management_token"
|
||||||
|
token_id: str = ""
|
||||||
|
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext):
|
||||||
|
token = ManagementTokenService.toggle_status(
|
||||||
|
db=context.db, token_id=self.token_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise NotFoundException("Management Token 不存在")
|
||||||
|
|
||||||
|
# 加载用户信息
|
||||||
|
token.user = context.db.query(User).filter(User.id == token.user_id).first()
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
token_id=token.id,
|
||||||
|
token_name=token.name,
|
||||||
|
owner_user_id=token.user_id,
|
||||||
|
is_active=token.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"message": f"Token 已{'启用' if token.is_active else '禁用'}",
|
||||||
|
"data": token_to_dict(token, include_user=True),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -8,7 +8,7 @@ from .catalog import router as catalog_router
|
|||||||
from .external import router as external_router
|
from .external import router as external_router
|
||||||
from .global_models import router as global_models_router
|
from .global_models import router as global_models_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Model Management"])
|
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Models"])
|
||||||
|
|
||||||
# 挂载子路由
|
# 挂载子路由
|
||||||
router.include_router(catalog_router)
|
router.include_router(catalog_router)
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ async def get_model_catalog(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> ModelCatalogResponse:
|
) -> ModelCatalogResponse:
|
||||||
|
"""
|
||||||
|
获取统一模型目录
|
||||||
|
|
||||||
|
基于 GlobalModel 聚合所有活跃模型及其关联提供商的信息,返回完整的模型目录视图。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `models`: 模型列表,每个模型包含:
|
||||||
|
- `global_model_name`: GlobalModel 名称
|
||||||
|
- `display_name`: 显示名称
|
||||||
|
- `description`: 模型描述
|
||||||
|
- `providers`: 提供商列表,包含提供商名称、价格、能力等详细信息
|
||||||
|
- `price_range`: 价格区间(基于 GlobalModel 第一阶梯价格)
|
||||||
|
- `total_providers`: 关联提供商数量
|
||||||
|
- `capabilities`: 模型能力标志(视觉、函数调用、流式输出)
|
||||||
|
- `total`: 模型总数
|
||||||
|
"""
|
||||||
adapter = AdminGetModelCatalogAdapter()
|
adapter = AdminGetModelCatalogAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -109,7 +125,6 @@ class AdminGetModelCatalogAdapter(AdminApiAdapter):
|
|||||||
ModelCatalogProviderDetail(
|
ModelCatalogProviderDetail(
|
||||||
provider_id=provider.id,
|
provider_id=provider.id,
|
||||||
provider_name=provider.name,
|
provider_name=provider.name,
|
||||||
provider_display_name=provider.display_name,
|
|
||||||
model_id=model.id,
|
model_id=model.id,
|
||||||
target_model=model.provider_model_name,
|
target_model=model.provider_model_name,
|
||||||
# 显示有效价格
|
# 显示有效价格
|
||||||
|
|||||||
@@ -82,9 +82,21 @@ def _mark_official_providers(data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
@router.get("/external")
|
@router.get("/external")
|
||||||
async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
|
async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
获取 models.dev 的模型数据(代理请求,解决跨域问题)
|
获取外部模型数据
|
||||||
数据缓存 15 分钟(使用 Redis,多 worker 共享)
|
|
||||||
每个提供商会标记 official 字段,前端可据此过滤
|
从 models.dev 获取第三方模型数据,用于导入新模型或参考定价信息。
|
||||||
|
该接口作为代理请求解决跨域问题,并提供缓存优化。
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
- 代理 models.dev API,解决前端跨域问题
|
||||||
|
- 使用 Redis 缓存 15 分钟,多 worker 共享缓存
|
||||||
|
- 自动标记官方提供商(official 字段),前端可据此过滤第三方转售商
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 键为提供商 ID(如 "anthropic"、"openai")
|
||||||
|
- 值为提供商详细信息,包含:
|
||||||
|
- `official`: 是否为官方提供商(true/false)
|
||||||
|
- 其他 models.dev 提供的原始字段(模型列表、定价等)
|
||||||
"""
|
"""
|
||||||
# 检查缓存
|
# 检查缓存
|
||||||
cached = await _get_cached_data()
|
cached = await _get_cached_data()
|
||||||
@@ -130,7 +142,16 @@ async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
|
|||||||
|
|
||||||
@router.delete("/external/cache")
|
@router.delete("/external/cache")
|
||||||
async def clear_external_models_cache(_: User = Depends(require_admin)) -> dict:
|
async def clear_external_models_cache(_: User = Depends(require_admin)) -> dict:
|
||||||
"""清除 models.dev 缓存"""
|
"""
|
||||||
|
清除外部模型数据缓存
|
||||||
|
|
||||||
|
手动清除 models.dev 的 Redis 缓存,强制下次请求重新获取最新数据。
|
||||||
|
通常用于需要立即更新外部模型数据的场景。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `cleared`: 是否成功清除缓存(true/false)
|
||||||
|
- `message`: 提示信息(仅在 Redis 未启用时返回)
|
||||||
|
"""
|
||||||
redis = await get_redis_client()
|
redis = await get_redis_client()
|
||||||
if redis is None:
|
if redis is None:
|
||||||
return {"cleared": False, "message": "Redis 未启用"}
|
return {"cleared": False, "message": "Redis 未启用"}
|
||||||
|
|||||||
@@ -40,7 +40,27 @@ async def list_global_models(
|
|||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> GlobalModelListResponse:
|
) -> GlobalModelListResponse:
|
||||||
"""获取 GlobalModel 列表"""
|
"""
|
||||||
|
获取 GlobalModel 列表
|
||||||
|
|
||||||
|
查询系统中的全局模型列表,支持分页、过滤和搜索功能。
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `skip`: 跳过记录数,用于分页(默认 0)
|
||||||
|
- `limit`: 返回记录数,用于分页(默认 100,最大 1000)
|
||||||
|
- `is_active`: 过滤活跃状态(true/false/null,null 表示不过滤)
|
||||||
|
- `search`: 搜索关键词,支持按名称或显示名称模糊搜索
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `models`: GlobalModel 列表,每个包含:
|
||||||
|
- `id`: GlobalModel ID
|
||||||
|
- `name`: 模型名称(唯一)
|
||||||
|
- `display_name`: 显示名称
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `provider_count`: 关联提供商数量
|
||||||
|
- 定价和能力配置等其他字段
|
||||||
|
- `total`: 返回的模型总数
|
||||||
|
"""
|
||||||
adapter = AdminListGlobalModelsAdapter(
|
adapter = AdminListGlobalModelsAdapter(
|
||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -56,7 +76,21 @@ async def get_global_model(
|
|||||||
global_model_id: str,
|
global_model_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> GlobalModelWithStats:
|
) -> GlobalModelWithStats:
|
||||||
"""获取单个 GlobalModel 详情(含统计信息)"""
|
"""
|
||||||
|
获取单个 GlobalModel 详情
|
||||||
|
|
||||||
|
查询指定 GlobalModel 的详细信息,包含关联的提供商和价格统计数据。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `global_model_id`: GlobalModel ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 基础字段:`id`, `name`, `display_name`, `is_active` 等
|
||||||
|
- 统计字段:
|
||||||
|
- `total_models`: 关联的 Model 实现数量
|
||||||
|
- `total_providers`: 关联的提供商数量
|
||||||
|
- `price_range`: 价格区间统计(最低/最高输入输出价格)
|
||||||
|
"""
|
||||||
adapter = AdminGetGlobalModelAdapter(global_model_id=global_model_id)
|
adapter = AdminGetGlobalModelAdapter(global_model_id=global_model_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -67,7 +101,24 @@ async def create_global_model(
|
|||||||
payload: GlobalModelCreate,
|
payload: GlobalModelCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> GlobalModelResponse:
|
) -> GlobalModelResponse:
|
||||||
"""创建 GlobalModel"""
|
"""
|
||||||
|
创建 GlobalModel
|
||||||
|
|
||||||
|
创建一个新的全局模型定义,作为多个提供商实现的统一抽象。
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `name`: 模型名称(唯一标识,如 "claude-3-5-sonnet-20241022")
|
||||||
|
- `display_name`: 显示名称(如 "Claude 3.5 Sonnet")
|
||||||
|
- `is_active`: 是否活跃(默认 true)
|
||||||
|
- `default_price_per_request`: 默认按次计费价格(可选)
|
||||||
|
- `default_tiered_pricing`: 默认阶梯定价配置(包含多个价格阶梯)
|
||||||
|
- `supported_capabilities`: 支持的能力标志(vision、function_calling、streaming)
|
||||||
|
- `config`: 额外配置(JSON 格式,如 description、context_window 等)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: 创建的 GlobalModel ID
|
||||||
|
- 其他请求体中的所有字段
|
||||||
|
"""
|
||||||
adapter = AdminCreateGlobalModelAdapter(payload=payload)
|
adapter = AdminCreateGlobalModelAdapter(payload=payload)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -79,7 +130,26 @@ async def update_global_model(
|
|||||||
payload: GlobalModelUpdate,
|
payload: GlobalModelUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> GlobalModelResponse:
|
) -> GlobalModelResponse:
|
||||||
"""更新 GlobalModel"""
|
"""
|
||||||
|
更新 GlobalModel
|
||||||
|
|
||||||
|
更新指定 GlobalModel 的配置信息,支持部分字段更新。
|
||||||
|
更新后会自动失效相关缓存。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `global_model_id`: GlobalModel ID
|
||||||
|
|
||||||
|
**请求体字段**(均为可选):
|
||||||
|
- `display_name`: 显示名称
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `default_price_per_request`: 默认按次计费价格
|
||||||
|
- `default_tiered_pricing`: 默认阶梯定价配置
|
||||||
|
- `supported_capabilities`: 支持的能力标志
|
||||||
|
- `config`: 额外配置
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 更新后的完整 GlobalModel 信息
|
||||||
|
"""
|
||||||
adapter = AdminUpdateGlobalModelAdapter(global_model_id=global_model_id, payload=payload)
|
adapter = AdminUpdateGlobalModelAdapter(global_model_id=global_model_id, payload=payload)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -90,7 +160,18 @@ async def delete_global_model(
|
|||||||
global_model_id: str,
|
global_model_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""删除 GlobalModel(级联删除所有关联的 Provider 模型实现)"""
|
"""
|
||||||
|
删除 GlobalModel
|
||||||
|
|
||||||
|
删除指定的 GlobalModel,会级联删除所有关联的 Provider 模型实现。
|
||||||
|
删除后会自动失效相关缓存。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `global_model_id`: GlobalModel ID
|
||||||
|
|
||||||
|
**返回**:
|
||||||
|
- 成功删除返回 204 状态码,无响应体
|
||||||
|
"""
|
||||||
adapter = AdminDeleteGlobalModelAdapter(global_model_id=global_model_id)
|
adapter = AdminDeleteGlobalModelAdapter(global_model_id=global_model_id)
|
||||||
await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
return None
|
return None
|
||||||
@@ -105,7 +186,29 @@ async def batch_assign_to_providers(
|
|||||||
payload: BatchAssignToProvidersRequest,
|
payload: BatchAssignToProvidersRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> BatchAssignToProvidersResponse:
|
) -> BatchAssignToProvidersResponse:
|
||||||
"""批量为多个 Provider 添加 GlobalModel 实现"""
|
"""
|
||||||
|
批量为提供商添加模型实现
|
||||||
|
|
||||||
|
为指定的 GlobalModel 批量创建多个 Provider 的模型实现(Model 记录)。
|
||||||
|
用于快速将一个统一模型分配给多个提供商。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `global_model_id`: GlobalModel ID
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `provider_ids`: 提供商 ID 列表
|
||||||
|
- `create_models`: Model 创建配置列表,每个包含:
|
||||||
|
- `provider_id`: 提供商 ID
|
||||||
|
- `provider_model_name`: 提供商侧的模型名称(如 "claude-3-5-sonnet-20241022")
|
||||||
|
- 其他可选字段(价格覆盖、能力覆盖等)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `success`: 成功创建的 Model 列表
|
||||||
|
- `errors`: 失败的提供商及错误信息列表
|
||||||
|
- `total_requested`: 请求处理的总数
|
||||||
|
- `total_success`: 成功创建的数量
|
||||||
|
- `total_errors`: 失败的数量
|
||||||
|
"""
|
||||||
adapter = AdminBatchAssignToProvidersAdapter(global_model_id=global_model_id, payload=payload)
|
adapter = AdminBatchAssignToProvidersAdapter(global_model_id=global_model_id, payload=payload)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -116,7 +219,27 @@ async def get_global_model_providers(
|
|||||||
global_model_id: str,
|
global_model_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> GlobalModelProvidersResponse:
|
) -> GlobalModelProvidersResponse:
|
||||||
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
|
"""
|
||||||
|
获取 GlobalModel 的关联提供商
|
||||||
|
|
||||||
|
查询指定 GlobalModel 的所有关联提供商及其模型实现详情,包括非活跃的提供商。
|
||||||
|
用于查看某个统一模型在各个提供商上的具体配置。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `global_model_id`: GlobalModel ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `providers`: 提供商列表,每个包含:
|
||||||
|
- `provider_id`: 提供商 ID
|
||||||
|
- `provider_name`: 提供商名称
|
||||||
|
- `provider_display_name`: 提供商显示名称
|
||||||
|
- `model_id`: Model 实现 ID
|
||||||
|
- `target_model`: 提供商侧的模型名称
|
||||||
|
- 价格信息(input_price_per_1m、output_price_per_1m 等)
|
||||||
|
- 能力标志(supports_vision、supports_function_calling、supports_streaming)
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `total`: 关联提供商总数
|
||||||
|
"""
|
||||||
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
|
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -146,20 +269,25 @@ class AdminListGlobalModelsAdapter(AdminApiAdapter):
|
|||||||
search=self.search,
|
search=self.search,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 为每个 GlobalModel 添加统计数据
|
# 一次性查询所有 GlobalModel 的 provider_count(优化 N+1 问题)
|
||||||
|
model_ids = [gm.id for gm in models]
|
||||||
|
provider_counts = {}
|
||||||
|
if model_ids:
|
||||||
|
count_results = (
|
||||||
|
context.db.query(
|
||||||
|
Model.global_model_id, func.count(func.distinct(Model.provider_id))
|
||||||
|
)
|
||||||
|
.filter(Model.global_model_id.in_(model_ids))
|
||||||
|
.group_by(Model.global_model_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
provider_counts = {gm_id: count for gm_id, count in count_results}
|
||||||
|
|
||||||
|
# 构建响应
|
||||||
model_responses = []
|
model_responses = []
|
||||||
for gm in models:
|
for gm in models:
|
||||||
# 统计关联的 Model 数量(去重 Provider)
|
|
||||||
provider_count = (
|
|
||||||
context.db.query(func.count(func.distinct(Model.provider_id)))
|
|
||||||
.filter(Model.global_model_id == gm.id)
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
response = GlobalModelResponse.model_validate(gm)
|
response = GlobalModelResponse.model_validate(gm)
|
||||||
response.provider_count = provider_count
|
response.provider_count = provider_counts.get(gm.id, 0)
|
||||||
# usage_count 直接从 GlobalModel 表读取,已在 model_validate 中自动映射
|
|
||||||
model_responses.append(response)
|
model_responses.append(response)
|
||||||
|
|
||||||
return GlobalModelListResponse(
|
return GlobalModelListResponse(
|
||||||
@@ -324,7 +452,6 @@ class AdminGetGlobalModelProvidersAdapter(AdminApiAdapter):
|
|||||||
ModelCatalogProviderDetail(
|
ModelCatalogProviderDetail(
|
||||||
provider_id=provider.id,
|
provider_id=provider.id,
|
||||||
provider_name=provider.name,
|
provider_name=provider.name,
|
||||||
provider_display_name=provider.display_name,
|
|
||||||
model_id=model.id,
|
model_id=model.id,
|
||||||
target_model=model.provider_model_name,
|
target_model=model.provider_model_name,
|
||||||
input_price_per_1m=model.get_effective_input_price(),
|
input_price_per_1m=model.get_effective_input_price(),
|
||||||
|
|||||||
@@ -39,6 +39,34 @@ async def get_audit_logs(
|
|||||||
offset: int = Query(0, description="偏移量"),
|
offset: int = Query(0, description="偏移量"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
获取审计日志
|
||||||
|
|
||||||
|
获取系统审计日志列表,支持按用户、事件类型、时间范围筛选。需要管理员权限。
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `user_id`: 可选,用户 ID 筛选(UUID 格式)
|
||||||
|
- `event_type`: 可选,事件类型筛选
|
||||||
|
- `days`: 查询最近多少天的日志,默认 7 天
|
||||||
|
- `limit`: 返回数量限制,默认 100
|
||||||
|
- `offset`: 分页偏移量,默认 0
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `items`: 审计日志列表,每条日志包含:
|
||||||
|
- `id`: 日志 ID
|
||||||
|
- `event_type`: 事件类型
|
||||||
|
- `user_id`: 用户 ID
|
||||||
|
- `user_email`: 用户邮箱
|
||||||
|
- `user_username`: 用户名
|
||||||
|
- `description`: 事件描述
|
||||||
|
- `ip_address`: IP 地址
|
||||||
|
- `status_code`: HTTP 状态码
|
||||||
|
- `error_message`: 错误信息
|
||||||
|
- `metadata`: 事件元数据
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
- `meta`: 分页元数据(total, limit, offset, count)
|
||||||
|
- `filters`: 筛选条件
|
||||||
|
"""
|
||||||
adapter = AdminGetAuditLogsAdapter(
|
adapter = AdminGetAuditLogsAdapter(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
@@ -51,6 +79,19 @@ async def get_audit_logs(
|
|||||||
|
|
||||||
@router.get("/system-status")
|
@router.get("/system-status")
|
||||||
async def get_system_status(request: Request, db: Session = Depends(get_db)):
|
async def get_system_status(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
获取系统状态
|
||||||
|
|
||||||
|
获取系统当前的运行状态和关键指标。需要管理员权限。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `timestamp`: 当前时间戳
|
||||||
|
- `users`: 用户统计(total: 总用户数, active: 活跃用户数)
|
||||||
|
- `providers`: 提供商统计(total: 总提供商数, active: 活跃提供商数)
|
||||||
|
- `api_keys`: API Key 统计(total: 总数, active: 活跃数)
|
||||||
|
- `today_stats`: 今日统计(requests: 请求数, tokens: token 数, cost_usd: 成本)
|
||||||
|
- `recent_errors`: 最近 1 小时内的错误数
|
||||||
|
"""
|
||||||
adapter = AdminSystemStatusAdapter()
|
adapter = AdminSystemStatusAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -61,6 +102,26 @@ async def get_suspicious_activities(
|
|||||||
hours: int = Query(24, description="时间范围(小时)"),
|
hours: int = Query(24, description="时间范围(小时)"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
获取可疑活动记录
|
||||||
|
|
||||||
|
获取系统检测到的可疑活动记录。需要管理员权限。
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `hours`: 时间范围(小时),默认 24 小时
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `activities`: 可疑活动列表,每条记录包含:
|
||||||
|
- `id`: 记录 ID
|
||||||
|
- `event_type`: 事件类型
|
||||||
|
- `user_id`: 用户 ID
|
||||||
|
- `description`: 事件描述
|
||||||
|
- `ip_address`: IP 地址
|
||||||
|
- `metadata`: 事件元数据
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
- `count`: 活动总数
|
||||||
|
- `time_range_hours`: 查询的时间范围(小时)
|
||||||
|
"""
|
||||||
adapter = AdminSuspiciousActivitiesAdapter(hours=hours)
|
adapter = AdminSuspiciousActivitiesAdapter(hours=hours)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -72,19 +133,56 @@ async def analyze_user_behavior(
|
|||||||
days: int = Query(30, description="分析天数"),
|
days: int = Query(30, description="分析天数"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
分析用户行为
|
||||||
|
|
||||||
|
分析指定用户的行为模式和使用情况。需要管理员权限。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `user_id`: 用户 ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `days`: 分析最近多少天的数据,默认 30 天
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 用户行为分析结果,包括活动频率、使用模式、异常行为等
|
||||||
|
"""
|
||||||
adapter = AdminUserBehaviorAdapter(user_id=user_id, days=days)
|
adapter = AdminUserBehaviorAdapter(user_id=user_id, days=days)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/resilience-status")
|
@router.get("/resilience-status")
|
||||||
async def get_resilience_status(request: Request, db: Session = Depends(get_db)):
|
async def get_resilience_status(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
获取韧性系统状态
|
||||||
|
|
||||||
|
获取系统韧性管理的当前状态,包括错误统计、熔断器状态等。需要管理员权限。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `timestamp`: 当前时间戳
|
||||||
|
- `health_score`: 健康评分(0-100)
|
||||||
|
- `status`: 系统状态(healthy: 健康,degraded: 降级,critical: 严重)
|
||||||
|
- `error_statistics`: 错误统计信息
|
||||||
|
- `recent_errors`: 最近的错误列表(最多 10 条)
|
||||||
|
- `recommendations`: 系统建议
|
||||||
|
"""
|
||||||
adapter = AdminResilienceStatusAdapter()
|
adapter = AdminResilienceStatusAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/resilience/error-stats")
|
@router.delete("/resilience/error-stats")
|
||||||
async def reset_error_stats(request: Request, db: Session = Depends(get_db)):
|
async def reset_error_stats(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Reset resilience error statistics"""
|
"""
|
||||||
|
重置错误统计
|
||||||
|
|
||||||
|
重置韧性系统的错误统计数据。需要管理员权限。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果信息
|
||||||
|
- `previous_stats`: 重置前的统计数据
|
||||||
|
- `reset_by`: 执行重置的管理员邮箱
|
||||||
|
- `reset_at`: 重置时间
|
||||||
|
"""
|
||||||
adapter = AdminResetErrorStatsAdapter()
|
adapter = AdminResetErrorStatsAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -95,6 +193,18 @@ async def get_circuit_history(
|
|||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
获取熔断器历史记录
|
||||||
|
|
||||||
|
获取熔断器的状态变更历史记录。需要管理员权限。
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `limit`: 返回数量限制,默认 50,最大 200
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `items`: 熔断器历史记录列表
|
||||||
|
- `count`: 记录总数
|
||||||
|
"""
|
||||||
adapter = AdminCircuitHistoryAdapter(limit=limit)
|
adapter = AdminCircuitHistoryAdapter(limit=limit)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -107,6 +217,9 @@ class AdminGetAuditLogsAdapter(AdminApiAdapter):
|
|||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
# 查看审计日志本身不应该产生审计记录,避免刷新页面时产生大量无意义的日志
|
||||||
|
audit_log_enabled: bool = False
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
cutoff_time = datetime.now(timezone.utc) - timedelta(days=self.days)
|
cutoff_time = datetime.now(timezone.utc) - timedelta(days=self.days)
|
||||||
|
|||||||
@@ -117,12 +117,21 @@ async def get_cache_stats(
|
|||||||
"""
|
"""
|
||||||
获取缓存亲和性统计信息
|
获取缓存亲和性统计信息
|
||||||
|
|
||||||
返回:
|
获取缓存调度器的运行统计数据,包括命中率、切换次数、调度器配置等。
|
||||||
- 缓存命中率
|
用于监控缓存亲和性功能的运行状态和性能指标。
|
||||||
- 缓存用户数
|
|
||||||
- Provider切换次数
|
**返回字段**:
|
||||||
- Key切换次数
|
- `status`: 状态(ok)
|
||||||
- 缓存预留配置
|
- `data`: 统计数据对象
|
||||||
|
- `scheduler`: 调度器名称(cache_aware 或 random)
|
||||||
|
- `total_affinities`: 总缓存亲和性数量
|
||||||
|
- `cache_hit_rate`: 缓存命中率(0.0-1.0)
|
||||||
|
- `provider_switches`: Provider 切换次数
|
||||||
|
- `key_switches`: Key 切换次数
|
||||||
|
- `cache_hits`: 缓存命中次数
|
||||||
|
- `cache_misses`: 缓存未命中次数
|
||||||
|
- `scheduler_metrics`: 调度器详细指标
|
||||||
|
- `affinity_stats`: 亲和性统计数据
|
||||||
"""
|
"""
|
||||||
adapter = AdminCacheStatsAdapter()
|
adapter = AdminCacheStatsAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -137,16 +146,33 @@ async def get_user_affinity(
|
|||||||
"""
|
"""
|
||||||
查询指定用户的所有缓存亲和性
|
查询指定用户的所有缓存亲和性
|
||||||
|
|
||||||
参数:
|
根据用户标识符查询该用户在各个端点上的缓存亲和性记录。
|
||||||
- user_identifier: 用户标识符,支持以下格式:
|
支持多种标识符格式的自动识别和解析。
|
||||||
* 用户名 (username),如: yuanhonghu
|
|
||||||
* 邮箱 (email),如: user@example.com
|
|
||||||
* 用户UUID (user_id),如: 550e8400-e29b-41d4-a716-446655440000
|
|
||||||
* API Key ID,如: 660e8400-e29b-41d4-a716-446655440000
|
|
||||||
|
|
||||||
返回:
|
**路径参数**:
|
||||||
- 用户信息
|
- `user_identifier`: 用户标识符,支持以下格式:
|
||||||
- 所有端点的缓存亲和性列表(每个端点一条记录)
|
- 用户名(username),如:yuanhonghu
|
||||||
|
- 邮箱(email),如:user@example.com
|
||||||
|
- 用户 UUID(user_id),如:550e8400-e29b-41d4-a716-446655440000
|
||||||
|
- API Key ID,如:660e8400-e29b-41d4-a716-446655440000
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok 或 not_found)
|
||||||
|
- `message`: 提示消息(当无缓存时)
|
||||||
|
- `user_info`: 用户信息
|
||||||
|
- `user_id`: 用户 ID
|
||||||
|
- `username`: 用户名
|
||||||
|
- `email`: 邮箱
|
||||||
|
- `affinities`: 缓存亲和性列表
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
- `key_id`: Key ID
|
||||||
|
- `api_format`: API 格式
|
||||||
|
- `model_name`: 模型名称(global_model_id)
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
- `expire_at`: 过期时间
|
||||||
|
- `request_count`: 请求计数
|
||||||
|
- `total_endpoints`: 缓存的端点数量
|
||||||
"""
|
"""
|
||||||
adapter = AdminGetUserAffinityAdapter(user_identifier=user_identifier)
|
adapter = AdminGetUserAffinityAdapter(user_identifier=user_identifier)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -161,10 +187,50 @@ async def list_affinities(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
获取所有缓存亲和性列表,可选按关键词过滤
|
获取所有缓存亲和性列表
|
||||||
|
|
||||||
参数:
|
查询系统中所有的缓存亲和性记录,支持按关键词过滤和分页。
|
||||||
- keyword: 可选,支持用户名/邮箱/User ID/API Key ID 或模糊匹配
|
返回详细的用户、Provider、Endpoint、Key 信息。
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `keyword`: 可选,支持以下过滤方式(可选)
|
||||||
|
- 用户名/邮箱/User ID/API Key ID(精确匹配)
|
||||||
|
- 任意字段的模糊匹配(affinity_key、user_id、username、email、provider_id、key_id)
|
||||||
|
- `limit`: 返回数量限制(1-1000,默认 100)
|
||||||
|
- `offset`: 偏移量(用于分页,默认 0)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `data`: 分页数据对象
|
||||||
|
- `items`: 缓存亲和性列表
|
||||||
|
- `affinity_key`: API Key ID(用于缓存键)
|
||||||
|
- `user_api_key_name`: 用户 API Key 名称
|
||||||
|
- `user_api_key_prefix`: 脱敏后的用户 API Key
|
||||||
|
- `is_standalone`: 是否为独立 API Key
|
||||||
|
- `user_id`: 用户 ID
|
||||||
|
- `username`: 用户名
|
||||||
|
- `email`: 邮箱
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `provider_name`: Provider 显示名称
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
- `endpoint_api_format`: Endpoint API 格式
|
||||||
|
- `endpoint_url`: Endpoint 基础 URL
|
||||||
|
- `key_id`: Key ID
|
||||||
|
- `key_name`: Key 名称
|
||||||
|
- `key_prefix`: 脱敏后的 Provider Key
|
||||||
|
- `rate_multiplier`: 速率倍数
|
||||||
|
- `global_model_id`: GlobalModel ID
|
||||||
|
- `model_name`: 模型名称
|
||||||
|
- `model_display_name`: 模型显示名称
|
||||||
|
- `api_format`: API 格式
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
- `expire_at`: 过期时间
|
||||||
|
- `request_count`: 请求计数
|
||||||
|
- `meta`: 分页元数据
|
||||||
|
- `count`: 总数量
|
||||||
|
- `limit`: 每页数量
|
||||||
|
- `offset`: 当前偏移量
|
||||||
|
- `matched_user_id`: 匹配到的用户 ID(当关键词为用户标识时)
|
||||||
"""
|
"""
|
||||||
adapter = AdminListAffinitiesAdapter(keyword=keyword, limit=limit, offset=offset)
|
adapter = AdminListAffinitiesAdapter(keyword=keyword, limit=limit, offset=offset)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -177,10 +243,27 @@ async def clear_user_cache(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Clear cache affinity for a specific user
|
清除指定用户的缓存亲和性
|
||||||
|
|
||||||
Parameters:
|
清除指定用户或 API Key 的所有缓存亲和性记录。
|
||||||
- user_identifier: User identifier (username, email, user_id, or API Key ID)
|
支持按用户维度或单个 API Key 维度清除。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `user_identifier`: 用户标识符,支持以下格式:
|
||||||
|
- 用户名(username)
|
||||||
|
- 邮箱(email)
|
||||||
|
- 用户 UUID(user_id)
|
||||||
|
- API Key ID(清除该 API Key 的缓存)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `user_info`: 用户信息
|
||||||
|
- `user_id`: 用户 ID
|
||||||
|
- `username`: 用户名
|
||||||
|
- `email`: 邮箱
|
||||||
|
- `api_key_id`: API Key ID(当清除单个 API Key 时)
|
||||||
|
- `api_key_name`: API Key 名称(当清除单个 API Key 时)
|
||||||
"""
|
"""
|
||||||
adapter = AdminClearUserCacheAdapter(user_identifier=user_identifier)
|
adapter = AdminClearUserCacheAdapter(user_identifier=user_identifier)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -196,13 +279,23 @@ async def clear_single_affinity(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Clear a single cache affinity entry
|
清除单条缓存亲和性记录
|
||||||
|
|
||||||
Parameters:
|
根据精确的缓存键(affinity_key + endpoint_id + model_id + api_format)
|
||||||
- affinity_key: API Key ID
|
清除单条缓存亲和性记录。用于精确控制缓存清除。
|
||||||
- endpoint_id: Endpoint ID
|
|
||||||
- model_id: Model ID (GlobalModel ID)
|
**路径参数**:
|
||||||
- api_format: API format (claude/openai)
|
- `affinity_key`: API Key ID(用于缓存的键)
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
- `model_id`: GlobalModel ID
|
||||||
|
- `api_format`: API 格式(如:claude、openai、gemini)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `affinity_key`: API Key ID
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
- `model_id`: GlobalModel ID
|
||||||
"""
|
"""
|
||||||
adapter = AdminClearSingleAffinityAdapter(
|
adapter = AdminClearSingleAffinityAdapter(
|
||||||
affinity_key=affinity_key, endpoint_id=endpoint_id, model_id=model_id, api_format=api_format
|
affinity_key=affinity_key, endpoint_id=endpoint_id, model_id=model_id, api_format=api_format
|
||||||
@@ -216,9 +309,17 @@ async def clear_all_cache(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Clear all cache affinities
|
清除所有缓存亲和性
|
||||||
|
|
||||||
Warning: This affects all users, use with caution
|
清除系统中所有用户的缓存亲和性记录。此操作会影响所有用户,
|
||||||
|
下次请求时将重新建立缓存亲和性。请谨慎使用。
|
||||||
|
|
||||||
|
**警告**: 此操作影响所有用户,使用前请确认
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `count`: 清除的缓存数量
|
||||||
"""
|
"""
|
||||||
adapter = AdminClearAllCacheAdapter()
|
adapter = AdminClearAllCacheAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -231,10 +332,19 @@ async def clear_provider_cache(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Clear cache affinities for a specific provider
|
清除指定 Provider 的缓存亲和性
|
||||||
|
|
||||||
Parameters:
|
清除与指定 Provider 相关的所有缓存亲和性记录。
|
||||||
- provider_id: Provider ID
|
当 Provider 配置变更或下线时使用。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `count`: 清除的缓存数量
|
||||||
"""
|
"""
|
||||||
adapter = AdminClearProviderCacheAdapter(provider_id=provider_id)
|
adapter = AdminClearProviderCacheAdapter(provider_id=provider_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -248,9 +358,25 @@ async def get_cache_config(
|
|||||||
"""
|
"""
|
||||||
获取缓存相关配置
|
获取缓存相关配置
|
||||||
|
|
||||||
返回:
|
获取缓存亲和性功能的配置参数,包括缓存 TTL、预留比例、
|
||||||
- 缓存TTL
|
动态预留机制配置等。
|
||||||
- 缓存预留比例
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `data`: 配置数据
|
||||||
|
- `cache_ttl_seconds`: 缓存亲和性有效期(秒)
|
||||||
|
- `cache_reservation_ratio`: 静态预留比例(已被动态预留替代)
|
||||||
|
- `dynamic_reservation`: 动态预留机制配置
|
||||||
|
- `enabled`: 是否启用
|
||||||
|
- `config`: 配置参数
|
||||||
|
- `probe_phase_requests`: 探测阶段请求数阈值
|
||||||
|
- `probe_reservation`: 探测阶段预留比例
|
||||||
|
- `stable_min_reservation`: 稳定阶段最小预留比例
|
||||||
|
- `stable_max_reservation`: 稳定阶段最大预留比例
|
||||||
|
- `low_load_threshold`: 低负载阈值
|
||||||
|
- `high_load_threshold`: 高负载阈值
|
||||||
|
- `description`: 各参数说明
|
||||||
|
- `description`: 配置说明
|
||||||
"""
|
"""
|
||||||
adapter = AdminCacheConfigAdapter()
|
adapter = AdminCacheConfigAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -262,7 +388,31 @@ async def get_cache_metrics(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
以 Prometheus 文本格式暴露缓存调度指标,方便接入 Grafana。
|
获取缓存调度指标(Prometheus 格式)
|
||||||
|
|
||||||
|
以 Prometheus 文本格式输出缓存调度器的监控指标,
|
||||||
|
方便接入 Prometheus/Grafana 等监控系统。
|
||||||
|
|
||||||
|
**返回格式**: Prometheus 文本格式(Content-Type: text/plain)
|
||||||
|
|
||||||
|
**指标列表**:
|
||||||
|
- `cache_scheduler_total_batches`: 总批次数
|
||||||
|
- `cache_scheduler_last_batch_size`: 最后一批候选数
|
||||||
|
- `cache_scheduler_total_candidates`: 总候选数
|
||||||
|
- `cache_scheduler_last_candidate_count`: 最后一批候选计数
|
||||||
|
- `cache_scheduler_cache_hits`: 缓存命中次数
|
||||||
|
- `cache_scheduler_cache_misses`: 缓存未命中次数
|
||||||
|
- `cache_scheduler_cache_hit_rate`: 缓存命中率
|
||||||
|
- `cache_scheduler_concurrency_denied`: 并发拒绝次数
|
||||||
|
- `cache_scheduler_avg_candidates_per_batch`: 平均每批候选数
|
||||||
|
- `cache_affinity_total`: 总缓存亲和性数量
|
||||||
|
- `cache_affinity_hits`: 亲和性命中次数
|
||||||
|
- `cache_affinity_misses`: 亲和性未命中次数
|
||||||
|
- `cache_affinity_hit_rate`: 亲和性命中率
|
||||||
|
- `cache_affinity_invalidations`: 亲和性失效次数
|
||||||
|
- `cache_affinity_provider_switches`: Provider 切换次数
|
||||||
|
- `cache_affinity_key_switches`: Key 切换次数
|
||||||
|
- `cache_scheduler_info`: 调度器信息(label: scheduler)
|
||||||
"""
|
"""
|
||||||
adapter = AdminCacheMetricsAdapter()
|
adapter = AdminCacheMetricsAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -669,7 +819,7 @@ class AdminListAffinitiesAdapter(AdminApiAdapter):
|
|||||||
"username": user.username if user else None,
|
"username": user.username if user else None,
|
||||||
"email": user.email if user else None,
|
"email": user.email if user else None,
|
||||||
"provider_id": provider_id,
|
"provider_id": provider_id,
|
||||||
"provider_name": provider.display_name if provider else None,
|
"provider_name": provider.name if provider else None,
|
||||||
"endpoint_id": endpoint_id,
|
"endpoint_id": endpoint_id,
|
||||||
"endpoint_api_format": (
|
"endpoint_api_format": (
|
||||||
endpoint.api_format if endpoint and endpoint.api_format else None
|
endpoint.api_format if endpoint and endpoint.api_format else None
|
||||||
@@ -998,10 +1148,39 @@ async def get_model_mapping_cache_stats(
|
|||||||
"""
|
"""
|
||||||
获取模型映射缓存统计信息
|
获取模型映射缓存统计信息
|
||||||
|
|
||||||
返回:
|
获取模型解析缓存的详细统计信息,包括各类型缓存键数量、
|
||||||
- 缓存键数量
|
映射关系列表、Provider 级别的模型映射缓存等。
|
||||||
- 缓存 TTL 配置
|
|
||||||
- 各类型缓存数量
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `data`: 统计数据
|
||||||
|
- `available`: Redis 是否可用
|
||||||
|
- `message`: 提示消息(当 Redis 未启用时)
|
||||||
|
- `ttl_seconds`: 缓存 TTL(秒)
|
||||||
|
- `total_keys`: 总缓存键数量
|
||||||
|
- `breakdown`: 各类型缓存键数量分解
|
||||||
|
- `model_by_id`: Model ID 缓存数量
|
||||||
|
- `model_by_provider_global`: Provider-GlobalModel 缓存数量
|
||||||
|
- `global_model_by_id`: GlobalModel ID 缓存数量
|
||||||
|
- `global_model_by_name`: GlobalModel 名称缓存数量
|
||||||
|
- `global_model_resolve`: GlobalModel 解析缓存数量
|
||||||
|
- `mappings`: 模型映射列表(最多 100 条)
|
||||||
|
- `mapping_name`: 映射名称(别名)
|
||||||
|
- `global_model_name`: GlobalModel 名称
|
||||||
|
- `global_model_display_name`: GlobalModel 显示名称
|
||||||
|
- `providers`: 使用该映射的 Provider 列表
|
||||||
|
- `ttl`: 缓存剩余 TTL(秒)
|
||||||
|
- `provider_model_mappings`: Provider 级别的模型映射(最多 100 条)
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `provider_name`: Provider 名称
|
||||||
|
- `global_model_id`: GlobalModel ID
|
||||||
|
- `global_model_name`: GlobalModel 名称
|
||||||
|
- `global_model_display_name`: GlobalModel 显示名称
|
||||||
|
- `provider_model_name`: Provider 侧的模型名称
|
||||||
|
- `aliases`: 别名列表
|
||||||
|
- `ttl`: 缓存剩余 TTL(秒)
|
||||||
|
- `hit_count`: 缓存命中次数
|
||||||
|
- `unmapped`: 未映射或无效的缓存条目
|
||||||
"""
|
"""
|
||||||
adapter = AdminModelMappingCacheStatsAdapter()
|
adapter = AdminModelMappingCacheStatsAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -1015,7 +1194,15 @@ async def clear_all_model_mapping_cache(
|
|||||||
"""
|
"""
|
||||||
清除所有模型映射缓存
|
清除所有模型映射缓存
|
||||||
|
|
||||||
警告: 这会影响所有模型解析,请谨慎使用
|
清除系统中所有的模型映射缓存,包括 Model、GlobalModel、
|
||||||
|
模型解析等所有相关缓存。下次请求时将重新从数据库查询。
|
||||||
|
|
||||||
|
**警告**: 此操作会影响所有模型解析,请谨慎使用
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `deleted_count`: 删除的缓存键数量
|
||||||
"""
|
"""
|
||||||
adapter = AdminClearAllModelMappingCacheAdapter()
|
adapter = AdminClearAllModelMappingCacheAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -1030,8 +1217,17 @@ async def clear_model_mapping_cache_by_name(
|
|||||||
"""
|
"""
|
||||||
清除指定模型名称的映射缓存
|
清除指定模型名称的映射缓存
|
||||||
|
|
||||||
参数:
|
根据模型名称清除相关的映射缓存,包括 resolve 缓存和 name 缓存。
|
||||||
- model_name: 模型名称(可以是 GlobalModel.name 或映射名称)
|
用于更新单个模型的配置后刷新缓存。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `model_name`: 模型名称(可以是 GlobalModel.name 或映射名称)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `model_name`: 模型名称
|
||||||
|
- `deleted_keys`: 删除的缓存键列表
|
||||||
"""
|
"""
|
||||||
adapter = AdminClearModelMappingCacheByNameAdapter(model_name=model_name)
|
adapter = AdminClearModelMappingCacheByNameAdapter(model_name=model_name)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -1047,9 +1243,19 @@ async def clear_provider_model_mapping_cache(
|
|||||||
"""
|
"""
|
||||||
清除指定 Provider 和 GlobalModel 的模型映射缓存
|
清除指定 Provider 和 GlobalModel 的模型映射缓存
|
||||||
|
|
||||||
参数:
|
清除特定 Provider 和 GlobalModel 组合的映射缓存及其命中次数统计。
|
||||||
- provider_id: Provider ID
|
用于 Provider 模型配置更新后刷新缓存。
|
||||||
- global_model_id: GlobalModel ID
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `global_model_id`: GlobalModel ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `status`: 状态(ok)
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `global_model_id`: GlobalModel ID
|
||||||
|
- `deleted_keys`: 删除的缓存键列表
|
||||||
"""
|
"""
|
||||||
adapter = AdminClearProviderModelMappingCacheAdapter(
|
adapter = AdminClearProviderModelMappingCacheAdapter(
|
||||||
provider_id=provider_id, global_model_id=global_model_id
|
provider_id=provider_id, global_model_id=global_model_id
|
||||||
@@ -1163,9 +1369,7 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
|||||||
for model, provider in models:
|
for model, provider in models:
|
||||||
# 检查是否是主模型名称
|
# 检查是否是主模型名称
|
||||||
if model.provider_model_name == mapping_name:
|
if model.provider_model_name == mapping_name:
|
||||||
provider_names.append(
|
provider_names.append(provider.name)
|
||||||
provider.display_name or provider.name
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
# 检查是否在映射列表中
|
# 检查是否在映射列表中
|
||||||
if model.provider_model_mappings:
|
if model.provider_model_mappings:
|
||||||
@@ -1175,9 +1379,7 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
|||||||
if isinstance(a, dict)
|
if isinstance(a, dict)
|
||||||
]
|
]
|
||||||
if mapping_name in mapping_list:
|
if mapping_name in mapping_list:
|
||||||
provider_names.append(
|
provider_names.append(provider.name)
|
||||||
provider.display_name or provider.name
|
|
||||||
)
|
|
||||||
provider_names = sorted(list(set(provider_names)))
|
provider_names = sorted(list(set(provider_names)))
|
||||||
|
|
||||||
mappings.append({
|
mappings.append({
|
||||||
@@ -1267,7 +1469,7 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
provider_model_mappings.append({
|
provider_model_mappings.append({
|
||||||
"provider_id": provider_id,
|
"provider_id": provider_id,
|
||||||
"provider_name": provider.display_name or provider.name,
|
"provider_name": provider.name,
|
||||||
"global_model_id": global_model_id,
|
"global_model_id": global_model_id,
|
||||||
"global_model_name": global_model.name,
|
"global_model_name": global_model.name,
|
||||||
"global_model_display_name": global_model.display_name,
|
"global_model_display_name": global_model.display_name,
|
||||||
|
|||||||
@@ -71,7 +71,47 @@ async def get_request_trace(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""获取特定请求的完整追踪信息"""
|
"""
|
||||||
|
获取请求的完整追踪信息
|
||||||
|
|
||||||
|
获取指定请求的完整链路追踪信息,包括所有候选(candidates)的执行情况。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `request_id`: 请求 ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `request_id`: 请求 ID
|
||||||
|
- `total_candidates`: 候选总数
|
||||||
|
- `final_status`: 最终状态(success: 成功,failed: 失败,streaming: 流式传输中,pending: 等待中)
|
||||||
|
- `total_latency_ms`: 总延迟(毫秒)
|
||||||
|
- `candidates`: 候选列表,每个候选包含:
|
||||||
|
- `id`: 候选 ID
|
||||||
|
- `request_id`: 请求 ID
|
||||||
|
- `candidate_index`: 候选索引
|
||||||
|
- `retry_index`: 重试序号
|
||||||
|
- `provider_id`: 提供商 ID
|
||||||
|
- `provider_name`: 提供商名称
|
||||||
|
- `provider_website`: 提供商官网
|
||||||
|
- `endpoint_id`: 端点 ID
|
||||||
|
- `endpoint_name`: 端点名称(API 格式)
|
||||||
|
- `key_id`: 密钥 ID
|
||||||
|
- `key_name`: 密钥名称
|
||||||
|
- `key_preview`: 密钥脱敏预览
|
||||||
|
- `key_capabilities`: 密钥支持的能力
|
||||||
|
- `required_capabilities`: 请求需要的能力标签
|
||||||
|
- `status`: 状态(pending, success, failed, skipped)
|
||||||
|
- `skip_reason`: 跳过原因
|
||||||
|
- `is_cached`: 是否缓存命中
|
||||||
|
- `status_code`: HTTP 状态码
|
||||||
|
- `error_type`: 错误类型
|
||||||
|
- `error_message`: 错误信息
|
||||||
|
- `latency_ms`: 延迟(毫秒)
|
||||||
|
- `concurrent_requests`: 并发请求数
|
||||||
|
- `extra_data`: 额外数据
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
- `started_at`: 开始时间
|
||||||
|
- `finished_at`: 完成时间
|
||||||
|
"""
|
||||||
|
|
||||||
adapter = AdminGetRequestTraceAdapter(request_id=request_id)
|
adapter = AdminGetRequestTraceAdapter(request_id=request_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -85,9 +125,23 @@ async def get_provider_failure_rate(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取某个 Provider 的失败率统计
|
获取提供商的失败率统计
|
||||||
|
|
||||||
需要管理员权限
|
获取指定提供商最近的失败率统计信息。需要管理员权限。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: 提供商 ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `limit`: 统计最近的尝试数量,默认 100,最大 1000
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `provider_id`: 提供商 ID
|
||||||
|
- `total_attempts`: 总尝试次数
|
||||||
|
- `success_count`: 成功次数
|
||||||
|
- `failed_count`: 失败次数
|
||||||
|
- `failure_rate`: 失败率(百分比)
|
||||||
|
- `avg_latency_ms`: 平均延迟(毫秒)
|
||||||
"""
|
"""
|
||||||
adapter = AdminProviderFailureRateAdapter(provider_id=provider_id, limit=limit)
|
adapter = AdminProviderFailureRateAdapter(provider_id=provider_id, limit=limit)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ from sqlalchemy.orm import Session, joinedload
|
|||||||
|
|
||||||
from src.api.handlers.base.chat_adapter_base import get_adapter_class
|
from src.api.handlers.base.chat_adapter_base import get_adapter_class
|
||||||
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
|
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
|
||||||
|
from src.config.constants import TimeoutDefaults
|
||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.database.database import get_db
|
from src.database.database import get_db
|
||||||
from src.models.database import Provider, ProviderEndpoint, User
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User
|
||||||
from src.utils.auth_utils import get_current_user
|
from src.utils.auth_utils import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
|
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
|
||||||
@@ -81,10 +82,13 @@ async def query_available_models(
|
|||||||
Returns:
|
Returns:
|
||||||
所有端点的模型列表(合并)
|
所有端点的模型列表(合并)
|
||||||
"""
|
"""
|
||||||
# 获取提供商及其端点
|
# 获取提供商及其端点和 API Keys
|
||||||
provider = (
|
provider = (
|
||||||
db.query(Provider)
|
db.query(Provider)
|
||||||
.options(joinedload(Provider.endpoints).joinedload(ProviderEndpoint.api_keys))
|
.options(
|
||||||
|
joinedload(Provider.endpoints),
|
||||||
|
joinedload(Provider.api_keys),
|
||||||
|
)
|
||||||
.filter(Provider.id == request.provider_id)
|
.filter(Provider.id == request.provider_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -95,42 +99,63 @@ async def query_available_models(
|
|||||||
# 收集所有活跃端点的配置
|
# 收集所有活跃端点的配置
|
||||||
endpoint_configs: list[dict] = []
|
endpoint_configs: list[dict] = []
|
||||||
|
|
||||||
if request.api_key_id:
|
# 构建 api_format -> endpoint 映射
|
||||||
# 指定了特定的 API Key,只使用该 Key 对应的端点
|
format_to_endpoint: dict[str, ProviderEndpoint] = {}
|
||||||
for endpoint in provider.endpoints:
|
for endpoint in provider.endpoints:
|
||||||
for api_key in endpoint.api_keys:
|
if endpoint.is_active:
|
||||||
if api_key.id == request.api_key_id:
|
format_to_endpoint[endpoint.api_format] = endpoint
|
||||||
|
|
||||||
|
if request.api_key_id:
|
||||||
|
# 指定了特定的 API Key(从 provider.api_keys 查找)
|
||||||
|
api_key = next(
|
||||||
|
(key for key in provider.api_keys if key.id == request.api_key_id),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=404, detail="API Key not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
api_key_value = crypto_service.decrypt(api_key.api_key)
|
api_key_value = crypto_service.decrypt(api_key.api_key)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to decrypt API key: {e}")
|
logger.error(f"Failed to decrypt API key: {e}")
|
||||||
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
|
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
|
||||||
|
|
||||||
|
# 根据 Key 的 api_formats 找对应的 Endpoint
|
||||||
|
key_formats = api_key.api_formats or []
|
||||||
|
for fmt in key_formats:
|
||||||
|
endpoint = format_to_endpoint.get(fmt)
|
||||||
|
if endpoint:
|
||||||
endpoint_configs.append({
|
endpoint_configs.append({
|
||||||
"api_key": api_key_value,
|
"api_key": api_key_value,
|
||||||
"base_url": endpoint.base_url,
|
"base_url": endpoint.base_url,
|
||||||
"api_format": endpoint.api_format,
|
"api_format": fmt,
|
||||||
"extra_headers": endpoint.headers,
|
"extra_headers": endpoint.headers,
|
||||||
})
|
})
|
||||||
break
|
|
||||||
if endpoint_configs:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not endpoint_configs:
|
if not endpoint_configs:
|
||||||
raise HTTPException(status_code=404, detail="API Key not found")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No matching endpoint found for this API Key's formats"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# 遍历所有活跃端点,每个端点取第一个可用的 Key
|
# 遍历所有活跃端点,为每个端点找一个支持该格式的 Key
|
||||||
for endpoint in provider.endpoints:
|
for endpoint in provider.endpoints:
|
||||||
if not endpoint.is_active or not endpoint.api_keys:
|
if not endpoint.is_active:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 找第一个可用的 Key
|
# 找第一个支持该格式的可用 Key
|
||||||
for api_key in endpoint.api_keys:
|
for api_key in provider.api_keys:
|
||||||
if api_key.is_active:
|
if not api_key.is_active:
|
||||||
|
continue
|
||||||
|
key_formats = api_key.api_formats or []
|
||||||
|
if endpoint.api_format not in key_formats:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
api_key_value = crypto_service.decrypt(api_key.api_key)
|
api_key_value = crypto_service.decrypt(api_key.api_key)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to decrypt API key: {e}")
|
logger.error(f"Failed to decrypt API key: {e}")
|
||||||
continue # 尝试下一个 Key
|
continue
|
||||||
endpoint_configs.append({
|
endpoint_configs.append({
|
||||||
"api_key": api_key_value,
|
"api_key": api_key_value,
|
||||||
"base_url": endpoint.base_url,
|
"base_url": endpoint.base_url,
|
||||||
@@ -214,7 +239,6 @@ async def query_available_models(
|
|||||||
"provider": {
|
"provider": {
|
||||||
"id": provider.id,
|
"id": provider.id,
|
||||||
"name": provider.name,
|
"name": provider.name,
|
||||||
"display_name": provider.display_name,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,17 +253,14 @@ async def test_model(
|
|||||||
测试模型连接性
|
测试模型连接性
|
||||||
|
|
||||||
向指定提供商的指定模型发送测试请求,验证模型是否可用
|
向指定提供商的指定模型发送测试请求,验证模型是否可用
|
||||||
|
|
||||||
Args:
|
|
||||||
request: 测试请求
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
测试结果
|
|
||||||
"""
|
"""
|
||||||
# 获取提供商及其端点
|
# 获取提供商及其端点和 Keys
|
||||||
provider = (
|
provider = (
|
||||||
db.query(Provider)
|
db.query(Provider)
|
||||||
.options(joinedload(Provider.endpoints).joinedload(ProviderEndpoint.api_keys))
|
.options(
|
||||||
|
joinedload(Provider.endpoints),
|
||||||
|
joinedload(Provider.api_keys),
|
||||||
|
)
|
||||||
.filter(Provider.id == request.provider_id)
|
.filter(Provider.id == request.provider_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -247,28 +268,38 @@ async def test_model(
|
|||||||
if not provider:
|
if not provider:
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
# 找到合适的端点和API Key
|
# 构建 api_format -> endpoint 映射
|
||||||
endpoint_config = None
|
format_to_endpoint: dict[str, ProviderEndpoint] = {}
|
||||||
|
for ep in provider.endpoints:
|
||||||
|
if ep.is_active:
|
||||||
|
format_to_endpoint[ep.api_format] = ep
|
||||||
|
|
||||||
|
# 找到合适的端点和 API Key
|
||||||
endpoint = None
|
endpoint = None
|
||||||
api_key = None
|
api_key = None
|
||||||
|
|
||||||
if request.api_key_id:
|
if request.api_key_id:
|
||||||
# 使用指定的API Key
|
# 使用指定的 API Key
|
||||||
for ep in provider.endpoints:
|
api_key = next(
|
||||||
for key in ep.api_keys:
|
(key for key in provider.api_keys if key.id == request.api_key_id and key.is_active),
|
||||||
if key.id == request.api_key_id and key.is_active and ep.is_active:
|
None
|
||||||
endpoint = ep
|
)
|
||||||
api_key = key
|
if api_key:
|
||||||
break
|
# 找到该 Key 支持的第一个活跃 Endpoint
|
||||||
if endpoint:
|
for fmt in (api_key.api_formats or []):
|
||||||
|
if fmt in format_to_endpoint:
|
||||||
|
endpoint = format_to_endpoint[fmt]
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# 使用第一个可用的端点和密钥
|
# 使用第一个可用的端点和密钥
|
||||||
for ep in provider.endpoints:
|
for ep in provider.endpoints:
|
||||||
if not ep.is_active or not ep.api_keys:
|
if not ep.is_active:
|
||||||
continue
|
continue
|
||||||
for key in ep.api_keys:
|
# 找支持该格式的第一个可用 Key
|
||||||
if key.is_active:
|
for key in provider.api_keys:
|
||||||
|
if not key.is_active:
|
||||||
|
continue
|
||||||
|
if ep.api_format in (key.api_formats or []):
|
||||||
endpoint = ep
|
endpoint = ep
|
||||||
api_key = key
|
api_key = key
|
||||||
break
|
break
|
||||||
@@ -284,14 +315,14 @@ async def test_model(
|
|||||||
logger.error(f"[test-model] Failed to decrypt API key: {e}")
|
logger.error(f"[test-model] Failed to decrypt API key: {e}")
|
||||||
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
|
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
|
||||||
|
|
||||||
# 构建请求配置
|
# 构建请求配置(timeout 从 Provider 读取)
|
||||||
endpoint_config = {
|
endpoint_config = {
|
||||||
"api_key": api_key_value,
|
"api_key": api_key_value,
|
||||||
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
|
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
|
||||||
"base_url": endpoint.base_url,
|
"base_url": endpoint.base_url,
|
||||||
"api_format": endpoint.api_format,
|
"api_format": endpoint.api_format,
|
||||||
"extra_headers": endpoint.headers,
|
"extra_headers": endpoint.headers,
|
||||||
"timeout": endpoint.timeout or 30.0,
|
"timeout": provider.timeout or TimeoutDefaults.HTTP_REQUEST,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -304,7 +335,6 @@ async def test_model(
|
|||||||
"provider": {
|
"provider": {
|
||||||
"id": provider.id,
|
"id": provider.id,
|
||||||
"name": provider.name,
|
"name": provider.name,
|
||||||
"display_name": provider.display_name,
|
|
||||||
},
|
},
|
||||||
"model": request.model_name,
|
"model": request.model_name,
|
||||||
}
|
}
|
||||||
@@ -325,7 +355,6 @@ async def test_model(
|
|||||||
"provider": {
|
"provider": {
|
||||||
"id": provider.id,
|
"id": provider.id,
|
||||||
"name": provider.name,
|
"name": provider.name,
|
||||||
"display_name": provider.display_name,
|
|
||||||
},
|
},
|
||||||
"model": request.model_name,
|
"model": request.model_name,
|
||||||
}
|
}
|
||||||
@@ -415,7 +444,6 @@ async def test_model(
|
|||||||
"provider": {
|
"provider": {
|
||||||
"id": provider.id,
|
"id": provider.id,
|
||||||
"name": provider.name,
|
"name": provider.name,
|
||||||
"display_name": provider.display_name,
|
|
||||||
},
|
},
|
||||||
"model": request.model_name,
|
"model": request.model_name,
|
||||||
"endpoint": {
|
"endpoint": {
|
||||||
@@ -433,7 +461,6 @@ async def test_model(
|
|||||||
"provider": {
|
"provider": {
|
||||||
"id": provider.id,
|
"id": provider.id,
|
||||||
"name": provider.name,
|
"name": provider.name,
|
||||||
"display_name": provider.display_name,
|
|
||||||
},
|
},
|
||||||
"model": request.model_name,
|
"model": request.model_name,
|
||||||
"endpoint": {
|
"endpoint": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
提供商策略管理 API 端点
|
提供商策略管理 API 端点
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -39,6 +39,31 @@ async def update_provider_billing(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
更新提供商计费配置
|
||||||
|
|
||||||
|
更新指定提供商的计费策略、配额设置和优先级配置。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: 提供商 ID
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `billing_type`: 计费类型(pay_as_you_go、subscription、prepaid、monthly_quota)
|
||||||
|
- `monthly_quota_usd`: 月度配额(美元),可选
|
||||||
|
- `quota_reset_day`: 配额重置周期(天数,1-365),默认 30
|
||||||
|
- `quota_last_reset_at`: 当前周期开始时间,可选(设置后会自动同步该周期内的历史使用量)
|
||||||
|
- `quota_expires_at`: 配额过期时间,可选
|
||||||
|
- `rpm_limit`: 每分钟请求数限制,可选
|
||||||
|
- `provider_priority`: 提供商优先级(0-200),默认 100
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果信息
|
||||||
|
- `provider`: 更新后的提供商信息
|
||||||
|
- `id`: 提供商 ID
|
||||||
|
- `name`: 提供商名称
|
||||||
|
- `billing_type`: 计费类型
|
||||||
|
- `provider_priority`: 提供商优先级
|
||||||
|
"""
|
||||||
adapter = AdminProviderBillingAdapter(provider_id=provider_id)
|
adapter = AdminProviderBillingAdapter(provider_id=provider_id)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -50,6 +75,35 @@ async def get_provider_stats(
|
|||||||
hours: int = 24,
|
hours: int = 24,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
获取提供商统计数据
|
||||||
|
|
||||||
|
获取指定提供商的计费信息和使用统计数据。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: 提供商 ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `hours`: 统计时间范围(小时),默认 24
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `provider_id`: 提供商 ID
|
||||||
|
- `provider_name`: 提供商名称
|
||||||
|
- `period_hours`: 统计时间范围
|
||||||
|
- `billing_info`: 计费信息
|
||||||
|
- `billing_type`: 计费类型
|
||||||
|
- `monthly_quota_usd`: 月度配额
|
||||||
|
- `monthly_used_usd`: 月度已使用
|
||||||
|
- `quota_remaining_usd`: 剩余配额
|
||||||
|
- `quota_expires_at`: 配额过期时间
|
||||||
|
- `usage_stats`: 使用统计
|
||||||
|
- `total_requests`: 总请求数
|
||||||
|
- `successful_requests`: 成功请求数
|
||||||
|
- `failed_requests`: 失败请求数
|
||||||
|
- `success_rate`: 成功率
|
||||||
|
- `avg_response_time_ms`: 平均响应时间(毫秒)
|
||||||
|
- `total_cost_usd`: 总成本(美元)
|
||||||
|
"""
|
||||||
adapter = AdminProviderStatsAdapter(provider_id=provider_id, hours=hours)
|
adapter = AdminProviderStatsAdapter(provider_id=provider_id, hours=hours)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -67,6 +121,20 @@ async def reset_provider_quota(
|
|||||||
|
|
||||||
@router.get("/strategies")
|
@router.get("/strategies")
|
||||||
async def list_available_strategies(request: Request, db: Session = Depends(get_db)):
|
async def list_available_strategies(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
获取可用负载均衡策略列表
|
||||||
|
|
||||||
|
列出系统中所有已注册的负载均衡策略插件。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `strategies`: 策略列表
|
||||||
|
- `name`: 策略名称
|
||||||
|
- `priority`: 策略优先级
|
||||||
|
- `version`: 策略版本
|
||||||
|
- `description`: 策略描述
|
||||||
|
- `author`: 策略作者
|
||||||
|
- `total`: 策略总数
|
||||||
|
"""
|
||||||
adapter = AdminListStrategiesAdapter()
|
adapter = AdminListStrategiesAdapter()
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
@@ -93,7 +161,6 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
|
|||||||
provider.billing_type = config.billing_type
|
provider.billing_type = config.billing_type
|
||||||
provider.monthly_quota_usd = config.monthly_quota_usd
|
provider.monthly_quota_usd = config.monthly_quota_usd
|
||||||
provider.quota_reset_day = config.quota_reset_day
|
provider.quota_reset_day = config.quota_reset_day
|
||||||
provider.rpm_limit = config.rpm_limit
|
|
||||||
provider.provider_priority = config.provider_priority
|
provider.provider_priority = config.provider_priority
|
||||||
|
|
||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
@@ -103,6 +170,9 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
if config.quota_last_reset_at:
|
if config.quota_last_reset_at:
|
||||||
new_reset_at = parser.parse(config.quota_last_reset_at)
|
new_reset_at = parser.parse(config.quota_last_reset_at)
|
||||||
|
# 确保有时区信息,如果没有则假设为 UTC
|
||||||
|
if new_reset_at.tzinfo is None:
|
||||||
|
new_reset_at = new_reset_at.replace(tzinfo=timezone.utc)
|
||||||
provider.quota_last_reset_at = new_reset_at
|
provider.quota_last_reset_at = new_reset_at
|
||||||
|
|
||||||
# 自动同步该周期内的历史使用量
|
# 自动同步该周期内的历史使用量
|
||||||
@@ -118,7 +188,11 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
|
|||||||
logger.info(f"Synced usage for provider {provider.name}: ${period_usage:.4f} since {new_reset_at}")
|
logger.info(f"Synced usage for provider {provider.name}: ${period_usage:.4f} since {new_reset_at}")
|
||||||
|
|
||||||
if config.quota_expires_at:
|
if config.quota_expires_at:
|
||||||
provider.quota_expires_at = parser.parse(config.quota_expires_at)
|
expires_at = parser.parse(config.quota_expires_at)
|
||||||
|
# 确保有时区信息,如果没有则假设为 UTC
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
provider.quota_expires_at = expires_at
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(provider)
|
db.refresh(provider)
|
||||||
@@ -149,7 +223,7 @@ class AdminProviderStatsAdapter(AdminApiAdapter):
|
|||||||
if not provider:
|
if not provider:
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
since = datetime.now() - timedelta(hours=self.hours)
|
since = datetime.now(timezone.utc) - timedelta(hours=self.hours)
|
||||||
stats = (
|
stats = (
|
||||||
db.query(ProviderUsageTracking)
|
db.query(ProviderUsageTracking)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -183,13 +257,6 @@ class AdminProviderStatsAdapter(AdminApiAdapter):
|
|||||||
provider.quota_expires_at.isoformat() if provider.quota_expires_at else None
|
provider.quota_expires_at.isoformat() if provider.quota_expires_at else None
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"rpm_info": {
|
|
||||||
"rpm_limit": provider.rpm_limit,
|
|
||||||
"rpm_used": provider.rpm_used,
|
|
||||||
"rpm_reset_at": (
|
|
||||||
provider.rpm_reset_at.isoformat() if provider.rpm_reset_at else None
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"usage_stats": {
|
"usage_stats": {
|
||||||
"total_requests": total_requests,
|
"total_requests": total_requests,
|
||||||
"successful_requests": total_success,
|
"successful_requests": total_success,
|
||||||
@@ -217,8 +284,6 @@ class AdminProviderResetQuotaAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
old_used = provider.monthly_used_usd
|
old_used = provider.monthly_used_usd
|
||||||
provider.monthly_used_usd = 0.0
|
provider.monthly_used_usd = 0.0
|
||||||
provider.rpm_used = 0
|
|
||||||
provider.rpm_reset_at = None
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
logger.info(f"Manually reset quota for provider {provider.name}")
|
logger.info(f"Manually reset quota for provider {provider.name}")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user