mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-13 04:58:28 +08:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465da6f818 | ||
|
|
e5f12fddd9 | ||
|
|
4fa9a1303a | ||
|
|
43f349d415 | ||
|
|
02069954de | ||
|
|
2e15875fed | ||
|
|
b34cfb676d | ||
|
|
3064497636 | ||
|
|
dec681fea0 | ||
|
|
523e27ba9a | ||
|
|
e7db76e581 | ||
|
|
689339117a | ||
|
|
b202765be4 | ||
|
|
3bbf3073df | ||
|
|
f46aaa2182 | ||
|
|
a2f33a6c35 | ||
|
|
b6bd6357ed | ||
|
|
c3a5878b1b | ||
|
|
c02ac56da8 | ||
|
|
cddc22d2b3 | ||
|
|
11ded575d5 | ||
|
|
394cc536a9 | ||
|
|
6bd8cdb9cf | ||
|
|
e20a09f15a | ||
|
|
b89a4af0cf | ||
|
|
a56854af43 | ||
|
|
4a35d78c8d | ||
|
|
26b281271e | ||
|
|
96094cfde2 | ||
|
|
7e26af5476 | ||
|
|
c8dfb784bc | ||
|
|
fd3a5a5afe | ||
|
|
599b3d4c95 | ||
|
|
41719a00e7 | ||
|
|
b5c0f85dca | ||
|
|
7d6d262ed3 | ||
|
|
e21acd73eb | ||
|
|
702f9bc5f1 | ||
|
|
d0ce798881 | ||
|
|
2b1d197047 | ||
|
|
71bc2e6aab | ||
|
|
afb329934a | ||
|
|
1313af45a3 | ||
|
|
dddb327885 | ||
|
|
26b4a37323 | ||
|
|
9dad194130 | ||
|
|
03ad16ea8a | ||
|
|
2fa64b98e3 | ||
|
|
75d7e89cbb | ||
|
|
d73a443484 | ||
|
|
15a9b88fc8 | ||
|
|
03eb7203ec | ||
|
|
e38cd6819b | ||
|
|
d44cfaddf6 | ||
|
|
65225710a8 | ||
|
|
d7f5b16359 | ||
|
|
7185818724 | ||
|
|
868f3349e5 | ||
|
|
d7384e69d9 | ||
|
|
1d5c378343 | ||
|
|
4e1aed9976 | ||
|
|
e2e7996a54 | ||
|
|
df9f9a9f4f | ||
|
|
7553b0da80 | ||
|
|
8f30bf0bef | ||
|
|
8c12174521 | ||
|
|
6aa1876955 | ||
|
|
7f07122aea | ||
|
|
c2ddc6bd3c |
10
.env.example
10
.env.example
@@ -1,8 +1,16 @@
|
|||||||
# ==================== 必须配置(启动前) ====================
|
# ==================== 必须配置(启动前) ====================
|
||||||
# 以下配置项必须在项目启动前设置
|
# 以下配置项必须在项目启动前设置
|
||||||
|
|
||||||
# 数据库密码
|
# 数据库配置
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_NAME=aether
|
||||||
DB_PASSWORD=your_secure_password_here
|
DB_PASSWORD=your_secure_password_here
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=your_redis_password_here
|
REDIS_PASSWORD=your_redis_password_here
|
||||||
|
|
||||||
# JWT密钥(使用 python generate_keys.py 生成)
|
# JWT密钥(使用 python generate_keys.py 生成)
|
||||||
|
|||||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -148,7 +148,7 @@ jobs:
|
|||||||
|
|
||||||
- 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|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest|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: Build and push app image
|
- name: Build and push app image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
|
|||||||
132
Dockerfile.app
132
Dockerfile.app
@@ -1,16 +1,134 @@
|
|||||||
# 应用镜像:基于基础镜像,只复制代码(秒级构建)
|
# 运行镜像:从 base 提取产物到精简运行时
|
||||||
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
|
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
|
||||||
FROM aether-base:latest
|
# 用于 GitHub Actions CI(官方源)
|
||||||
|
FROM aether-base:latest AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制前端源码并构建
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
RUN cd frontend && npm run build
|
||||||
|
|
||||||
|
# ==================== 运行时镜像 ====================
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 运行时依赖(无 gcc/nodejs/npm)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
libpq5 \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 从 base 镜像复制 Python 包
|
||||||
|
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
|
|
||||||
|
# 只复制需要的 Python 可执行文件
|
||||||
|
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||||
|
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||||
|
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||||
|
|
||||||
|
# 从 builder 阶段复制前端构建产物
|
||||||
|
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# 复制后端代码
|
# 复制后端代码
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
|
|
||||||
# 构建前端(使用基础镜像中已安装的 node_modules)
|
# Nginx 配置模板
|
||||||
COPY frontend/ /tmp/frontend/
|
RUN printf '%s\n' \
|
||||||
RUN cd /tmp/frontend && npm run build && \
|
'server {' \
|
||||||
cp -r dist/* /usr/share/nginx/html/ && \
|
' listen 80;' \
|
||||||
rm -rf /tmp/frontend
|
' server_name _;' \
|
||||||
|
' root /usr/share/nginx/html;' \
|
||||||
|
' index index.html;' \
|
||||||
|
' client_max_body_size 100M;' \
|
||||||
|
'' \
|
||||||
|
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||||
|
' expires 1y;' \
|
||||||
|
' add_header Cache-Control "public, no-transform";' \
|
||||||
|
' try_files $uri =404;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location ~ ^/(src|node_modules)/ {' \
|
||||||
|
' deny all;' \
|
||||||
|
' return 404;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
||||||
|
' try_files $uri $uri/ /index.html;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location / {' \
|
||||||
|
' try_files $uri $uri/ @backend;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location @backend {' \
|
||||||
|
' 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 $remote_addr;' \
|
||||||
|
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
||||||
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
|
' proxy_set_header Connection "";' \
|
||||||
|
' proxy_set_header Accept $http_accept;' \
|
||||||
|
' proxy_set_header Content-Type $content_type;' \
|
||||||
|
' proxy_set_header Authorization $http_authorization;' \
|
||||||
|
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||||
|
' proxy_buffering off;' \
|
||||||
|
' proxy_cache off;' \
|
||||||
|
' proxy_request_buffering off;' \
|
||||||
|
' chunked_transfer_encoding on;' \
|
||||||
|
' gzip off;' \
|
||||||
|
' add_header X-Accel-Buffering no;' \
|
||||||
|
' proxy_connect_timeout 600s;' \
|
||||||
|
' proxy_send_timeout 600s;' \
|
||||||
|
' proxy_read_timeout 600s;' \
|
||||||
|
' }' \
|
||||||
|
'}' > /etc/nginx/sites-available/default.template
|
||||||
|
|
||||||
|
# Supervisor 配置
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'[supervisord]' \
|
||||||
|
'nodaemon=true' \
|
||||||
|
'logfile=/var/log/supervisor/supervisord.log' \
|
||||||
|
'pidfile=/var/run/supervisord.pid' \
|
||||||
|
'' \
|
||||||
|
'[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;\""' \
|
||||||
|
'autostart=true' \
|
||||||
|
'autorestart=true' \
|
||||||
|
'stdout_logfile=/var/log/nginx/access.log' \
|
||||||
|
'stderr_logfile=/var/log/nginx/error.log' \
|
||||||
|
'' \
|
||||||
|
'[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' \
|
||||||
|
'directory=/app' \
|
||||||
|
'autostart=true' \
|
||||||
|
'autorestart=true' \
|
||||||
|
'stdout_logfile=/dev/stdout' \
|
||||||
|
'stdout_logfile_maxbytes=0' \
|
||||||
|
'stderr_logfile=/dev/stderr' \
|
||||||
|
'stderr_logfile_maxbytes=0' \
|
||||||
|
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# 创建目录
|
||||||
|
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONIOENCODING=utf-8 \
|
||||||
|
LANG=C.UTF-8 \
|
||||||
|
LC_ALL=C.UTF-8 \
|
||||||
|
PORT=8084
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/health || exit 1
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
135
Dockerfile.app.local
Normal file
135
Dockerfile.app.local
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
|
||||||
|
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
|
||||||
|
# 用于本地/国内服务器部署
|
||||||
|
FROM aether-base:latest AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制前端源码并构建
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
RUN cd frontend && npm run build
|
||||||
|
|
||||||
|
# ==================== 运行时镜像 ====================
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 运行时依赖(使用清华镜像源)
|
||||||
|
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
|
apt-get update && apt-get install -y \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
libpq5 \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 从 base 镜像复制 Python 包
|
||||||
|
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
|
|
||||||
|
# 只复制需要的 Python 可执行文件
|
||||||
|
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||||
|
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||||
|
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||||
|
|
||||||
|
# 从 builder 阶段复制前端构建产物
|
||||||
|
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制后端代码
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY alembic.ini ./
|
||||||
|
COPY alembic/ ./alembic/
|
||||||
|
|
||||||
|
# Nginx 配置模板
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'server {' \
|
||||||
|
' listen 80;' \
|
||||||
|
' server_name _;' \
|
||||||
|
' root /usr/share/nginx/html;' \
|
||||||
|
' index index.html;' \
|
||||||
|
' client_max_body_size 100M;' \
|
||||||
|
'' \
|
||||||
|
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||||
|
' expires 1y;' \
|
||||||
|
' add_header Cache-Control "public, no-transform";' \
|
||||||
|
' try_files $uri =404;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location ~ ^/(src|node_modules)/ {' \
|
||||||
|
' deny all;' \
|
||||||
|
' return 404;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
||||||
|
' try_files $uri $uri/ /index.html;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location / {' \
|
||||||
|
' try_files $uri $uri/ @backend;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location @backend {' \
|
||||||
|
' 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 $remote_addr;' \
|
||||||
|
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
||||||
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
|
' proxy_set_header Connection "";' \
|
||||||
|
' proxy_set_header Accept $http_accept;' \
|
||||||
|
' proxy_set_header Content-Type $content_type;' \
|
||||||
|
' proxy_set_header Authorization $http_authorization;' \
|
||||||
|
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||||
|
' proxy_buffering off;' \
|
||||||
|
' proxy_cache off;' \
|
||||||
|
' proxy_request_buffering off;' \
|
||||||
|
' chunked_transfer_encoding on;' \
|
||||||
|
' gzip off;' \
|
||||||
|
' add_header X-Accel-Buffering no;' \
|
||||||
|
' proxy_connect_timeout 600s;' \
|
||||||
|
' proxy_send_timeout 600s;' \
|
||||||
|
' proxy_read_timeout 600s;' \
|
||||||
|
' }' \
|
||||||
|
'}' > /etc/nginx/sites-available/default.template
|
||||||
|
|
||||||
|
# Supervisor 配置
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'[supervisord]' \
|
||||||
|
'nodaemon=true' \
|
||||||
|
'logfile=/var/log/supervisor/supervisord.log' \
|
||||||
|
'pidfile=/var/run/supervisord.pid' \
|
||||||
|
'' \
|
||||||
|
'[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;\""' \
|
||||||
|
'autostart=true' \
|
||||||
|
'autorestart=true' \
|
||||||
|
'stdout_logfile=/var/log/nginx/access.log' \
|
||||||
|
'stderr_logfile=/var/log/nginx/error.log' \
|
||||||
|
'' \
|
||||||
|
'[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' \
|
||||||
|
'directory=/app' \
|
||||||
|
'autostart=true' \
|
||||||
|
'autorestart=true' \
|
||||||
|
'stdout_logfile=/dev/stdout' \
|
||||||
|
'stdout_logfile_maxbytes=0' \
|
||||||
|
'stderr_logfile=/dev/stderr' \
|
||||||
|
'stderr_logfile_maxbytes=0' \
|
||||||
|
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# 创建目录
|
||||||
|
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONIOENCODING=utf-8 \
|
||||||
|
LANG=C.UTF-8 \
|
||||||
|
LC_ALL=C.UTF-8 \
|
||||||
|
PORT=8084
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/health || exit 1
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
119
Dockerfile.base
119
Dockerfile.base
@@ -1,124 +1,25 @@
|
|||||||
# 基础镜像:包含所有依赖,只在依赖变化时需要重建
|
# 构建镜像:编译环境 + 预编译的依赖
|
||||||
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
|
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
|
||||||
|
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
||||||
|
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 系统依赖
|
# 构建工具
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
nginx \
|
|
||||||
supervisor \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
gcc \
|
||||||
curl \
|
|
||||||
gettext-base \
|
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Python 依赖(安装到系统,不用 -e 模式)
|
# Python 依赖
|
||||||
COPY pyproject.toml README.md ./
|
COPY pyproject.toml README.md ./
|
||||||
RUN mkdir -p src && touch src/__init__.py && \
|
RUN mkdir -p src && touch src/__init__.py && \
|
||||||
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir .
|
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
|
||||||
|
pip cache purge
|
||||||
|
|
||||||
# 前端依赖
|
# 前端依赖(只安装,不构建)
|
||||||
COPY frontend/package*.json /tmp/frontend/
|
COPY frontend/package*.json ./frontend/
|
||||||
WORKDIR /tmp/frontend
|
RUN cd frontend && npm ci
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Nginx 配置模板
|
|
||||||
RUN printf '%s\n' \
|
|
||||||
'server {' \
|
|
||||||
' listen 80;' \
|
|
||||||
' server_name _;' \
|
|
||||||
' root /usr/share/nginx/html;' \
|
|
||||||
' index index.html;' \
|
|
||||||
' client_max_body_size 100M;' \
|
|
||||||
'' \
|
|
||||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
|
||||||
' expires 1y;' \
|
|
||||||
' add_header Cache-Control "public, no-transform";' \
|
|
||||||
' try_files $uri =404;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location ~ ^/(src|node_modules)/ {' \
|
|
||||||
' deny all;' \
|
|
||||||
' return 404;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
|
||||||
' try_files $uri $uri/ /index.html;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location / {' \
|
|
||||||
' try_files $uri $uri/ @backend;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location @backend {' \
|
|
||||||
' 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 $remote_addr;' \
|
|
||||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
|
||||||
' proxy_set_header Connection "";' \
|
|
||||||
' proxy_set_header Accept $http_accept;' \
|
|
||||||
' proxy_set_header Content-Type $content_type;' \
|
|
||||||
' proxy_set_header Authorization $http_authorization;' \
|
|
||||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
|
||||||
' proxy_buffering off;' \
|
|
||||||
' proxy_cache off;' \
|
|
||||||
' proxy_request_buffering off;' \
|
|
||||||
' chunked_transfer_encoding on;' \
|
|
||||||
' gzip off;' \
|
|
||||||
' add_header X-Accel-Buffering no;' \
|
|
||||||
' proxy_connect_timeout 600s;' \
|
|
||||||
' proxy_send_timeout 600s;' \
|
|
||||||
' proxy_read_timeout 600s;' \
|
|
||||||
' }' \
|
|
||||||
'}' > /etc/nginx/sites-available/default.template
|
|
||||||
|
|
||||||
# Supervisor 配置
|
|
||||||
RUN printf '%s\n' \
|
|
||||||
'[supervisord]' \
|
|
||||||
'nodaemon=true' \
|
|
||||||
'logfile=/var/log/supervisor/supervisord.log' \
|
|
||||||
'pidfile=/var/run/supervisord.pid' \
|
|
||||||
'' \
|
|
||||||
'[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;\""' \
|
|
||||||
'autostart=true' \
|
|
||||||
'autorestart=true' \
|
|
||||||
'stdout_logfile=/var/log/nginx/access.log' \
|
|
||||||
'stderr_logfile=/var/log/nginx/error.log' \
|
|
||||||
'' \
|
|
||||||
'[program:app]' \
|
|
||||||
'command=gunicorn src.main:app -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' \
|
|
||||||
'directory=/app' \
|
|
||||||
'autostart=true' \
|
|
||||||
'autorestart=true' \
|
|
||||||
'stdout_logfile=/dev/stdout' \
|
|
||||||
'stdout_logfile_maxbytes=0' \
|
|
||||||
'stderr_logfile=/dev/stderr' \
|
|
||||||
'stderr_logfile_maxbytes=0' \
|
|
||||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
|
|
||||||
# 创建目录
|
|
||||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data /usr/share/nginx/html
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 环境变量
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONIOENCODING=utf-8 \
|
|
||||||
LANG=C.UTF-8 \
|
|
||||||
LC_ALL=C.UTF-8 \
|
|
||||||
PORT=8084
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost/health || exit 1
|
|
||||||
|
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
# 基础镜像:包含所有依赖,只在依赖变化时需要重建
|
# 构建镜像:编译环境 + 预编译的依赖(国内镜像源版本)
|
||||||
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
# 构建命令: docker build -f Dockerfile.base.local -t aether-base:latest .
|
||||||
|
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 系统依赖
|
# 构建工具(使用清华镜像源)
|
||||||
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
apt-get update && apt-get install -y \
|
apt-get update && apt-get install -y \
|
||||||
nginx \
|
|
||||||
supervisor \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
gcc \
|
||||||
curl \
|
|
||||||
gettext-base \
|
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -20,109 +17,12 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.li
|
|||||||
# pip 镜像源
|
# pip 镜像源
|
||||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|
||||||
# Python 依赖(安装到系统,不用 -e 模式)
|
# Python 依赖
|
||||||
COPY pyproject.toml README.md ./
|
COPY pyproject.toml README.md ./
|
||||||
RUN mkdir -p src && touch src/__init__.py && \
|
RUN mkdir -p src && touch src/__init__.py && \
|
||||||
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir .
|
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
|
||||||
|
pip cache purge
|
||||||
|
|
||||||
# 前端依赖
|
# 前端依赖(只安装,不构建,使用淘宝镜像源)
|
||||||
COPY frontend/package*.json /tmp/frontend/
|
COPY frontend/package*.json ./frontend/
|
||||||
WORKDIR /tmp/frontend
|
RUN cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
|
||||||
RUN npm config set registry https://registry.npmmirror.com && npm ci
|
|
||||||
|
|
||||||
# Nginx 配置模板
|
|
||||||
RUN printf '%s\n' \
|
|
||||||
'server {' \
|
|
||||||
' listen 80;' \
|
|
||||||
' server_name _;' \
|
|
||||||
' root /usr/share/nginx/html;' \
|
|
||||||
' index index.html;' \
|
|
||||||
' client_max_body_size 100M;' \
|
|
||||||
'' \
|
|
||||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
|
||||||
' expires 1y;' \
|
|
||||||
' add_header Cache-Control "public, no-transform";' \
|
|
||||||
' try_files $uri =404;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location ~ ^/(src|node_modules)/ {' \
|
|
||||||
' deny all;' \
|
|
||||||
' return 404;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
|
||||||
' try_files $uri $uri/ /index.html;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location / {' \
|
|
||||||
' try_files $uri $uri/ @backend;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location @backend {' \
|
|
||||||
' 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 $remote_addr;' \
|
|
||||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
|
||||||
' proxy_set_header Connection "";' \
|
|
||||||
' proxy_set_header Accept $http_accept;' \
|
|
||||||
' proxy_set_header Content-Type $content_type;' \
|
|
||||||
' proxy_set_header Authorization $http_authorization;' \
|
|
||||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
|
||||||
' proxy_buffering off;' \
|
|
||||||
' proxy_cache off;' \
|
|
||||||
' proxy_request_buffering off;' \
|
|
||||||
' chunked_transfer_encoding on;' \
|
|
||||||
' gzip off;' \
|
|
||||||
' add_header X-Accel-Buffering no;' \
|
|
||||||
' proxy_connect_timeout 600s;' \
|
|
||||||
' proxy_send_timeout 600s;' \
|
|
||||||
' proxy_read_timeout 600s;' \
|
|
||||||
' }' \
|
|
||||||
'}' > /etc/nginx/sites-available/default.template
|
|
||||||
|
|
||||||
# Supervisor 配置
|
|
||||||
RUN printf '%s\n' \
|
|
||||||
'[supervisord]' \
|
|
||||||
'nodaemon=true' \
|
|
||||||
'logfile=/var/log/supervisor/supervisord.log' \
|
|
||||||
'pidfile=/var/run/supervisord.pid' \
|
|
||||||
'' \
|
|
||||||
'[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;\""' \
|
|
||||||
'autostart=true' \
|
|
||||||
'autorestart=true' \
|
|
||||||
'stdout_logfile=/var/log/nginx/access.log' \
|
|
||||||
'stderr_logfile=/var/log/nginx/error.log' \
|
|
||||||
'' \
|
|
||||||
'[program:app]' \
|
|
||||||
'command=gunicorn src.main:app -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' \
|
|
||||||
'directory=/app' \
|
|
||||||
'autostart=true' \
|
|
||||||
'autorestart=true' \
|
|
||||||
'stdout_logfile=/dev/stdout' \
|
|
||||||
'stdout_logfile_maxbytes=0' \
|
|
||||||
'stderr_logfile=/dev/stderr' \
|
|
||||||
'stderr_logfile_maxbytes=0' \
|
|
||||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
|
|
||||||
# 创建目录
|
|
||||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data /usr/share/nginx/html
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 环境变量
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONIOENCODING=utf-8 \
|
|
||||||
LANG=C.UTF-8 \
|
|
||||||
LC_ALL=C.UTF-8 \
|
|
||||||
PORT=8084
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost/health || exit 1
|
|
||||||
|
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
|
||||||
|
|||||||
15
LICENSE
15
LICENSE
@@ -5,12 +5,17 @@ Aether 非商业开源许可证
|
|||||||
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
||||||
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
||||||
|
|
||||||
1. 仅限非商业用途
|
1. 仅限非盈利用途
|
||||||
本软件不得用于商业目的。商业目的包括但不限于:
|
本软件不得用于盈利目的。盈利目的包括但不限于:
|
||||||
- 出售本软件或任何衍生作品
|
- 出售本软件或任何衍生作品
|
||||||
- 使用本软件提供付费服务
|
- 使用本软件提供付费服务
|
||||||
- 将本软件用于商业产品或服务
|
- 将本软件用于以盈利为目的的商业产品或服务
|
||||||
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
|
|
||||||
|
以下用途被明确允许:
|
||||||
|
- 个人学习和研究
|
||||||
|
- 教育机构的教学和研究
|
||||||
|
- 非盈利组织的内部使用
|
||||||
|
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
|
||||||
|
|
||||||
2. 署名要求
|
2. 署名要求
|
||||||
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
||||||
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
|
|||||||
您不得以不同的条款将本软件再许可给他人。
|
您不得以不同的条款将本软件再许可给他人。
|
||||||
|
|
||||||
5. 商业许可
|
5. 商业许可
|
||||||
如需商业使用,请联系版权持有人以获取单独的商业许可。
|
如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
|
||||||
|
|
||||||
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
||||||
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -58,13 +58,13 @@ cp .env.example .env
|
|||||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||||
|
|
||||||
# 3. 部署
|
# 3. 部署
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 4. 首次部署时, 初始化数据库
|
# 4. 首次部署时, 初始化数据库
|
||||||
./migrate.sh
|
./migrate.sh
|
||||||
|
|
||||||
# 5. 更新
|
# 5. 更新
|
||||||
docker-compose pull && docker-compose up -d && ./migrate.sh
|
docker compose pull && docker compose up -d && ./migrate.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Compose(本地构建镜像)
|
### Docker Compose(本地构建镜像)
|
||||||
@@ -86,7 +86,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
|
||||||
@@ -143,7 +143,7 @@ cd frontend && npm install && npm run dev
|
|||||||
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
|
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
|
||||||
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
|
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
|
||||||
|
|
||||||
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能调用支持 1H缓存的模型
|
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
|
||||||
|
|
||||||
### Q: 如何配置负载均衡?
|
### Q: 如何配置负载均衡?
|
||||||
|
|
||||||
@@ -162,4 +162,16 @@ cd frontend && npm install && npm run dev
|
|||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目采用 [Aether 非商业开源许可证](LICENSE)。
|
本项目采用 [Aether 非商业开源许可证](LICENSE)。允许个人学习、教育研究、非盈利组织及企业内部非盈利性质的使用;禁止用于盈利目的。商业使用请联系获取商业许可。
|
||||||
|
|
||||||
|
## 联系作者
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#fawney19/Aether&Date)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -394,6 +394,10 @@ def upgrade() -> None:
|
|||||||
index=True,
|
index=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# usage 表复合索引(优化常见查询)
|
||||||
|
op.create_index("idx_usage_user_created", "usage", ["user_id", "created_at"])
|
||||||
|
op.create_index("idx_usage_apikey_created", "usage", ["api_key_id", "created_at"])
|
||||||
|
op.create_index("idx_usage_provider_model_created", "usage", ["provider", "model", "created_at"])
|
||||||
|
|
||||||
# ==================== user_quotas ====================
|
# ==================== user_quotas ====================
|
||||||
op.create_table(
|
op.create_table(
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""add stats_daily_model table and rename provider_model_aliases
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: f30f9936f6a2
|
||||||
|
Create Date: 2025-12-20 12:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a1b2c3d4e5f6'
|
||||||
|
down_revision = 'f30f9936f6a2'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
"""检查表是否存在"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""创建 stats_daily_model 表,重命名 provider_model_aliases 为 provider_model_mappings"""
|
||||||
|
# 1. 创建 stats_daily_model 表
|
||||||
|
if not table_exists('stats_daily_model'):
|
||||||
|
op.create_table(
|
||||||
|
'stats_daily_model',
|
||||||
|
sa.Column('id', sa.String(36), primary_key=True),
|
||||||
|
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('model', sa.String(100), nullable=False),
|
||||||
|
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
|
||||||
|
sa.Column('input_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||||
|
sa.Column('output_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||||
|
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||||
|
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||||
|
sa.Column('total_cost', sa.Float(), nullable=False, default=0.0),
|
||||||
|
sa.Column('avg_response_time_ms', sa.Float(), nullable=False, default=0.0),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||||
|
sa.UniqueConstraint('date', 'model', name='uq_stats_daily_model'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建索引
|
||||||
|
op.create_index('idx_stats_daily_model_date', 'stats_daily_model', ['date'])
|
||||||
|
op.create_index('idx_stats_daily_model_date_model', 'stats_daily_model', ['date', 'model'])
|
||||||
|
|
||||||
|
# 2. 重命名 models 表的 provider_model_aliases 为 provider_model_mappings
|
||||||
|
if column_exists('models', 'provider_model_aliases') and not column_exists('models', 'provider_model_mappings'):
|
||||||
|
op.alter_column('models', 'provider_model_aliases', new_column_name='provider_model_mappings')
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
"""检查索引是否存在"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
|
||||||
|
return index_name in indexes
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除 stats_daily_model 表,恢复 provider_model_aliases 列名"""
|
||||||
|
# 恢复列名
|
||||||
|
if column_exists('models', 'provider_model_mappings') and not column_exists('models', 'provider_model_aliases'):
|
||||||
|
op.alter_column('models', 'provider_model_mappings', new_column_name='provider_model_aliases')
|
||||||
|
|
||||||
|
# 删除表
|
||||||
|
if table_exists('stats_daily_model'):
|
||||||
|
if index_exists('stats_daily_model', 'idx_stats_daily_model_date_model'):
|
||||||
|
op.drop_index('idx_stats_daily_model_date_model', table_name='stats_daily_model')
|
||||||
|
if index_exists('stats_daily_model', 'idx_stats_daily_model_date'):
|
||||||
|
op.drop_index('idx_stats_daily_model_date', table_name='stats_daily_model')
|
||||||
|
op.drop_table('stats_daily_model')
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""add usage table composite indexes for query optimization
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6g7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2025-12-20 15:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b2c3d4e5f6g7'
|
||||||
|
down_revision = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""为 usage 表添加复合索引以优化常见查询
|
||||||
|
|
||||||
|
注意:这些索引已经在 baseline 迁移中创建。
|
||||||
|
此迁移仅用于从旧版本升级的场景,新安装会跳过。
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 检查 usage 表是否存在
|
||||||
|
result = conn.execute(text(
|
||||||
|
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usage')"
|
||||||
|
))
|
||||||
|
if not result.scalar():
|
||||||
|
# 表不存在,跳过
|
||||||
|
return
|
||||||
|
|
||||||
|
# 定义需要创建的索引
|
||||||
|
indexes = [
|
||||||
|
("idx_usage_user_created", "ON usage (user_id, created_at)"),
|
||||||
|
("idx_usage_apikey_created", "ON usage (api_key_id, created_at)"),
|
||||||
|
("idx_usage_provider_model_created", "ON usage (provider, model, created_at)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 分别检查并创建每个索引
|
||||||
|
for index_name, index_def in indexes:
|
||||||
|
result = conn.execute(text(
|
||||||
|
f"SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = '{index_name}')"
|
||||||
|
))
|
||||||
|
if result.scalar():
|
||||||
|
continue # 索引已存在,跳过
|
||||||
|
|
||||||
|
conn.execute(text(f"CREATE INDEX {index_name} {index_def}"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除复合索引"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 使用 IF EXISTS 避免索引不存在时报错
|
||||||
|
conn.execute(text(
|
||||||
|
"DROP INDEX IF EXISTS idx_usage_provider_model_created"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"DROP INDEX IF EXISTS idx_usage_apikey_created"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"DROP INDEX IF EXISTS idx_usage_user_created"
|
||||||
|
))
|
||||||
29
deploy.sh
29
deploy.sh
@@ -26,10 +26,13 @@ calc_deps_hash() {
|
|||||||
cat pyproject.toml frontend/package.json frontend/package-lock.json Dockerfile.base.local 2>/dev/null | md5sum | cut -d' ' -f1
|
cat pyproject.toml frontend/package.json frontend/package-lock.json Dockerfile.base.local 2>/dev/null | md5sum | cut -d' ' -f1
|
||||||
}
|
}
|
||||||
|
|
||||||
# 计算代码文件的哈希值
|
# 计算代码文件的哈希值(包含 Dockerfile.app.local)
|
||||||
calc_code_hash() {
|
calc_code_hash() {
|
||||||
find src -type f -name "*.py" 2>/dev/null | sort | xargs cat 2>/dev/null | md5sum | cut -d' ' -f1
|
{
|
||||||
find frontend/src -type f \( -name "*.vue" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" \) 2>/dev/null | sort | xargs cat 2>/dev/null | md5sum | cut -d' ' -f1
|
cat Dockerfile.app.local 2>/dev/null
|
||||||
|
find src -type f -name "*.py" 2>/dev/null | sort | xargs cat 2>/dev/null
|
||||||
|
find frontend/src -type f \( -name "*.vue" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" \) 2>/dev/null | sort | xargs cat 2>/dev/null
|
||||||
|
} | md5sum | cut -d' ' -f1
|
||||||
}
|
}
|
||||||
|
|
||||||
# 计算迁移文件的哈希值
|
# 计算迁移文件的哈希值
|
||||||
@@ -88,7 +91,7 @@ build_base() {
|
|||||||
# 构建应用镜像
|
# 构建应用镜像
|
||||||
build_app() {
|
build_app() {
|
||||||
echo ">>> Building app image (code only)..."
|
echo ">>> Building app image (code only)..."
|
||||||
docker build -f Dockerfile.app -t aether-app:latest .
|
docker build -f Dockerfile.app.local -t aether-app:latest .
|
||||||
save_code_hash
|
save_code_hash
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +182,13 @@ else
|
|||||||
echo ">>> Dependencies unchanged."
|
echo ">>> Dependencies unchanged."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查代码是否变化,或者 base 重建了(app 依赖 base)
|
# 检查代码或迁移是否变化,或者 base 重建了(app 依赖 base)
|
||||||
|
# 注意:迁移文件打包在镜像中,所以迁移变化也需要重建 app 镜像
|
||||||
|
MIGRATION_CHANGED=false
|
||||||
|
if check_migration_changed; then
|
||||||
|
MIGRATION_CHANGED=true
|
||||||
|
fi
|
||||||
|
|
||||||
if ! docker image inspect aether-app:latest >/dev/null 2>&1; then
|
if ! docker image inspect aether-app:latest >/dev/null 2>&1; then
|
||||||
echo ">>> App image not found, building..."
|
echo ">>> App image not found, building..."
|
||||||
build_app
|
build_app
|
||||||
@@ -192,6 +201,10 @@ elif check_code_changed; then
|
|||||||
echo ">>> Code changed, rebuilding app image..."
|
echo ">>> Code changed, rebuilding app image..."
|
||||||
build_app
|
build_app
|
||||||
NEED_RESTART=true
|
NEED_RESTART=true
|
||||||
|
elif [ "$MIGRATION_CHANGED" = true ]; then
|
||||||
|
echo ">>> Migration files changed, rebuilding app image..."
|
||||||
|
build_app
|
||||||
|
NEED_RESTART=true
|
||||||
else
|
else
|
||||||
echo ">>> Code unchanged."
|
echo ">>> Code unchanged."
|
||||||
fi
|
fi
|
||||||
@@ -204,9 +217,9 @@ else
|
|||||||
echo ">>> No changes detected, skipping restart."
|
echo ">>> No changes detected, skipping restart."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查迁移变化
|
# 检查迁移变化(如果前面已经检测到变化并重建了镜像,这里直接运行迁移)
|
||||||
if check_migration_changed; then
|
if [ "$MIGRATION_CHANGED" = true ]; then
|
||||||
echo ">>> Migration files changed, running database migration..."
|
echo ">>> Running database migration..."
|
||||||
sleep 3
|
sleep 3
|
||||||
run_migration
|
run_migration
|
||||||
else
|
else
|
||||||
|
|||||||
3
dev.sh
3
dev.sh
@@ -8,7 +8,8 @@ source .env
|
|||||||
set +a
|
set +a
|
||||||
|
|
||||||
# 构建 DATABASE_URL
|
# 构建 DATABASE_URL
|
||||||
export DATABASE_URL="postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether"
|
export DATABASE_URL="postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME:-aether}"
|
||||||
|
export REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379}/0
|
||||||
|
|
||||||
# 启动 uvicorn(热重载模式)
|
# 启动 uvicorn(热重载模式)
|
||||||
echo "🚀 启动本地开发服务器..."
|
echo "🚀 启动本地开发服务器..."
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.app
|
dockerfile: Dockerfile.app.local
|
||||||
image: aether-app:latest
|
image: aether-app:latest
|
||||||
container_name: aether-app
|
container_name: aether-app
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Aether 部署配置 - 使用预构建镜像
|
# Aether 部署配置 - 使用预构建镜像
|
||||||
# 使用方法: docker-compose up -d
|
# 使用方法: docker compose up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
BIN
docs/author/qq_qrcode.jpg
Normal file
BIN
docs/author/qq_qrcode.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
BIN
docs/author/wechat_payment.jpg
Normal file
BIN
docs/author/wechat_payment.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
522
frontend/package-lock.json
generated
522
frontend/package-lock.json
generated
@@ -262,6 +262,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -305,6 +306,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -316,9 +318,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
|
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -333,9 +335,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
|
||||||
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
|
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -350,9 +352,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
|
||||||
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
|
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -367,9 +369,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
|
||||||
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
|
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -384,9 +386,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
|
||||||
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
|
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -401,9 +403,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
|
||||||
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
|
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -418,9 +420,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
|
||||||
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
|
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -435,9 +437,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
|
||||||
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
|
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -452,9 +454,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
|
||||||
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
|
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -469,9 +471,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
|
||||||
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
|
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -486,9 +488,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
|
||||||
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
|
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -503,9 +505,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
|
||||||
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
|
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -520,9 +522,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
|
||||||
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
|
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -537,9 +539,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
|
||||||
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
|
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -554,9 +556,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
|
||||||
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
|
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -571,9 +573,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
|
||||||
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
|
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -588,9 +590,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
|
||||||
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
|
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -605,9 +607,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
|
||||||
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
|
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -622,9 +624,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
|
||||||
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
|
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -639,9 +641,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
|
||||||
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
|
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -656,9 +658,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
|
||||||
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
|
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -673,9 +675,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
|
||||||
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
|
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -690,9 +692,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
|
||||||
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
|
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -707,9 +709,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
|
||||||
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
|
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -724,9 +726,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
|
||||||
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
|
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -741,9 +743,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
|
||||||
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
|
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1598,6 +1600,7 @@
|
|||||||
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
|
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.10.0"
|
"undici-types": "~7.10.0"
|
||||||
}
|
}
|
||||||
@@ -1676,6 +1679,7 @@
|
|||||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.49.0",
|
"@typescript-eslint/scope-manager": "8.49.0",
|
||||||
"@typescript-eslint/types": "8.49.0",
|
"@typescript-eslint/types": "8.49.0",
|
||||||
@@ -2004,6 +2008,7 @@
|
|||||||
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
|
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.0.10",
|
"@vitest/utils": "4.0.10",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
@@ -2301,6 +2306,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2602,6 +2608,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.2",
|
"baseline-browser-mapping": "^2.8.2",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
@@ -2718,6 +2725,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -2940,6 +2948,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
@@ -2999,18 +3008,6 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/didyoumean": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -3134,9 +3131,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.9",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3147,32 +3144,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.25.9",
|
"@esbuild/aix-ppc64": "0.27.2",
|
||||||
"@esbuild/android-arm": "0.25.9",
|
"@esbuild/android-arm": "0.27.2",
|
||||||
"@esbuild/android-arm64": "0.25.9",
|
"@esbuild/android-arm64": "0.27.2",
|
||||||
"@esbuild/android-x64": "0.25.9",
|
"@esbuild/android-x64": "0.27.2",
|
||||||
"@esbuild/darwin-arm64": "0.25.9",
|
"@esbuild/darwin-arm64": "0.27.2",
|
||||||
"@esbuild/darwin-x64": "0.25.9",
|
"@esbuild/darwin-x64": "0.27.2",
|
||||||
"@esbuild/freebsd-arm64": "0.25.9",
|
"@esbuild/freebsd-arm64": "0.27.2",
|
||||||
"@esbuild/freebsd-x64": "0.25.9",
|
"@esbuild/freebsd-x64": "0.27.2",
|
||||||
"@esbuild/linux-arm": "0.25.9",
|
"@esbuild/linux-arm": "0.27.2",
|
||||||
"@esbuild/linux-arm64": "0.25.9",
|
"@esbuild/linux-arm64": "0.27.2",
|
||||||
"@esbuild/linux-ia32": "0.25.9",
|
"@esbuild/linux-ia32": "0.27.2",
|
||||||
"@esbuild/linux-loong64": "0.25.9",
|
"@esbuild/linux-loong64": "0.27.2",
|
||||||
"@esbuild/linux-mips64el": "0.25.9",
|
"@esbuild/linux-mips64el": "0.27.2",
|
||||||
"@esbuild/linux-ppc64": "0.25.9",
|
"@esbuild/linux-ppc64": "0.27.2",
|
||||||
"@esbuild/linux-riscv64": "0.25.9",
|
"@esbuild/linux-riscv64": "0.27.2",
|
||||||
"@esbuild/linux-s390x": "0.25.9",
|
"@esbuild/linux-s390x": "0.27.2",
|
||||||
"@esbuild/linux-x64": "0.25.9",
|
"@esbuild/linux-x64": "0.27.2",
|
||||||
"@esbuild/netbsd-arm64": "0.25.9",
|
"@esbuild/netbsd-arm64": "0.27.2",
|
||||||
"@esbuild/netbsd-x64": "0.25.9",
|
"@esbuild/netbsd-x64": "0.27.2",
|
||||||
"@esbuild/openbsd-arm64": "0.25.9",
|
"@esbuild/openbsd-arm64": "0.27.2",
|
||||||
"@esbuild/openbsd-x64": "0.25.9",
|
"@esbuild/openbsd-x64": "0.27.2",
|
||||||
"@esbuild/openharmony-arm64": "0.25.9",
|
"@esbuild/openharmony-arm64": "0.27.2",
|
||||||
"@esbuild/sunos-x64": "0.25.9",
|
"@esbuild/sunos-x64": "0.27.2",
|
||||||
"@esbuild/win32-arm64": "0.25.9",
|
"@esbuild/win32-arm64": "0.27.2",
|
||||||
"@esbuild/win32-ia32": "0.25.9",
|
"@esbuild/win32-ia32": "0.27.2",
|
||||||
"@esbuild/win32-x64": "0.25.9"
|
"@esbuild/win32-x64": "0.27.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
@@ -3204,6 +3201,7 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3747,9 +3745,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4084,18 +4082,6 @@
|
|||||||
"@pkgjs/parseargs": "^0.11.0"
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jiti": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
@@ -4115,6 +4101,7 @@
|
|||||||
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.23",
|
"@acemir/cssom": "^0.9.23",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.4",
|
"@asamuzakjp/dom-selector": "^6.7.4",
|
||||||
@@ -4194,257 +4181,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"lightningcss-darwin-arm64": "1.30.1",
|
|
||||||
"lightningcss-darwin-x64": "1.30.1",
|
|
||||||
"lightningcss-freebsd-x64": "1.30.1",
|
|
||||||
"lightningcss-linux-arm-gnueabihf": "1.30.1",
|
|
||||||
"lightningcss-linux-arm64-gnu": "1.30.1",
|
|
||||||
"lightningcss-linux-arm64-musl": "1.30.1",
|
|
||||||
"lightningcss-linux-x64-gnu": "1.30.1",
|
|
||||||
"lightningcss-linux-x64-musl": "1.30.1",
|
|
||||||
"lightningcss-win32-arm64-msvc": "1.30.1",
|
|
||||||
"lightningcss-win32-x64-msvc": "1.30.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-darwin-arm64": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-darwin-x64": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-freebsd-x64": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-arm64-musl": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-x64-gnu": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-x64-musl": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-win32-x64-msvc": {
|
|
||||||
"version": "1.30.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
|
|
||||||
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -4930,6 +4666,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4997,6 +4734,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -6027,6 +5765,7 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6115,13 +5854,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.5",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
@@ -6195,6 +5935,7 @@
|
|||||||
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
|
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.10",
|
"@vitest/expect": "4.0.10",
|
||||||
"@vitest/mocker": "4.0.10",
|
"@vitest/mocker": "4.0.10",
|
||||||
@@ -6279,6 +6020,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
|
||||||
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
|
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.21",
|
"@vue/compiler-dom": "3.5.21",
|
||||||
"@vue/compiler-sfc": "3.5.21",
|
"@vue/compiler-sfc": "3.5.21",
|
||||||
@@ -6311,7 +6053,6 @@
|
|||||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"eslint-scope": "^8.2.0",
|
"eslint-scope": "^8.2.0",
|
||||||
@@ -6336,7 +6077,6 @@
|
|||||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export interface UserApiKeyExport {
|
|||||||
allowed_endpoints?: 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
|
||||||
@@ -112,7 +112,7 @@ export interface KeyExport {
|
|||||||
export interface ModelExport {
|
export interface ModelExport {
|
||||||
global_model_name: string | null
|
global_model_name: string | null
|
||||||
provider_model_name: string
|
provider_model_name: string
|
||||||
provider_model_aliases?: any
|
provider_model_mappings?: any
|
||||||
price_per_request?: number | null
|
price_per_request?: number | null
|
||||||
tiered_pricing?: any
|
tiered_pricing?: any
|
||||||
supports_vision?: boolean | null
|
supports_vision?: boolean | null
|
||||||
@@ -124,6 +124,37 @@ export interface ModelExport {
|
|||||||
config?: any
|
config?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 邮件模板接口
|
||||||
|
export interface EmailTemplateInfo {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
variables: string[]
|
||||||
|
subject: string
|
||||||
|
html: string
|
||||||
|
is_custom: boolean
|
||||||
|
default_subject?: string
|
||||||
|
default_html?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailTemplatesResponse {
|
||||||
|
templates: EmailTemplateInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailTemplatePreviewResponse {
|
||||||
|
html: string
|
||||||
|
variables: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailTemplateResetResponse {
|
||||||
|
message: string
|
||||||
|
template: {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
subject: string
|
||||||
|
html: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Provider 模型查询响应
|
// Provider 模型查询响应
|
||||||
export interface ProviderModelsQueryResponse {
|
export interface ProviderModelsQueryResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -189,7 +220,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 // 允许的模型列表
|
||||||
@@ -205,8 +236,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 // 过期后是否自动删除
|
||||||
}
|
}
|
||||||
@@ -386,5 +417,61 @@ export const adminApi = {
|
|||||||
{ provider_id: providerId, api_key_id: apiKeyId }
|
{ provider_id: providerId, api_key_id: apiKeyId }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 测试 SMTP 连接,支持传入未保存的配置
|
||||||
|
async testSmtpConnection(config: Record<string, any> = {}): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await apiClient.post<{ success: boolean; message: string }>(
|
||||||
|
'/api/admin/system/smtp/test',
|
||||||
|
config
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 邮件模板相关
|
||||||
|
// 获取所有邮件模板
|
||||||
|
async getEmailTemplates(): Promise<EmailTemplatesResponse> {
|
||||||
|
const response = await apiClient.get<EmailTemplatesResponse>('/api/admin/system/email/templates')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取指定类型的邮件模板
|
||||||
|
async getEmailTemplate(templateType: string): Promise<EmailTemplateInfo> {
|
||||||
|
const response = await apiClient.get<EmailTemplateInfo>(
|
||||||
|
`/api/admin/system/email/templates/${templateType}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新邮件模板
|
||||||
|
async updateEmailTemplate(
|
||||||
|
templateType: string,
|
||||||
|
data: { subject?: string; html?: string }
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.put<{ message: string }>(
|
||||||
|
`/api/admin/system/email/templates/${templateType}`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 预览邮件模板
|
||||||
|
async previewEmailTemplate(
|
||||||
|
templateType: string,
|
||||||
|
data?: { html?: string } & Record<string, string>
|
||||||
|
): Promise<EmailTemplatePreviewResponse> {
|
||||||
|
const response = await apiClient.post<EmailTemplatePreviewResponse>(
|
||||||
|
`/api/admin/system/email/templates/${templateType}/preview`,
|
||||||
|
data || {}
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置邮件模板为默认值
|
||||||
|
async resetEmailTemplate(templateType: string): Promise<EmailTemplateResetResponse> {
|
||||||
|
const response = await apiClient.post<EmailTemplateResetResponse>(
|
||||||
|
`/api/admin/system/email/templates/${templateType}/reset`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,56 @@ export interface UserStats {
|
|||||||
[key: string]: unknown // 允许扩展其他统计数据
|
[key: string]: unknown // 允许扩展其他统计数据
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendVerificationCodeRequest {
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendVerificationCodeResponse {
|
||||||
|
message: string
|
||||||
|
success: boolean
|
||||||
|
expire_minutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyEmailRequest {
|
||||||
|
email: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyEmailResponse {
|
||||||
|
message: string
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationStatusRequest {
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationStatusResponse {
|
||||||
|
email: string
|
||||||
|
has_pending_code: boolean
|
||||||
|
is_verified: boolean
|
||||||
|
cooldown_remaining: number | null
|
||||||
|
code_expires_in: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterResponse {
|
||||||
|
user_id: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationSettingsResponse {
|
||||||
|
enable_registration: boolean
|
||||||
|
require_email_verification: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
username: string
|
username: string
|
||||||
@@ -87,5 +137,41 @@ export const authApi = {
|
|||||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||||
}
|
}
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendVerificationCode(email: string): Promise<SendVerificationCodeResponse> {
|
||||||
|
const response = await apiClient.post<SendVerificationCodeResponse>(
|
||||||
|
'/api/auth/send-verification-code',
|
||||||
|
{ email }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async verifyEmail(email: string, code: string): Promise<VerifyEmailResponse> {
|
||||||
|
const response = await apiClient.post<VerifyEmailResponse>(
|
||||||
|
'/api/auth/verify-email',
|
||||||
|
{ email, code }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||||
|
const response = await apiClient.post<RegisterResponse>('/api/auth/register', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRegistrationSettings(): Promise<RegistrationSettingsResponse> {
|
||||||
|
const response = await apiClient.get<RegistrationSettingsResponse>(
|
||||||
|
'/api/auth/registration-settings'
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getVerificationStatus(email: string): Promise<VerificationStatusResponse> {
|
||||||
|
const response = await apiClient.post<VerificationStatusResponse>(
|
||||||
|
'/api/auth/verification-status',
|
||||||
|
{ email }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export interface DashboardStatsResponse {
|
|||||||
cache_stats?: CacheStats
|
cache_stats?: CacheStats
|
||||||
users?: UserStats
|
users?: UserStats
|
||||||
token_breakdown?: TokenBreakdown
|
token_breakdown?: TokenBreakdown
|
||||||
|
// 普通用户专用字段
|
||||||
|
monthly_cost?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecentRequestsResponse {
|
export interface RecentRequestsResponse {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type {
|
|||||||
GlobalModelUpdate,
|
GlobalModelUpdate,
|
||||||
GlobalModelResponse,
|
GlobalModelResponse,
|
||||||
GlobalModelWithStats,
|
GlobalModelWithStats,
|
||||||
GlobalModelListResponse
|
GlobalModelListResponse,
|
||||||
|
ModelCatalogProviderDetail,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,3 +84,16 @@ export async function batchAssignToProviders(
|
|||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 GlobalModel 的所有关联提供商(包括非活跃的)
|
||||||
|
*/
|
||||||
|
export async function getGlobalModelProviders(globalModelId: string): Promise<{
|
||||||
|
providers: ModelCatalogProviderDetail[]
|
||||||
|
total: number
|
||||||
|
}> {
|
||||||
|
const response = await client.get(
|
||||||
|
`/api/admin/models/global/${globalModelId}/providers`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,14 @@ export async function updateEndpointKey(
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取完整的 API Key(用于查看和复制)
|
||||||
|
*/
|
||||||
|
export async function revealEndpointKey(keyId: string): Promise<{ api_key: string }> {
|
||||||
|
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除 Endpoint Key
|
* 删除 Endpoint Key
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
ModelUpdate,
|
ModelUpdate,
|
||||||
ModelCatalogResponse,
|
ModelCatalogResponse,
|
||||||
ProviderAvailableSourceModelsResponse,
|
ProviderAvailableSourceModelsResponse,
|
||||||
|
UpstreamModel,
|
||||||
|
ImportFromUpstreamResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,3 +121,40 @@ export async function batchAssignModelsToProvider(
|
|||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询提供商的上游模型列表
|
||||||
|
*/
|
||||||
|
export async function queryProviderUpstreamModels(
|
||||||
|
providerId: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data: {
|
||||||
|
models: UpstreamModel[]
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
provider: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
const response = await client.post('/api/admin/provider-query/models', {
|
||||||
|
provider_id: providerId,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从上游提供商导入模型
|
||||||
|
*/
|
||||||
|
export async function importModelsFromUpstream(
|
||||||
|
providerId: string,
|
||||||
|
modelIds: string[]
|
||||||
|
): Promise<ImportFromUpstreamResponse> {
|
||||||
|
const response = await client.post(
|
||||||
|
`/api/admin/providers/${providerId}/import-from-upstream`,
|
||||||
|
{ model_ids: modelIds }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,3 +58,38 @@ export async function deleteProvider(providerId: string): Promise<{ message: str
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试模型连接性
|
||||||
|
*/
|
||||||
|
export interface TestModelRequest {
|
||||||
|
provider_id: string
|
||||||
|
model_name: string
|
||||||
|
api_key_id?: string
|
||||||
|
message?: string
|
||||||
|
api_format?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestModelResponse {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
data?: {
|
||||||
|
response?: {
|
||||||
|
status_code?: number
|
||||||
|
error?: string | { message?: string }
|
||||||
|
choices?: Array<{ message?: { content?: string } }>
|
||||||
|
}
|
||||||
|
content_preview?: string
|
||||||
|
}
|
||||||
|
provider?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
}
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testModel(data: TestModelRequest): Promise<TestModelResponse> {
|
||||||
|
const response = await client.post('/api/admin/provider-query/test-model', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,24 @@ export interface EndpointAPIKey {
|
|||||||
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
|
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EndpointAPIKeyUpdate {
|
||||||
|
name?: string
|
||||||
|
api_key?: string // 仅在需要更新时提供
|
||||||
|
rate_multiplier?: number
|
||||||
|
internal_priority?: number
|
||||||
|
global_priority?: number | null
|
||||||
|
max_concurrent?: number | null // null 表示切换为自适应模式
|
||||||
|
rate_limit?: number
|
||||||
|
daily_limit?: number
|
||||||
|
monthly_limit?: number
|
||||||
|
allowed_models?: string[] | null
|
||||||
|
capabilities?: Record<string, boolean> | null
|
||||||
|
cache_ttl_minutes?: number
|
||||||
|
max_probe_interval_minutes?: number
|
||||||
|
note?: string
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface EndpointHealthDetail {
|
export interface EndpointHealthDetail {
|
||||||
api_format: string
|
api_format: string
|
||||||
health_score: number
|
health_score: number
|
||||||
@@ -244,18 +262,21 @@ export interface ConcurrencyStatus {
|
|||||||
key_max_concurrent?: number
|
key_max_concurrent?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderModelAlias {
|
export interface ProviderModelMapping {
|
||||||
name: string
|
name: string
|
||||||
priority: number // 优先级(数字越小优先级越高)
|
priority: number // 优先级(数字越小优先级越高)
|
||||||
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
|
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保留别名以保持向后兼容
|
||||||
|
export type ProviderModelAlias = ProviderModelMapping
|
||||||
|
|
||||||
export interface Model {
|
export interface Model {
|
||||||
id: string
|
id: string
|
||||||
provider_id: string
|
provider_id: string
|
||||||
global_model_id?: string // 关联的 GlobalModel ID
|
global_model_id?: string // 关联的 GlobalModel ID
|
||||||
provider_model_name: string // Provider 侧的主模型名称
|
provider_model_name: string // Provider 侧的主模型名称
|
||||||
provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级)
|
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||||
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
|
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
|
||||||
price_per_request?: number | null // 按次计费价格
|
price_per_request?: number | null // 按次计费价格
|
||||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||||
@@ -285,7 +306,7 @@ export interface Model {
|
|||||||
|
|
||||||
export interface ModelCreate {
|
export interface ModelCreate {
|
||||||
provider_model_name: string // Provider 侧的主模型名称
|
provider_model_name: string // Provider 侧的主模型名称
|
||||||
provider_model_aliases?: ProviderModelAlias[] // 模型名称别名列表(带优先级)
|
provider_model_mappings?: ProviderModelMapping[] // 模型名称映射列表(带优先级)
|
||||||
global_model_id: string // 关联的 GlobalModel ID(必填)
|
global_model_id: string // 关联的 GlobalModel ID(必填)
|
||||||
// 计费配置(可选,为空时使用 GlobalModel 默认值)
|
// 计费配置(可选,为空时使用 GlobalModel 默认值)
|
||||||
price_per_request?: number // 按次计费价格
|
price_per_request?: number // 按次计费价格
|
||||||
@@ -302,7 +323,7 @@ export interface ModelCreate {
|
|||||||
|
|
||||||
export interface ModelUpdate {
|
export interface ModelUpdate {
|
||||||
provider_model_name?: string
|
provider_model_name?: string
|
||||||
provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级)
|
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||||
global_model_id?: string
|
global_model_id?: string
|
||||||
price_per_request?: number | null // 按次计费价格(null 表示清空/使用默认值)
|
price_per_request?: number | null // 按次计费价格(null 表示清空/使用默认值)
|
||||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||||
@@ -495,3 +516,42 @@ export interface GlobalModelListResponse {
|
|||||||
models: GlobalModelResponse[]
|
models: GlobalModelResponse[]
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 上游模型导入相关 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上游模型(从提供商 API 获取的原始模型)
|
||||||
|
*/
|
||||||
|
export interface UpstreamModel {
|
||||||
|
id: string
|
||||||
|
owned_by?: string
|
||||||
|
display_name?: string
|
||||||
|
api_format?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入成功的模型信息
|
||||||
|
*/
|
||||||
|
export interface ImportFromUpstreamSuccessItem {
|
||||||
|
model_id: string
|
||||||
|
global_model_id: string
|
||||||
|
global_model_name: string
|
||||||
|
provider_model_id: string
|
||||||
|
created_global_model: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入失败的模型信息
|
||||||
|
*/
|
||||||
|
export interface ImportFromUpstreamErrorItem {
|
||||||
|
model_id: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从上游提供商导入模型响应
|
||||||
|
*/
|
||||||
|
export interface ImportFromUpstreamResponse {
|
||||||
|
success: ImportFromUpstreamSuccessItem[]
|
||||||
|
errors: ImportFromUpstreamErrorItem[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ export {
|
|||||||
updateGlobalModel,
|
updateGlobalModel,
|
||||||
deleteGlobalModel,
|
deleteGlobalModel,
|
||||||
batchAssignToProviders,
|
batchAssignToProviders,
|
||||||
|
getGlobalModelProviders,
|
||||||
} from './endpoints/global-models'
|
} from './endpoints/global-models'
|
||||||
|
|||||||
@@ -75,6 +75,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 +97,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 +192,8 @@ export const meApi = {
|
|||||||
async getUsage(params?: {
|
async getUsage(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: 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 +203,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 +287,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,10 +193,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
192
frontend/src/components/VerificationCodeInput.vue
Normal file
192
frontend/src/components/VerificationCodeInput.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div class="verification-code-input">
|
||||||
|
<div class="code-inputs flex gap-2">
|
||||||
|
<input
|
||||||
|
v-for="(digit, index) in digits"
|
||||||
|
:key="index"
|
||||||
|
:ref="(el) => (inputRefs[index] = el as HTMLInputElement)"
|
||||||
|
v-model="digits[index]"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="1"
|
||||||
|
class="code-digit"
|
||||||
|
:class="{ error: hasError }"
|
||||||
|
@input="handleInput(index, $event)"
|
||||||
|
@keydown="handleKeyDown(index, $event)"
|
||||||
|
@paste="handlePaste"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string
|
||||||
|
length?: number
|
||||||
|
hasError?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
(e: 'complete', value: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
length: 6,
|
||||||
|
hasError: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const digits = ref<string[]>(Array(props.length).fill(''))
|
||||||
|
const inputRefs = ref<HTMLInputElement[]>([])
|
||||||
|
|
||||||
|
// Watch modelValue changes from parent
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue.length <= props.length) {
|
||||||
|
digits.value = newValue.split('').concat(Array(props.length - newValue.length).fill(''))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateValue = () => {
|
||||||
|
const value = digits.value.join('')
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
|
||||||
|
// Emit complete event when all digits are filled
|
||||||
|
if (value.length === props.length && /^\d+$/.test(value)) {
|
||||||
|
emit('complete', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = (index: number, event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const value = input.value
|
||||||
|
|
||||||
|
// Only allow digits
|
||||||
|
if (!/^\d*$/.test(value)) {
|
||||||
|
input.value = digits.value[index]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
digits.value[index] = value
|
||||||
|
|
||||||
|
// Auto-focus next input
|
||||||
|
if (value && index < props.length - 1) {
|
||||||
|
inputRefs.value[index + 1]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (index: number, event: KeyboardEvent) => {
|
||||||
|
// Handle backspace
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
if (!digits.value[index] && index > 0) {
|
||||||
|
// If current input is empty, move to previous and clear it
|
||||||
|
inputRefs.value[index - 1]?.focus()
|
||||||
|
digits.value[index - 1] = ''
|
||||||
|
updateValue()
|
||||||
|
} else {
|
||||||
|
// Clear current input
|
||||||
|
digits.value[index] = ''
|
||||||
|
updateValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle arrow keys
|
||||||
|
else if (event.key === 'ArrowLeft' && index > 0) {
|
||||||
|
inputRefs.value[index - 1]?.focus()
|
||||||
|
} else if (event.key === 'ArrowRight' && index < props.length - 1) {
|
||||||
|
inputRefs.value[index + 1]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const pastedData = event.clipboardData?.getData('text') || ''
|
||||||
|
const cleanedData = pastedData.replace(/\D/g, '').slice(0, props.length)
|
||||||
|
|
||||||
|
if (cleanedData) {
|
||||||
|
digits.value = cleanedData.split('').concat(Array(props.length - cleanedData.length).fill(''))
|
||||||
|
updateValue()
|
||||||
|
|
||||||
|
// Focus the next empty input or the last input
|
||||||
|
const nextEmptyIndex = digits.value.findIndex((d) => !d)
|
||||||
|
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : props.length - 1
|
||||||
|
inputRefs.value[focusIndex]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose method to clear inputs
|
||||||
|
const clear = () => {
|
||||||
|
digits.value = Array(props.length).fill('')
|
||||||
|
inputRefs.value[0]?.focus()
|
||||||
|
updateValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose method to focus first input
|
||||||
|
const focus = () => {
|
||||||
|
inputRefs.value[0]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
clear,
|
||||||
|
focus
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.code-inputs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-digit {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 2px solid hsl(var(--border));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-digit:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-digit:hover:not(:focus) {
|
||||||
|
border-color: hsl(var(--primary) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-digit.error {
|
||||||
|
border-color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-digit.error:focus {
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--destructive) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent spinner buttons on number inputs */
|
||||||
|
.code-digit::-webkit-outer-spin-button,
|
||||||
|
.code-digit::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-digit[type='number'] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -71,8 +71,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<!-- 内容区域:统一添加 padding -->
|
<!-- 内容区域:可选添加 padding -->
|
||||||
<div class="px-6 py-3">
|
<div :class="noPadding ? '' : 'px-6 py-3'">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, useSlots, type Component } from 'vue'
|
import { computed, useSlots, type Component } from 'vue'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
|
|
||||||
// Props 定义
|
// Props 定义
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -104,6 +105,7 @@ const props = defineProps<{
|
|||||||
icon?: Component // Lucide icon component
|
icon?: Component // Lucide icon component
|
||||||
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
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Emits 定义
|
// Emits 定义
|
||||||
@@ -157,4 +159,16 @@ const maxWidthClass = computed(() => {
|
|||||||
const containerZIndex = computed(() => props.zIndex || 60)
|
const containerZIndex = computed(() => props.zIndex || 60)
|
||||||
const backdropZIndex = computed(() => props.zIndex || 60)
|
const backdropZIndex = computed(() => props.zIndex || 60)
|
||||||
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
|
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
handleClose()
|
||||||
|
return true // 阻止其他监听器(如父级抽屉的 ESC 监听器)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
:class="inputClass"
|
:class="inputClass"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:autocomplete="autocompleteAttr"
|
:autocomplete="autocompleteAttr"
|
||||||
|
:data-lpignore="disableAutofill ? 'true' : undefined"
|
||||||
|
:data-1p-ignore="disableAutofill ? 'true' : undefined"
|
||||||
|
:data-form-type="disableAutofill ? 'other' : undefined"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
>
|
>
|
||||||
@@ -16,6 +19,7 @@ interface Props {
|
|||||||
modelValue?: string | number
|
modelValue?: string | number
|
||||||
class?: string
|
class?: string
|
||||||
autocomplete?: string
|
autocomplete?: string
|
||||||
|
disableAutofill?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -23,7 +27,12 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [value: string]
|
'update:modelValue': [value: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const autocompleteAttr = computed(() => props.autocomplete ?? 'off')
|
const autocompleteAttr = computed(() => {
|
||||||
|
if (props.disableAutofill) {
|
||||||
|
return 'one-time-code'
|
||||||
|
}
|
||||||
|
return props.autocomplete ?? 'off'
|
||||||
|
})
|
||||||
|
|
||||||
const inputClass = computed(() =>
|
const inputClass = computed(() =>
|
||||||
cn(
|
cn(
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { log } from '@/utils/logger'
|
|||||||
export function useClipboard() {
|
export function useClipboard() {
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
|
||||||
async function copyToClipboard(text: string): Promise<boolean> {
|
async function copyToClipboard(text: string, showToast = true): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
success('已复制到剪贴板')
|
if (showToast) success('已复制到剪贴板')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,17 +25,17 @@ export function useClipboard() {
|
|||||||
try {
|
try {
|
||||||
const successful = document.execCommand('copy')
|
const successful = document.execCommand('copy')
|
||||||
if (successful) {
|
if (successful) {
|
||||||
success('已复制到剪贴板')
|
if (showToast) success('已复制到剪贴板')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
showError('复制失败,请手动复制')
|
if (showToast) showError('复制失败,请手动复制')
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(textArea)
|
document.body.removeChild(textArea)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('复制失败:', err)
|
log.error('复制失败:', err)
|
||||||
showError('复制失败,请手动选择文本进行复制')
|
if (showToast) showError('复制失败,请手动选择文本进行复制')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ export function useConfirm() {
|
|||||||
/**
|
/**
|
||||||
* 便捷方法:危险操作确认(红色主题)
|
* 便捷方法:危险操作确认(红色主题)
|
||||||
*/
|
*/
|
||||||
const confirmDanger = (message: string, title?: string): Promise<boolean> => {
|
const confirmDanger = (message: string, title?: string, confirmText?: string): Promise<boolean> => {
|
||||||
return confirm({
|
return confirm({
|
||||||
message,
|
message,
|
||||||
title: title || '危险操作',
|
title: title || '危险操作',
|
||||||
confirmText: '删除',
|
confirmText: confirmText || '删除',
|
||||||
variant: 'danger'
|
variant: 'danger'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
83
frontend/src/composables/useEscapeKey.ts
Normal file
83
frontend/src/composables/useEscapeKey.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ESC 键监听 Composable(简化版本,直接使用独立监听器)
|
||||||
|
* 用于按 ESC 键关闭弹窗或其他可关闭的组件
|
||||||
|
*
|
||||||
|
* @param callback - 按 ESC 键时执行的回调函数,返回 true 表示已处理事件,阻止其他监听器执行
|
||||||
|
* @param options - 配置选项
|
||||||
|
*/
|
||||||
|
export function useEscapeKey(
|
||||||
|
callback: () => void | boolean,
|
||||||
|
options: {
|
||||||
|
/** 是否在输入框获得焦点时禁用 ESC 键,默认 true */
|
||||||
|
disableOnInput?: boolean
|
||||||
|
/** 是否只监听一次,默认 false */
|
||||||
|
once?: boolean
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const { disableOnInput = true, once = false } = options
|
||||||
|
const isActive = ref(true)
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
// 只处理 ESC 键
|
||||||
|
if (event.key !== 'Escape') return
|
||||||
|
|
||||||
|
// 检查组件是否还活跃
|
||||||
|
if (!isActive.value) return
|
||||||
|
|
||||||
|
// 如果配置了在输入框获得焦点时禁用,则检查当前焦点元素
|
||||||
|
if (disableOnInput) {
|
||||||
|
const activeElement = document.activeElement
|
||||||
|
const isInputElement = activeElement && (
|
||||||
|
activeElement.tagName === 'INPUT' ||
|
||||||
|
activeElement.tagName === 'TEXTAREA' ||
|
||||||
|
activeElement.tagName === 'SELECT' ||
|
||||||
|
activeElement.contentEditable === 'true' ||
|
||||||
|
activeElement.getAttribute('role') === 'textbox' ||
|
||||||
|
activeElement.getAttribute('role') === 'combobox'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果焦点在输入框中,不处理 ESC 键
|
||||||
|
if (isInputElement) return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行回调,如果返回 true 则阻止其他监听器
|
||||||
|
const handled = callback()
|
||||||
|
if (handled === true) {
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除当前元素的焦点,避免残留样式
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只监听一次,则移除监听器
|
||||||
|
if (once) {
|
||||||
|
removeEventListener()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEventListener() {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEventListener() {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
addEventListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
isActive.value = false
|
||||||
|
removeEventListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
addEventListener,
|
||||||
|
removeEventListener
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
|
|
||||||
@@ -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,13 +409,11 @@ 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
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
|
|||||||
@@ -98,12 +98,27 @@
|
|||||||
|
|
||||||
<!-- 提示信息 -->
|
<!-- 提示信息 -->
|
||||||
<p
|
<p
|
||||||
v-if="!isDemo"
|
v-if="!isDemo && !allowRegistration"
|
||||||
class="text-xs text-slate-400 dark:text-muted-foreground/80"
|
class="text-xs text-slate-400 dark:text-muted-foreground/80"
|
||||||
>
|
>
|
||||||
如需开通账户,请联系管理员配置访问权限
|
如需开通账户,请联系管理员配置访问权限
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- 注册链接 -->
|
||||||
|
<div
|
||||||
|
v-if="allowRegistration"
|
||||||
|
class="mt-4 text-center text-sm"
|
||||||
|
>
|
||||||
|
还没有账户?
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
class="h-auto p-0"
|
||||||
|
@click="handleSwitchToRegister"
|
||||||
|
>
|
||||||
|
立即注册
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -124,10 +139,18 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Register Dialog -->
|
||||||
|
<RegisterDialog
|
||||||
|
v-model:open="showRegisterDialog"
|
||||||
|
:require-email-verification="requireEmailVerification"
|
||||||
|
@success="handleRegisterSuccess"
|
||||||
|
@switch-to-login="handleSwitchToLogin"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Dialog } from '@/components/ui'
|
import { Dialog } from '@/components/ui'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
@@ -136,6 +159,8 @@ import Label from '@/components/ui/label.vue'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
|
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
|
||||||
|
import RegisterDialog from './RegisterDialog.vue'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -151,6 +176,9 @@ const { success: showSuccess, warning: showWarning, error: showError } = useToas
|
|||||||
|
|
||||||
const isOpen = ref(props.modelValue)
|
const isOpen = ref(props.modelValue)
|
||||||
const isDemo = computed(() => isDemoMode())
|
const isDemo = computed(() => isDemoMode())
|
||||||
|
const showRegisterDialog = ref(false)
|
||||||
|
const requireEmailVerification = ref(false)
|
||||||
|
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||||
|
|
||||||
watch(() => props.modelValue, (val) => {
|
watch(() => props.modelValue, (val) => {
|
||||||
isOpen.value = val
|
isOpen.value = val
|
||||||
@@ -201,4 +229,33 @@ async function handleLogin() {
|
|||||||
showError(authStore.error || '登录失败,请检查邮箱和密码')
|
showError(authStore.error || '登录失败,请检查邮箱和密码')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSwitchToRegister() {
|
||||||
|
isOpen.value = false
|
||||||
|
showRegisterDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegisterSuccess() {
|
||||||
|
showRegisterDialog.value = false
|
||||||
|
showSuccess('注册成功!请登录')
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwitchToLogin() {
|
||||||
|
showRegisterDialog.value = false
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load registration settings on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const settings = await authApi.getRegistrationSettings()
|
||||||
|
allowRegistration.value = !!settings.enable_registration
|
||||||
|
requireEmailVerification.value = !!settings.require_email_verification
|
||||||
|
} catch (error) {
|
||||||
|
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证
|
||||||
|
allowRegistration.value = false
|
||||||
|
requireEmailVerification.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
640
frontend/src/features/auth/components/RegisterDialog.vue
Normal file
640
frontend/src/features/auth/components/RegisterDialog.vue
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:open="isOpen"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Logo 和标题 -->
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
|
||||||
|
<img
|
||||||
|
src="/aether_adaptive.svg"
|
||||||
|
alt="Logo"
|
||||||
|
class="h-16 w-16"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
注册新账户
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
请填写您的邮箱和个人信息完成注册
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 注册表单 -->
|
||||||
|
<form
|
||||||
|
class="space-y-4"
|
||||||
|
autocomplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="reg-email">邮箱 <span class="text-muted-foreground">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="reg-email"
|
||||||
|
v-model="formData.email"
|
||||||
|
type="email"
|
||||||
|
placeholder="hello@example.com"
|
||||||
|
required
|
||||||
|
disable-autofill
|
||||||
|
:disabled="isLoading || emailVerified"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verification Code Section -->
|
||||||
|
<div
|
||||||
|
v-if="requireEmailVerification"
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>验证码 <span class="text-muted-foreground">*</span></Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
class="h-auto p-0 text-xs"
|
||||||
|
:disabled="isSendingCode || !canSendCode || emailVerified"
|
||||||
|
@click="handleSendCode"
|
||||||
|
>
|
||||||
|
{{ sendCodeButtonText }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center gap-2">
|
||||||
|
<!-- 发送中显示 loading -->
|
||||||
|
<div
|
||||||
|
v-if="isSendingCode"
|
||||||
|
class="flex items-center justify-center gap-2 h-14 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="animate-spin h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">正在发送验证码...</span>
|
||||||
|
</div>
|
||||||
|
<!-- 验证码输入框 -->
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-for="(_, index) in 6"
|
||||||
|
:key="index"
|
||||||
|
:ref="(el) => setCodeInputRef(index, el as HTMLInputElement)"
|
||||||
|
v-model="codeDigits[index]"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="1"
|
||||||
|
autocomplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
class="w-12 h-14 text-center text-xl font-semibold border-2 rounded-lg bg-background transition-all focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||||
|
:class="verificationError ? 'border-destructive' : 'border-border focus:border-primary'"
|
||||||
|
:disabled="emailVerified"
|
||||||
|
@input="handleCodeInput(index, $event)"
|
||||||
|
@keydown="handleCodeKeyDown(index, $event)"
|
||||||
|
@paste="handleCodePaste"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Username -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="reg-uname">用户名 <span class="text-muted-foreground">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="reg-uname"
|
||||||
|
v-model="formData.username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
required
|
||||||
|
disable-autofill
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label :for="`pwd-${formNonce}`">密码 <span class="text-muted-foreground">*</span></Label>
|
||||||
|
<Input
|
||||||
|
:id="`pwd-${formNonce}`"
|
||||||
|
v-model="formData.password"
|
||||||
|
type="text"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
:name="`pwd-${formNonce}`"
|
||||||
|
placeholder="至少 6 个字符"
|
||||||
|
required
|
||||||
|
class="-webkit-text-security-disc"
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Password -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-muted-foreground">*</span></Label>
|
||||||
|
<Input
|
||||||
|
:id="`pwd-confirm-${formNonce}`"
|
||||||
|
v-model="formData.confirmPassword"
|
||||||
|
type="text"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
:name="`pwd-confirm-${formNonce}`"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
required
|
||||||
|
class="-webkit-text-security-disc"
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- 登录链接 -->
|
||||||
|
<div class="text-center text-sm">
|
||||||
|
已有账户?
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
class="h-auto p-0"
|
||||||
|
@click="handleSwitchToLogin"
|
||||||
|
>
|
||||||
|
立即登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
|
||||||
|
:disabled="isLoading || !canSubmit"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ isLoading ? loadingText : '注册' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { Dialog } from '@/components/ui'
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import Input from '@/components/ui/input.vue'
|
||||||
|
import Label from '@/components/ui/label.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean
|
||||||
|
requireEmailVerification?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:open', value: boolean): void
|
||||||
|
(e: 'success'): void
|
||||||
|
(e: 'switchToLogin'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
open: false,
|
||||||
|
requireEmailVerification: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
const { success, error: showError } = useToast()
|
||||||
|
|
||||||
|
// Form nonce for password fields (prevent autofill)
|
||||||
|
const formNonce = ref(createFormNonce())
|
||||||
|
|
||||||
|
function createFormNonce(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verification code inputs
|
||||||
|
const codeInputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||||
|
const codeDigits = ref<string[]>(['', '', '', '', '', ''])
|
||||||
|
|
||||||
|
const setCodeInputRef = (index: number, el: HTMLInputElement | null) => {
|
||||||
|
codeInputRefs.value[index] = el
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle verification code input
|
||||||
|
const handleCodeInput = (index: number, event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const value = input.value
|
||||||
|
|
||||||
|
// Only allow digits
|
||||||
|
if (!/^\d*$/.test(value)) {
|
||||||
|
input.value = codeDigits.value[index]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
codeDigits.value[index] = value
|
||||||
|
|
||||||
|
// Auto-focus next input
|
||||||
|
if (value && index < 5) {
|
||||||
|
codeInputRefs.value[index + 1]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all digits are filled
|
||||||
|
const fullCode = codeDigits.value.join('')
|
||||||
|
if (fullCode.length === 6 && /^\d+$/.test(fullCode)) {
|
||||||
|
handleCodeComplete(fullCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCodeKeyDown = (index: number, event: KeyboardEvent) => {
|
||||||
|
// Handle backspace
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
if (!codeDigits.value[index] && index > 0) {
|
||||||
|
// If current input is empty, move to previous and clear it
|
||||||
|
codeInputRefs.value[index - 1]?.focus()
|
||||||
|
codeDigits.value[index - 1] = ''
|
||||||
|
} else {
|
||||||
|
// Clear current input
|
||||||
|
codeDigits.value[index] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle arrow keys
|
||||||
|
else if (event.key === 'ArrowLeft' && index > 0) {
|
||||||
|
codeInputRefs.value[index - 1]?.focus()
|
||||||
|
} else if (event.key === 'ArrowRight' && index < 5) {
|
||||||
|
codeInputRefs.value[index + 1]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCodePaste = (event: ClipboardEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const pastedData = event.clipboardData?.getData('text') || ''
|
||||||
|
const cleanedData = pastedData.replace(/\D/g, '').slice(0, 6)
|
||||||
|
|
||||||
|
if (cleanedData) {
|
||||||
|
// Fill digits
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
codeDigits.value[i] = cleanedData[i] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the next empty input or the last input
|
||||||
|
const nextEmptyIndex = codeDigits.value.findIndex((d) => !d)
|
||||||
|
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : 5
|
||||||
|
codeInputRefs.value[focusIndex]?.focus()
|
||||||
|
|
||||||
|
// Check if all digits are filled
|
||||||
|
if (cleanedData.length === 6) {
|
||||||
|
handleCodeComplete(cleanedData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCodeInputs = () => {
|
||||||
|
codeDigits.value = ['', '', '', '', '', '']
|
||||||
|
codeInputRefs.value[0]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.open,
|
||||||
|
set: (value) => emit('update:open', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
email: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
verificationCode: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const loadingText = ref('注册中...')
|
||||||
|
const isSendingCode = ref(false)
|
||||||
|
const emailVerified = ref(false)
|
||||||
|
const verificationError = ref(false)
|
||||||
|
const codeSentAt = ref<number | null>(null)
|
||||||
|
const cooldownSeconds = ref(0)
|
||||||
|
const expireMinutes = ref(5)
|
||||||
|
const cooldownTimer = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Send code cooldown timer
|
||||||
|
const canSendCode = computed(() => {
|
||||||
|
if (!formData.value.email) return false
|
||||||
|
if (cooldownSeconds.value > 0) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendCodeButtonText = computed(() => {
|
||||||
|
if (isSendingCode.value) return '发送中...'
|
||||||
|
if (emailVerified.value) return '验证成功'
|
||||||
|
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
||||||
|
if (codeSentAt.value) return '重新发送验证码'
|
||||||
|
return '发送验证码'
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
const hasBasicInfo =
|
||||||
|
formData.value.email &&
|
||||||
|
formData.value.username &&
|
||||||
|
formData.value.password &&
|
||||||
|
formData.value.confirmPassword
|
||||||
|
|
||||||
|
if (!hasBasicInfo) return false
|
||||||
|
|
||||||
|
// If email verification is required, check if verified
|
||||||
|
if (props.requireEmailVerification && !emailVerified.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password match
|
||||||
|
if (formData.value.password !== formData.value.confirmPassword) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password length
|
||||||
|
if (formData.value.password.length < 6) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 查询并恢复验证状态
|
||||||
|
const checkAndRestoreVerificationStatus = async (email: string) => {
|
||||||
|
if (!email || !props.requireEmailVerification) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await authApi.getVerificationStatus(email)
|
||||||
|
|
||||||
|
// 注意:不恢复 is_verified 状态
|
||||||
|
// 刷新页面后需要重新发送验证码并验证,防止验证码被他人使用
|
||||||
|
// 只恢复"有待验证验证码"的状态(冷却时间)
|
||||||
|
if (status.has_pending_code) {
|
||||||
|
codeSentAt.value = Date.now()
|
||||||
|
verificationError.value = false
|
||||||
|
|
||||||
|
// 恢复冷却时间
|
||||||
|
if (status.cooldown_remaining && status.cooldown_remaining > 0) {
|
||||||
|
startCooldown(status.cooldown_remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 查询失败时静默处理,不影响用户体验
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邮箱查询防抖定时器
|
||||||
|
let emailCheckTimer: number | null = null
|
||||||
|
|
||||||
|
// 监听邮箱变化,查询验证状态
|
||||||
|
watch(
|
||||||
|
() => formData.value.email,
|
||||||
|
(newEmail, oldEmail) => {
|
||||||
|
// 邮箱变化时重置验证状态
|
||||||
|
if (newEmail !== oldEmail) {
|
||||||
|
emailVerified.value = false
|
||||||
|
verificationError.value = false
|
||||||
|
codeSentAt.value = null
|
||||||
|
cooldownSeconds.value = 0
|
||||||
|
if (cooldownTimer.value !== null) {
|
||||||
|
clearInterval(cooldownTimer.value)
|
||||||
|
cooldownTimer.value = null
|
||||||
|
}
|
||||||
|
codeDigits.value = ['', '', '', '', '', '']
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (emailCheckTimer !== null) {
|
||||||
|
clearTimeout(emailCheckTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证邮箱格式
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(newEmail)) return
|
||||||
|
|
||||||
|
// 防抖:500ms 后查询验证状态
|
||||||
|
emailCheckTimer = window.setTimeout(() => {
|
||||||
|
checkAndRestoreVerificationStatus(newEmail)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset form when dialog opens
|
||||||
|
watch(isOpen, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start cooldown timer
|
||||||
|
const startCooldown = (seconds: number) => {
|
||||||
|
// Clear existing timer if any
|
||||||
|
if (cooldownTimer.value !== null) {
|
||||||
|
clearInterval(cooldownTimer.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
cooldownSeconds.value = seconds
|
||||||
|
cooldownTimer.value = window.setInterval(() => {
|
||||||
|
cooldownSeconds.value--
|
||||||
|
if (cooldownSeconds.value <= 0) {
|
||||||
|
if (cooldownTimer.value !== null) {
|
||||||
|
clearInterval(cooldownTimer.value)
|
||||||
|
cooldownTimer.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup timer on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (cooldownTimer.value !== null) {
|
||||||
|
clearInterval(cooldownTimer.value)
|
||||||
|
}
|
||||||
|
if (emailCheckTimer !== null) {
|
||||||
|
clearTimeout(emailCheckTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.value = {
|
||||||
|
email: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
verificationCode: ''
|
||||||
|
}
|
||||||
|
emailVerified.value = false
|
||||||
|
verificationError.value = false
|
||||||
|
isSendingCode.value = false
|
||||||
|
codeSentAt.value = null
|
||||||
|
cooldownSeconds.value = 0
|
||||||
|
|
||||||
|
// Reset password field nonce
|
||||||
|
formNonce.value = createFormNonce()
|
||||||
|
|
||||||
|
// Clear timer
|
||||||
|
if (cooldownTimer.value !== null) {
|
||||||
|
clearInterval(cooldownTimer.value)
|
||||||
|
cooldownTimer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear verification code inputs
|
||||||
|
codeDigits.value = ['', '', '', '', '', '']
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendCode = async () => {
|
||||||
|
if (!formData.value.email) {
|
||||||
|
showError('请输入邮箱')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(formData.value.email)) {
|
||||||
|
showError('请输入有效的邮箱地址', '邮箱格式错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSendingCode.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authApi.sendVerificationCode(formData.value.email)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
codeSentAt.value = Date.now()
|
||||||
|
if (response.expire_minutes) {
|
||||||
|
expireMinutes.value = response.expire_minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
success(`请查收邮件,验证码有效期 ${expireMinutes.value} 分钟`, '验证码已发送')
|
||||||
|
|
||||||
|
// Start 60 second cooldown
|
||||||
|
startCooldown(60)
|
||||||
|
|
||||||
|
// Focus the first verification code input
|
||||||
|
nextTick(() => {
|
||||||
|
codeInputRefs.value[0]?.focus()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showError(response.message || '请稍后重试', '发送失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg = error.response?.data?.detail
|
||||||
|
|| error.response?.data?.error?.message
|
||||||
|
|| error.message
|
||||||
|
|| '网络错误,请重试'
|
||||||
|
showError(errorMsg, '发送失败')
|
||||||
|
} finally {
|
||||||
|
isSendingCode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCodeComplete = async (code: string) => {
|
||||||
|
if (!formData.value.email || code.length !== 6) return
|
||||||
|
|
||||||
|
// 如果已经验证成功,不再重复验证
|
||||||
|
if (emailVerified.value) return
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
loadingText.value = '验证中...'
|
||||||
|
verificationError.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authApi.verifyEmail(formData.value.email, code)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
emailVerified.value = true
|
||||||
|
success('邮箱验证通过,请继续完成注册', '验证成功')
|
||||||
|
} else {
|
||||||
|
verificationError.value = true
|
||||||
|
showError(response.message || '验证码错误', '验证失败')
|
||||||
|
// Clear the code input
|
||||||
|
clearCodeInputs()
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
verificationError.value = true
|
||||||
|
const errorMsg = error.response?.data?.detail
|
||||||
|
|| error.response?.data?.error?.message
|
||||||
|
|| error.message
|
||||||
|
|| '验证码错误,请重试'
|
||||||
|
showError(errorMsg, '验证失败')
|
||||||
|
// Clear the code input
|
||||||
|
clearCodeInputs()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validate password match
|
||||||
|
if (formData.value.password !== formData.value.confirmPassword) {
|
||||||
|
showError('两次输入的密码不一致', '密码不匹配')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (formData.value.password.length < 6) {
|
||||||
|
showError('密码长度至少 6 位', '密码过短')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email verification if required
|
||||||
|
if (props.requireEmailVerification && !emailVerified.value) {
|
||||||
|
showError('请先完成邮箱验证')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
loadingText.value = '注册中...'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authApi.register({
|
||||||
|
email: formData.value.email,
|
||||||
|
username: formData.value.username,
|
||||||
|
password: formData.value.password
|
||||||
|
})
|
||||||
|
|
||||||
|
success(response.message || '欢迎加入!请登录以继续', '注册成功')
|
||||||
|
|
||||||
|
emit('success')
|
||||||
|
isOpen.value = false
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg = error.response?.data?.detail
|
||||||
|
|| error.response?.data?.error?.message
|
||||||
|
|| error.message
|
||||||
|
|| '注册失败,请重试'
|
||||||
|
showError(errorMsg, '注册失败')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSwitchToLogin = () => {
|
||||||
|
emit('switchToLogin')
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -698,7 +698,9 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
BarChart3
|
BarChart3
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
@@ -730,6 +732,7 @@ const emit = defineEmits<{
|
|||||||
'refreshProviders': []
|
'refreshProviders': []
|
||||||
}>()
|
}>()
|
||||||
const { success: showSuccess, error: showError } = useToast()
|
const { success: showSuccess, error: showError } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
model: GlobalModelResponse | null
|
model: GlobalModelResponse | null
|
||||||
@@ -762,16 +765,6 @@ function handleClose() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制到剪贴板
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
showSuccess('已复制')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
@@ -833,6 +826,16 @@ watch(() => props.open, (newOpen) => {
|
|||||||
detailTab.value = 'basic'
|
detailTab.value = 'basic'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (props.open) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -31,29 +31,46 @@
|
|||||||
|
|
||||||
<!-- 左右对比布局 -->
|
<!-- 左右对比布局 -->
|
||||||
<div class="flex gap-2 items-stretch">
|
<div class="flex gap-2 items-stretch">
|
||||||
<!-- 左侧:可添加的模型 -->
|
<!-- 左侧:可添加的模型(分组折叠) -->
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<p class="text-sm font-medium shrink-0">
|
||||||
<p class="text-sm font-medium">
|
|
||||||
可添加
|
可添加
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<div class="flex-1 relative">
|
||||||
v-if="availableModels.length > 0"
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
variant="ghost"
|
<Input
|
||||||
size="sm"
|
v-model="searchQuery"
|
||||||
class="h-6 px-2 text-xs"
|
placeholder="搜索模型..."
|
||||||
@click="toggleSelectAllLeft"
|
class="pl-7 h-7 text-xs"
|
||||||
>
|
/>
|
||||||
{{ isAllLeftSelected ? '取消全选' : '全选' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<button
|
||||||
variant="secondary"
|
v-if="upstreamModelsLoaded"
|
||||||
class="text-xs"
|
type="button"
|
||||||
|
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||||
|
title="刷新上游模型"
|
||||||
|
:disabled="fetchingUpstreamModels"
|
||||||
|
@click="fetchUpstreamModels(true)"
|
||||||
>
|
>
|
||||||
{{ availableModels.length }} 个
|
<RefreshCw
|
||||||
</Badge>
|
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>
|
||||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
@@ -63,7 +80,7 @@
|
|||||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="availableModels.length === 0"
|
v-else-if="totalAvailableCount === 0 && !upstreamModelsLoaded"
|
||||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Layers class="w-10 h-10 mb-2 opacity-30" />
|
<Layers class="w-10 h-10 mb-2 opacity-30" />
|
||||||
@@ -73,20 +90,62 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="p-2 space-y-1"
|
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
|
<div
|
||||||
v-for="model in availableModels"
|
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.id"
|
:key="model.id"
|
||||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors"
|
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||||
:class="selectedLeftIds.includes(model.id)
|
:class="selectedGlobalModelIds.includes(model.id)
|
||||||
? 'border-primary bg-primary/10'
|
? 'border-primary bg-primary/10'
|
||||||
: 'hover:bg-muted/50 cursor-pointer'"
|
: 'hover:bg-muted/50'"
|
||||||
@click="toggleLeftSelection(model.id)"
|
@click="toggleGlobalModelSelection(model.id)"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="selectedLeftIds.includes(model.id)"
|
:checked="selectedGlobalModelIds.includes(model.id)"
|
||||||
@update:checked="toggleLeftSelection(model.id)"
|
@update:checked="toggleGlobalModelSelection(model.id)"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -107,6 +166,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="selectedUpstreamModelIds.includes(model.id)
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'hover:bg-muted/50'"
|
||||||
|
@click="toggleUpstreamModelSelection(model.id)"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:checked="selectedUpstreamModelIds.includes(model.id)"
|
||||||
|
@update:checked="toggleUpstreamModelSelection(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>
|
||||||
|
|
||||||
<!-- 中间:操作按钮 -->
|
<!-- 中间:操作按钮 -->
|
||||||
@@ -115,8 +237,8 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-9 h-8"
|
class="w-9 h-8"
|
||||||
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'border-primary' : ''"
|
:class="totalSelectedCount > 0 && !submittingAdd ? 'border-primary' : ''"
|
||||||
:disabled="selectedLeftIds.length === 0 || submittingAdd"
|
:disabled="totalSelectedCount === 0 || submittingAdd"
|
||||||
title="添加选中"
|
title="添加选中"
|
||||||
@click="batchAddSelected"
|
@click="batchAddSelected"
|
||||||
>
|
>
|
||||||
@@ -127,7 +249,7 @@
|
|||||||
<ChevronRight
|
<ChevronRight
|
||||||
v-else
|
v-else
|
||||||
class="w-6 h-6 stroke-[3]"
|
class="w-6 h-6 stroke-[3]"
|
||||||
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'text-primary' : ''"
|
:class="totalSelectedCount > 0 && !submittingAdd ? 'text-primary' : ''"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -154,7 +276,6 @@
|
|||||||
<!-- 右侧:已添加的模型 -->
|
<!-- 右侧:已添加的模型 -->
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<p class="text-sm font-medium">
|
<p class="text-sm font-medium">
|
||||||
已添加
|
已添加
|
||||||
</p>
|
</p>
|
||||||
@@ -165,16 +286,9 @@
|
|||||||
class="h-6 px-2 text-xs"
|
class="h-6 px-2 text-xs"
|
||||||
@click="toggleSelectAllRight"
|
@click="toggleSelectAllRight"
|
||||||
>
|
>
|
||||||
{{ isAllRightSelected ? '取消全选' : '全选' }}
|
{{ isAllRightSelected ? '取消' : '全选' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ existingModels.length }} 个
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-if="existingModels.length === 0"
|
v-if="existingModels.length === 0"
|
||||||
@@ -238,11 +352,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Layers, Loader2, ChevronRight, ChevronLeft } from 'lucide-vue-next'
|
import { Layers, Loader2, ChevronRight, ChevronLeft, ChevronDown, Zap, RefreshCw, Search } from 'lucide-vue-next'
|
||||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||||
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 Input from '@/components/ui/input.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
@@ -253,8 +368,11 @@ import {
|
|||||||
getProviderModels,
|
getProviderModels,
|
||||||
batchAssignModelsToProvider,
|
batchAssignModelsToProvider,
|
||||||
deleteModel,
|
deleteModel,
|
||||||
|
importModelsFromUpstream,
|
||||||
|
API_FORMAT_LABELS,
|
||||||
type Model
|
type Model
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
|
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -268,23 +386,35 @@ const emit = defineEmits<{
|
|||||||
'changed': []
|
'changed': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||||
|
|
||||||
const { error: showError, success } = useToast()
|
const { error: showError, success } = useToast()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const loadingGlobalModels = ref(false)
|
const loadingGlobalModels = ref(false)
|
||||||
const submittingAdd = ref(false)
|
const submittingAdd = ref(false)
|
||||||
const submittingRemove = ref(false)
|
const submittingRemove = ref(false)
|
||||||
|
const fetchingUpstreamModels = ref(false)
|
||||||
|
const upstreamModelsLoaded = ref(false)
|
||||||
|
|
||||||
// 数据
|
// 数据
|
||||||
const allGlobalModels = ref<GlobalModelResponse[]>([])
|
const allGlobalModels = ref<GlobalModelResponse[]>([])
|
||||||
const existingModels = ref<Model[]>([])
|
const existingModels = ref<Model[]>([])
|
||||||
|
const upstreamModels = ref<UpstreamModel[]>([])
|
||||||
|
|
||||||
// 选择状态
|
// 选择状态
|
||||||
const selectedLeftIds = ref<string[]>([])
|
const selectedGlobalModelIds = ref<string[]>([])
|
||||||
|
const selectedUpstreamModelIds = ref<string[]>([])
|
||||||
const selectedRightIds = ref<string[]>([])
|
const selectedRightIds = ref<string[]>([])
|
||||||
|
|
||||||
// 计算可添加的模型(排除已关联的)
|
// 折叠状态
|
||||||
const availableModels = computed(() => {
|
const collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 搜索状态
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// 计算可添加的全局模型(排除已关联的)
|
||||||
|
const availableGlobalModelsBase = computed(() => {
|
||||||
const existingGlobalModelIds = new Set(
|
const existingGlobalModelIds = new Set(
|
||||||
existingModels.value
|
existingModels.value
|
||||||
.filter(m => m.global_model_id)
|
.filter(m => m.global_model_id)
|
||||||
@@ -293,31 +423,129 @@ const availableModels = computed(() => {
|
|||||||
return allGlobalModels.value.filter(m => !existingGlobalModelIds.has(m.id))
|
return allGlobalModels.value.filter(m => !existingGlobalModelIds.has(m.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 全选状态
|
// 搜索过滤后的全局模型
|
||||||
const isAllLeftSelected = computed(() =>
|
const availableGlobalModels = computed(() => {
|
||||||
availableModels.value.length > 0 &&
|
if (!searchQuery.value.trim()) return availableGlobalModelsBase.value
|
||||||
selectedLeftIds.value.length === availableModels.value.length
|
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 existingModelNames = new Set<string>()
|
||||||
|
for (const m of existingModels.value) {
|
||||||
|
// 主模型名
|
||||||
|
existingModelNames.add(m.provider_model_name)
|
||||||
|
// 映射名称
|
||||||
|
for (const mapping of m.provider_model_mappings ?? []) {
|
||||||
|
if (mapping.name) existingModelNames.add(mapping.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return upstreamModels.value.filter(m => !existingModelNames.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 API_FORMAT_LABELS 的顺序排序
|
||||||
|
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 totalSelectedCount = computed(() => {
|
||||||
|
return selectedGlobalModelIds.value.length + selectedUpstreamModelIds.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全选状态
|
||||||
const isAllRightSelected = computed(() =>
|
const isAllRightSelected = computed(() =>
|
||||||
existingModels.value.length > 0 &&
|
existingModels.value.length > 0 &&
|
||||||
selectedRightIds.value.length === existingModels.value.length
|
selectedRightIds.value.length === existingModels.value.length
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 全局模型是否全选
|
||||||
|
const isAllGlobalModelsSelected = computed(() => {
|
||||||
|
if (availableGlobalModels.value.length === 0) return false
|
||||||
|
return availableGlobalModels.value.every(m => selectedGlobalModelIds.value.includes(m.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查某个上游组是否全选
|
||||||
|
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 => selectedUpstreamModelIds.value.includes(m.id))
|
||||||
|
}
|
||||||
|
|
||||||
// 监听打开状态
|
// 监听打开状态
|
||||||
watch(() => props.open, async (isOpen) => {
|
watch(() => props.open, async (isOpen) => {
|
||||||
if (isOpen && props.providerId) {
|
if (isOpen && props.providerId) {
|
||||||
await loadData()
|
await loadData()
|
||||||
} else {
|
} else {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
selectedLeftIds.value = []
|
selectedGlobalModelIds.value = []
|
||||||
|
selectedUpstreamModelIds.value = []
|
||||||
selectedRightIds.value = []
|
selectedRightIds.value = []
|
||||||
|
upstreamModels.value = []
|
||||||
|
upstreamModelsLoaded.value = false
|
||||||
|
collapsedGroups.value = new Set()
|
||||||
|
searchQuery.value = ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
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)
|
||||||
|
if (cachedModels) {
|
||||||
|
upstreamModels.value = cachedModels
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
// 折叠所有上游模型组
|
||||||
|
for (const model of cachedModels) {
|
||||||
|
if (model.api_format) {
|
||||||
|
collapsedGroups.value.add(model.api_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载全局模型列表
|
// 加载全局模型列表
|
||||||
@@ -342,13 +570,91 @@ async function loadExistingModels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换左侧选择
|
// 从提供商获取模型
|
||||||
function toggleLeftSelection(id: string) {
|
async function fetchUpstreamModels(forceRefresh = false) {
|
||||||
const index = selectedLeftIds.value.indexOf(id)
|
if (forceRefresh) {
|
||||||
if (index === -1) {
|
clearCache(props.providerId)
|
||||||
selectedLeftIds.value.push(id)
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fetchingUpstreamModels.value = true
|
||||||
|
const result = await fetchCachedModels(props.providerId, forceRefresh)
|
||||||
|
if (result) {
|
||||||
|
if (result.error) {
|
||||||
|
showError(result.error, '错误')
|
||||||
} else {
|
} else {
|
||||||
selectedLeftIds.value.splice(index, 1)
|
upstreamModels.value = result.models
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
// 折叠所有上游模型组
|
||||||
|
const allGroups = new Set(collapsedGroups.value)
|
||||||
|
for (const model of result.models) {
|
||||||
|
if (model.api_format) {
|
||||||
|
allGroups.add(model.api_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collapsedGroups.value = allGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换全局模型选择
|
||||||
|
function toggleGlobalModelSelection(id: string) {
|
||||||
|
const index = selectedGlobalModelIds.value.indexOf(id)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedGlobalModelIds.value.push(id)
|
||||||
|
} else {
|
||||||
|
selectedGlobalModelIds.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换上游模型选择
|
||||||
|
function toggleUpstreamModelSelection(id: string) {
|
||||||
|
const index = selectedUpstreamModelIds.value.indexOf(id)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedUpstreamModelIds.value.push(id)
|
||||||
|
} else {
|
||||||
|
selectedUpstreamModelIds.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选全局模型
|
||||||
|
function selectAllGlobalModels() {
|
||||||
|
const allIds = availableGlobalModels.value.map(m => m.id)
|
||||||
|
const allSelected = allIds.every(id => selectedGlobalModelIds.value.includes(id))
|
||||||
|
if (allSelected) {
|
||||||
|
selectedGlobalModelIds.value = selectedGlobalModelIds.value.filter(id => !allIds.includes(id))
|
||||||
|
} else {
|
||||||
|
const newIds = allIds.filter(id => !selectedGlobalModelIds.value.includes(id))
|
||||||
|
selectedGlobalModelIds.value.push(...newIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选某个 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 => selectedUpstreamModelIds.value.includes(id))
|
||||||
|
if (allSelected) {
|
||||||
|
selectedUpstreamModelIds.value = selectedUpstreamModelIds.value.filter(id => !allIds.includes(id))
|
||||||
|
} else {
|
||||||
|
const newIds = allIds.filter(id => !selectedUpstreamModelIds.value.includes(id))
|
||||||
|
selectedUpstreamModelIds.value.push(...newIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,15 +668,6 @@ function toggleRightSelection(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全选/取消全选左侧
|
|
||||||
function toggleSelectAllLeft() {
|
|
||||||
if (isAllLeftSelected.value) {
|
|
||||||
selectedLeftIds.value = []
|
|
||||||
} else {
|
|
||||||
selectedLeftIds.value = availableModels.value.map(m => m.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全选/取消全选右侧
|
// 全选/取消全选右侧
|
||||||
function toggleSelectAllRight() {
|
function toggleSelectAllRight() {
|
||||||
if (isAllRightSelected.value) {
|
if (isAllRightSelected.value) {
|
||||||
@@ -382,22 +679,41 @@ function toggleSelectAllRight() {
|
|||||||
|
|
||||||
// 批量添加选中的模型
|
// 批量添加选中的模型
|
||||||
async function batchAddSelected() {
|
async function batchAddSelected() {
|
||||||
if (selectedLeftIds.value.length === 0) return
|
if (totalSelectedCount.value === 0) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
submittingAdd.value = true
|
submittingAdd.value = true
|
||||||
const result = await batchAssignModelsToProvider(props.providerId, selectedLeftIds.value)
|
let totalSuccess = 0
|
||||||
|
const allErrors: string[] = []
|
||||||
if (result.success.length > 0) {
|
|
||||||
success(`成功添加 ${result.success.length} 个模型`)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 处理全局模型
|
||||||
|
if (selectedGlobalModelIds.value.length > 0) {
|
||||||
|
const result = await batchAssignModelsToProvider(props.providerId, selectedGlobalModelIds.value)
|
||||||
|
totalSuccess += result.success.length
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
const errorMessages = result.errors.map(e => e.error).join(', ')
|
allErrors.push(...result.errors.map(e => e.error))
|
||||||
showError(`部分模型添加失败: ${errorMessages}`, '警告')
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedLeftIds.value = []
|
// 处理上游模型(调用 import-from-upstream API)
|
||||||
|
if (selectedUpstreamModelIds.value.length > 0) {
|
||||||
|
const result = await importModelsFromUpstream(props.providerId, selectedUpstreamModelIds.value)
|
||||||
|
totalSuccess += result.success.length
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
allErrors.push(...result.errors.map(e => e.error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSuccess > 0) {
|
||||||
|
success(`成功添加 ${totalSuccess} 个模型`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
showError(`部分模型添加失败: ${allErrors.slice(0, 3).join(', ')}${allErrors.length > 3 ? '...' : ''}`, '警告')
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedGlobalModelIds.value = []
|
||||||
|
selectedUpstreamModelIds.value = []
|
||||||
await loadExistingModels()
|
await loadExistingModels()
|
||||||
emit('changed')
|
emit('changed')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -177,8 +177,8 @@
|
|||||||
<Label for="proxy_user">用户名(可选)</Label>
|
<Label for="proxy_user">用户名(可选)</Label>
|
||||||
<Input
|
<Input
|
||||||
:id="`proxy_user_${formId}`"
|
:id="`proxy_user_${formId}`"
|
||||||
:name="`proxy_user_${formId}`"
|
|
||||||
v-model="form.proxy_username"
|
v-model="form.proxy_username"
|
||||||
|
:name="`proxy_user_${formId}`"
|
||||||
placeholder="代理认证用户名"
|
placeholder="代理认证用户名"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
data-form-type="other"
|
data-form-type="other"
|
||||||
@@ -191,8 +191,8 @@
|
|||||||
<Label :for="`proxy_pass_${formId}`">密码(可选)</Label>
|
<Label :for="`proxy_pass_${formId}`">密码(可选)</Label>
|
||||||
<Input
|
<Input
|
||||||
:id="`proxy_pass_${formId}`"
|
:id="`proxy_pass_${formId}`"
|
||||||
:name="`proxy_pass_${formId}`"
|
|
||||||
v-model="form.proxy_password"
|
v-model="form.proxy_password"
|
||||||
|
:name="`proxy_pass_${formId}`"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="passwordPlaceholder"
|
:placeholder="passwordPlaceholder"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|||||||
@@ -116,6 +116,25 @@
|
|||||||
{{ model.global_model_name }}
|
{{ model.global_model_name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试按钮 -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 shrink-0"
|
||||||
|
title="测试模型连接"
|
||||||
|
:disabled="testingModelName === model.global_model_name"
|
||||||
|
@click.stop="testModelConnection(model)"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="testingModelName === model.global_model_name"
|
||||||
|
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>
|
||||||
@@ -148,16 +167,17 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Box, Loader2, Settings2 } from 'lucide-vue-next'
|
import { Box, Loader2, Settings2, Play } 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 } from '@/utils/errorParser'
|
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
updateEndpointKey,
|
updateEndpointKey,
|
||||||
getProviderAvailableSourceModels,
|
getProviderAvailableSourceModels,
|
||||||
|
testModel,
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type ProviderAvailableSourceModel
|
type ProviderAvailableSourceModel
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
@@ -181,6 +201,7 @@ const loadingModels = ref(false)
|
|||||||
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
||||||
const selectedModels = ref<string[]>([])
|
const selectedModels = ref<string[]>([])
|
||||||
const initialModels = ref<string[]>([])
|
const initialModels = ref<string[]>([])
|
||||||
|
const testingModelName = ref<string | null>(null)
|
||||||
|
|
||||||
// 监听对话框打开
|
// 监听对话框打开
|
||||||
watch(() => props.open, (open) => {
|
watch(() => props.open, (open) => {
|
||||||
@@ -268,6 +289,32 @@ function clearModels() {
|
|||||||
selectedModels.value = []
|
selectedModels.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 测试模型连接
|
||||||
|
async function testModelConnection(model: ProviderAvailableSourceModel) {
|
||||||
|
if (!props.providerId || !props.apiKey || testingModelName.value) return
|
||||||
|
|
||||||
|
testingModelName.value = model.global_model_name
|
||||||
|
try {
|
||||||
|
const result = await testModel({
|
||||||
|
provider_id: props.providerId,
|
||||||
|
model_name: model.provider_model_name,
|
||||||
|
api_key_id: props.apiKey.id,
|
||||||
|
message: "hello"
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
success(`模型 "${model.display_name}" 测试成功`)
|
||||||
|
} else {
|
||||||
|
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||||
|
showError(`模型测试失败: ${errorMsg}`)
|
||||||
|
} finally {
|
||||||
|
testingModelName.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function areArraysEqual(a: string[], b: string[]): boolean {
|
function areArraysEqual(a: string[], b: string[]): boolean {
|
||||||
if (a.length !== b.length) return false
|
if (a.length !== b.length) return false
|
||||||
const sortedA = [...a].sort()
|
const sortedA = [...a].sort()
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ import {
|
|||||||
updateEndpointKey,
|
updateEndpointKey,
|
||||||
getAllCapabilities,
|
getAllCapabilities,
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
|
type EndpointAPIKeyUpdate,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
type CapabilityDefinition
|
type CapabilityDefinition
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
@@ -386,10 +387,11 @@ function loadKeyData() {
|
|||||||
api_key: '',
|
api_key: '',
|
||||||
rate_multiplier: props.editingKey.rate_multiplier || 1.0,
|
rate_multiplier: props.editingKey.rate_multiplier || 1.0,
|
||||||
internal_priority: props.editingKey.internal_priority ?? 50,
|
internal_priority: props.editingKey.internal_priority ?? 50,
|
||||||
max_concurrent: props.editingKey.max_concurrent || undefined,
|
// 保留原始的 null/undefined 状态,null 表示自适应模式
|
||||||
rate_limit: props.editingKey.rate_limit || undefined,
|
max_concurrent: props.editingKey.max_concurrent ?? undefined,
|
||||||
daily_limit: props.editingKey.daily_limit || undefined,
|
rate_limit: props.editingKey.rate_limit ?? undefined,
|
||||||
monthly_limit: props.editingKey.monthly_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 || '',
|
||||||
@@ -439,12 +441,17 @@ async function handleSave() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
if (props.editingKey) {
|
if (props.editingKey) {
|
||||||
// 更新
|
// 更新模式
|
||||||
const updateData: any = {
|
// 注意:max_concurrent 需要显式发送 null 来切换到自适应模式
|
||||||
|
// undefined 会在 JSON 中被忽略,所以用 null 表示"清空/自适应"
|
||||||
|
const updateData: EndpointAPIKeyUpdate = {
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
rate_multiplier: form.value.rate_multiplier,
|
rate_multiplier: form.value.rate_multiplier,
|
||||||
internal_priority: form.value.internal_priority,
|
internal_priority: form.value.internal_priority,
|
||||||
max_concurrent: form.value.max_concurrent,
|
// 显式使用 null 表示自适应模式,这样后端能区分"未提供"和"设置为 null"
|
||||||
|
// 注意:只有 max_concurrent 需要这种处理,因为它有"自适应模式"的概念
|
||||||
|
// 其他限制字段(rate_limit 等)不支持"清空"操作,undefined 会被 JSON 忽略即不更新
|
||||||
|
max_concurrent: form.value.max_concurrent === undefined ? null : form.value.max_concurrent,
|
||||||
rate_limit: form.value.rate_limit,
|
rate_limit: form.value.rate_limit,
|
||||||
daily_limit: form.value.daily_limit,
|
daily_limit: form.value.daily_limit,
|
||||||
monthly_limit: form.value.monthly_limit,
|
monthly_limit: form.value.monthly_limit,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 别名列表 -->
|
<!-- 映射列表 -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Label class="text-sm font-medium">名称映射</Label>
|
<Label class="text-sm font-medium">名称映射</Label>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 别名输入框 -->
|
<!-- 映射输入框 -->
|
||||||
<Input
|
<Input
|
||||||
v-model="alias.name"
|
v-model="alias.name"
|
||||||
placeholder="映射名称,如 Claude-Sonnet-4.5"
|
placeholder="映射名称,如 Claude-Sonnet-4.5"
|
||||||
@@ -184,9 +184,9 @@ const editingPriorityIndex = ref<number | null>(null)
|
|||||||
// 监听 open 变化
|
// 监听 open 变化
|
||||||
watch(() => props.open, (newOpen) => {
|
watch(() => props.open, (newOpen) => {
|
||||||
if (newOpen && props.model) {
|
if (newOpen && props.model) {
|
||||||
// 加载现有别名配置
|
// 加载现有映射配置
|
||||||
if (props.model.provider_model_aliases && Array.isArray(props.model.provider_model_aliases)) {
|
if (props.model.provider_model_mappings && Array.isArray(props.model.provider_model_mappings)) {
|
||||||
aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_aliases))
|
aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_mappings))
|
||||||
} else {
|
} else {
|
||||||
aliases.value = []
|
aliases.value = []
|
||||||
}
|
}
|
||||||
@@ -197,16 +197,16 @@ watch(() => props.open, (newOpen) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加别名
|
// 添加映射
|
||||||
function addAlias() {
|
function addAlias() {
|
||||||
// 新别名优先级为当前最大优先级 + 1,或者默认为 1
|
// 新映射优先级为当前最大优先级 + 1,或者默认为 1
|
||||||
const maxPriority = aliases.value.length > 0
|
const maxPriority = aliases.value.length > 0
|
||||||
? Math.max(...aliases.value.map(a => a.priority))
|
? Math.max(...aliases.value.map(a => a.priority))
|
||||||
: 0
|
: 0
|
||||||
aliases.value.push({ name: '', priority: maxPriority + 1 })
|
aliases.value.push({ name: '', priority: maxPriority + 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除别名
|
// 移除映射
|
||||||
function removeAlias(index: number) {
|
function removeAlias(index: number) {
|
||||||
aliases.value.splice(index, 1)
|
aliases.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ function handleDrop(targetIndex: number) {
|
|||||||
const items = [...aliases.value]
|
const items = [...aliases.value]
|
||||||
const draggedItem = items[dragIndex]
|
const draggedItem = items[dragIndex]
|
||||||
|
|
||||||
// 记录每个别名的原始优先级(在修改前)
|
// 记录每个映射的原始优先级(在修改前)
|
||||||
const originalPriorityMap = new Map<number, number>()
|
const originalPriorityMap = new Map<number, number>()
|
||||||
items.forEach((alias, idx) => {
|
items.forEach((alias, idx) => {
|
||||||
originalPriorityMap.set(idx, alias.priority)
|
originalPriorityMap.set(idx, alias.priority)
|
||||||
@@ -255,7 +255,7 @@ function handleDrop(targetIndex: number) {
|
|||||||
items.splice(targetIndex, 0, draggedItem)
|
items.splice(targetIndex, 0, draggedItem)
|
||||||
|
|
||||||
// 按新顺序为每个组分配新的优先级
|
// 按新顺序为每个组分配新的优先级
|
||||||
// 同组的别名保持相同的优先级(被拖动的别名单独成组)
|
// 同组的映射保持相同的优先级(被拖动的映射单独成组)
|
||||||
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
||||||
let currentPriority = 1
|
let currentPriority = 1
|
||||||
|
|
||||||
@@ -263,12 +263,12 @@ function handleDrop(targetIndex: number) {
|
|||||||
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
|
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
|
||||||
|
|
||||||
items.forEach((alias, newIdx) => {
|
items.forEach((alias, newIdx) => {
|
||||||
// 找到这个别名在原数组中的索引
|
// 找到这个映射在原数组中的索引
|
||||||
const originalIdx = aliases.value.findIndex(a => a === alias)
|
const originalIdx = aliases.value.findIndex(a => a === alias)
|
||||||
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
||||||
|
|
||||||
if (alias === draggedItem) {
|
if (alias === draggedItem) {
|
||||||
// 被拖动的别名是独立的新组,获得当前优先级
|
// 被拖动的映射是独立的新组,获得当前优先级
|
||||||
alias.priority = currentPriority
|
alias.priority = currentPriority
|
||||||
currentPriority++
|
currentPriority++
|
||||||
} else {
|
} else {
|
||||||
@@ -318,11 +318,11 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
// 过滤掉空的别名
|
// 过滤掉空的映射
|
||||||
const validAliases = aliases.value.filter(a => a.name.trim())
|
const validAliases = aliases.value.filter(a => a.name.trim())
|
||||||
|
|
||||||
await updateModel(props.providerId, props.model.id, {
|
await updateModel(props.providerId, props.model.id, {
|
||||||
provider_model_aliases: validAliases.length > 0 ? validAliases : null
|
provider_model_mappings: validAliases.length > 0 ? validAliases : null
|
||||||
})
|
})
|
||||||
|
|
||||||
showSuccess('映射配置已保存')
|
showSuccess('映射配置已保存')
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:model-value="open"
|
||||||
|
:title="editingGroup ? '编辑模型映射' : '添加模型映射'"
|
||||||
|
:description="editingGroup ? '修改映射配置' : '为模型添加新的名称映射'"
|
||||||
|
:icon="Tag"
|
||||||
|
size="4xl"
|
||||||
|
@update:model-value="$emit('update:open', $event)"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 第一行:目标模型 | 作用域 -->
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<!-- 目标模型 -->
|
||||||
|
<div class="flex-1 space-y-1.5">
|
||||||
|
<Label class="text-xs">目标模型</Label>
|
||||||
|
<Select
|
||||||
|
v-model:open="modelSelectOpen"
|
||||||
|
:model-value="formData.modelId"
|
||||||
|
:disabled="!!editingGroup"
|
||||||
|
@update:model-value="handleModelChange"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-9">
|
||||||
|
<SelectValue placeholder="请选择模型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="model in models"
|
||||||
|
:key="model.id"
|
||||||
|
:value="model.id"
|
||||||
|
>
|
||||||
|
{{ model.global_model_display_name || model.provider_model_name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 作用域 -->
|
||||||
|
<div class="flex-1 space-y-1.5">
|
||||||
|
<Label class="text-xs">作用域 <span class="text-muted-foreground font-normal">(不选则适用全部)</span></Label>
|
||||||
|
<div
|
||||||
|
v-if="providerApiFormats.length > 0"
|
||||||
|
class="flex flex-wrap gap-1.5 p-2 rounded-md border bg-muted/30 min-h-[36px]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="format in providerApiFormats"
|
||||||
|
:key="format"
|
||||||
|
type="button"
|
||||||
|
class="px-2.5 py-0.5 rounded text-xs font-medium transition-colors"
|
||||||
|
:class="[
|
||||||
|
formData.apiFormats.includes(format)
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-background border border-border hover:bg-muted'
|
||||||
|
]"
|
||||||
|
@click="toggleApiFormat(format)"
|
||||||
|
>
|
||||||
|
{{ API_FORMAT_LABELS[format] || format }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="h-9 flex items-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
无可用格式
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第二行:两栏布局 -->
|
||||||
|
<div class="flex gap-4 items-stretch">
|
||||||
|
<!-- 左侧:上游模型列表 -->
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-medium shrink-0">
|
||||||
|
上游模型
|
||||||
|
</span>
|
||||||
|
<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="upstreamModelSearch"
|
||||||
|
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="refreshingUpstreamModels"
|
||||||
|
@click="refreshUpstreamModels"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
:class="{ 'animate-spin': refreshingUpstreamModels }"
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<template v-if="upstreamModelsLoaded">
|
||||||
|
<div
|
||||||
|
v-if="groupedAvailableUpstreamModels.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Zap class="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p class="text-sm">
|
||||||
|
{{ upstreamModelSearch ? '没有匹配的模型' : '所有模型已添加' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="p-2 space-y-2"
|
||||||
|
>
|
||||||
|
<!-- 按分组显示(可折叠) -->
|
||||||
|
<div
|
||||||
|
v-for="group in groupedAvailableUpstreamModels"
|
||||||
|
: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>
|
||||||
|
</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 hover:bg-muted/30"
|
||||||
|
:title="model.id"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1 hover:bg-primary/10 rounded transition-colors shrink-0"
|
||||||
|
title="添加到映射"
|
||||||
|
@click="addUpstreamModel(model.id)"
|
||||||
|
>
|
||||||
|
<ChevronRight class="w-4 h-4 text-muted-foreground hover:text-primary" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 未加载状态 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Zap class="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p class="text-sm">
|
||||||
|
点击右上角按钮
|
||||||
|
</p>
|
||||||
|
<p class="text-xs mt-1">
|
||||||
|
从上游获取可用模型
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:映射名称列表 -->
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
映射名称
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 hover:bg-muted rounded-md transition-colors"
|
||||||
|
title="手动添加"
|
||||||
|
@click="addAliasItem"
|
||||||
|
>
|
||||||
|
<Plus class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-if="formData.aliases.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Tag 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="(alias, index) in formData.aliases"
|
||||||
|
:key="`alias-${index}`"
|
||||||
|
class="group flex items-center gap-2 p-2 rounded-lg border transition-colors hover:bg-muted/30"
|
||||||
|
:class="[
|
||||||
|
draggedIndex === index ? 'bg-primary/5' : '',
|
||||||
|
dragOverIndex === index ? 'bg-primary/10 border-primary' : ''
|
||||||
|
]"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleDragStart(index, $event)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@dragover.prevent="handleDragOver(index)"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop(index)"
|
||||||
|
>
|
||||||
|
<!-- 删除按钮 -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1 hover:bg-destructive/10 rounded transition-colors shrink-0"
|
||||||
|
title="移除"
|
||||||
|
@click="removeAliasItem(index)"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 优先级 -->
|
||||||
|
<div class="shrink-0">
|
||||||
|
<input
|
||||||
|
v-if="editingPriorityIndex === index"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:value="alias.priority"
|
||||||
|
class="w-7 h-6 rounded bg-background border border-primary text-xs text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
autofocus
|
||||||
|
@blur="finishEditPriority(index, $event)"
|
||||||
|
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
||||||
|
@keydown.escape="cancelEditPriority"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-6 h-6 rounded bg-muted/50 flex items-center justify-center text-xs text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary"
|
||||||
|
title="点击编辑优先级"
|
||||||
|
@click.stop="startEditPriority(index)"
|
||||||
|
>
|
||||||
|
{{ alias.priority }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 名称显示/编辑 -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Input
|
||||||
|
v-if="alias.isEditing"
|
||||||
|
v-model="alias.name"
|
||||||
|
placeholder="输入映射名称"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
autofocus
|
||||||
|
@blur="alias.isEditing = false"
|
||||||
|
@keydown.enter="alias.isEditing = false"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="font-medium text-sm truncate cursor-pointer hover:text-primary"
|
||||||
|
title="点击编辑"
|
||||||
|
@click="alias.isEditing = true"
|
||||||
|
>
|
||||||
|
{{ alias.name || '点击输入名称' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 拖拽手柄 -->
|
||||||
|
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover:text-muted-foreground shrink-0">
|
||||||
|
<GripVertical class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 拖拽提示 -->
|
||||||
|
<div
|
||||||
|
v-if="formData.aliases.length > 1"
|
||||||
|
class="px-3 py-1.5 bg-muted/30 border-t text-xs text-muted-foreground text-center"
|
||||||
|
>
|
||||||
|
拖拽调整优先级顺序
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('update:open', false)"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:disabled="submitting || !formData.modelId || formData.aliases.length === 0 || !hasValidAliases"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="submitting"
|
||||||
|
class="w-4 h-4 mr-2 animate-spin"
|
||||||
|
/>
|
||||||
|
{{ editingGroup ? '保存' : '添加' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Tag, Loader2, GripVertical, Zap, Search, RefreshCw, ChevronDown, ChevronRight, ChevronLeft, Plus } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Dialog,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import {
|
||||||
|
API_FORMAT_LABELS,
|
||||||
|
type Model,
|
||||||
|
type ProviderModelAlias
|
||||||
|
} from '@/api/endpoints'
|
||||||
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
|
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||||
|
|
||||||
|
interface FormAlias {
|
||||||
|
name: string
|
||||||
|
priority: number
|
||||||
|
isEditing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AliasGroup {
|
||||||
|
model: Model
|
||||||
|
apiFormatsKey: string
|
||||||
|
apiFormats: string[]
|
||||||
|
aliases: ProviderModelAlias[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
providerId: string
|
||||||
|
providerApiFormats: string[]
|
||||||
|
models: Model[]
|
||||||
|
editingGroup?: AliasGroup | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
|
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const submitting = ref(false)
|
||||||
|
const modelSelectOpen = ref(false)
|
||||||
|
|
||||||
|
// 拖拽状态
|
||||||
|
const draggedIndex = ref<number | null>(null)
|
||||||
|
const dragOverIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
// 优先级编辑状态
|
||||||
|
const editingPriorityIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
// 快速添加(上游模型)状态
|
||||||
|
const fetchingUpstreamModels = ref(false)
|
||||||
|
const refreshingUpstreamModels = ref(false)
|
||||||
|
const upstreamModelsLoaded = ref(false)
|
||||||
|
const upstreamModels = ref<UpstreamModel[]>([])
|
||||||
|
const upstreamModelSearch = ref('')
|
||||||
|
|
||||||
|
// 分组折叠状态
|
||||||
|
const collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<{
|
||||||
|
modelId: string
|
||||||
|
apiFormats: string[]
|
||||||
|
aliases: FormAlias[]
|
||||||
|
}>({
|
||||||
|
modelId: '',
|
||||||
|
apiFormats: [],
|
||||||
|
aliases: []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查是否有有效的映射
|
||||||
|
const hasValidAliases = computed(() => {
|
||||||
|
return formData.value.aliases.some(a => a.name.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤和排序后的上游模型列表
|
||||||
|
const filteredUpstreamModels = computed(() => {
|
||||||
|
const searchText = upstreamModelSearch.value.toLowerCase().trim()
|
||||||
|
let result = [...upstreamModels.value]
|
||||||
|
|
||||||
|
result.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
if (searchText) {
|
||||||
|
const keywords = searchText.split(/\s+/).filter(k => k.length > 0)
|
||||||
|
result = result.filter(m => {
|
||||||
|
const searchableText = `${m.id} ${m.owned_by || ''} ${m.api_format || ''}`.toLowerCase()
|
||||||
|
return keywords.every(keyword => searchableText.includes(keyword))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按 API 格式分组的上游模型列表
|
||||||
|
interface UpstreamModelGroup {
|
||||||
|
api_format: string
|
||||||
|
models: Array<{ id: string; owned_by?: string; api_format?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedAvailableUpstreamModels = computed<UpstreamModelGroup[]>(() => {
|
||||||
|
// 收集当前表单已添加的名称
|
||||||
|
const addedNames = new Set(formData.value.aliases.map(a => a.name.trim()))
|
||||||
|
|
||||||
|
// 收集所有已存在的映射名称(包括主模型名和映射名称)
|
||||||
|
for (const m of props.models) {
|
||||||
|
addedNames.add(m.provider_model_name)
|
||||||
|
for (const mapping of m.provider_model_mappings ?? []) {
|
||||||
|
if (mapping.name) addedNames.add(mapping.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableModels = filteredUpstreamModels.value.filter(m => !addedNames.has(m.id))
|
||||||
|
|
||||||
|
const groups = new Map<string, UpstreamModelGroup>()
|
||||||
|
|
||||||
|
for (const model of availableModels) {
|
||||||
|
const format = model.api_format || 'UNKNOWN'
|
||||||
|
if (!groups.has(format)) {
|
||||||
|
groups.set(format, { api_format: format, models: [] })
|
||||||
|
}
|
||||||
|
groups.get(format)!.models.push(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = Object.keys(API_FORMAT_LABELS)
|
||||||
|
return Array.from(groups.values()).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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听打开状态
|
||||||
|
watch(() => props.open, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
initForm()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化表单
|
||||||
|
function initForm() {
|
||||||
|
if (props.editingGroup) {
|
||||||
|
formData.value = {
|
||||||
|
modelId: props.editingGroup.model.id,
|
||||||
|
apiFormats: [...props.editingGroup.apiFormats],
|
||||||
|
aliases: props.editingGroup.aliases.map(a => ({ name: a.name, priority: a.priority }))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formData.value = {
|
||||||
|
modelId: '',
|
||||||
|
apiFormats: [],
|
||||||
|
aliases: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 重置状态
|
||||||
|
editingPriorityIndex.value = null
|
||||||
|
draggedIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
upstreamModelSearch.value = ''
|
||||||
|
collapsedGroups.value = new Set()
|
||||||
|
|
||||||
|
// 检查缓存,如果有缓存数据则直接使用
|
||||||
|
const cachedModels = getCachedModels(props.providerId)
|
||||||
|
if (cachedModels) {
|
||||||
|
upstreamModels.value = cachedModels
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
// 默认折叠所有分组
|
||||||
|
for (const model of cachedModels) {
|
||||||
|
if (model.api_format) {
|
||||||
|
collapsedGroups.value.add(model.api_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
upstreamModelsLoaded.value = false
|
||||||
|
upstreamModels.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理模型选择变更
|
||||||
|
function handleModelChange(value: string) {
|
||||||
|
formData.value.modelId = value
|
||||||
|
const selectedModel = props.models.find(m => m.id === value)
|
||||||
|
if (selectedModel) {
|
||||||
|
upstreamModelSearch.value = selectedModel.provider_model_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换 API 格式
|
||||||
|
function toggleApiFormat(format: string) {
|
||||||
|
const index = formData.value.apiFormats.indexOf(format)
|
||||||
|
if (index >= 0) {
|
||||||
|
formData.value.apiFormats.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
formData.value.apiFormats.push(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换分组折叠状态
|
||||||
|
function toggleGroupCollapse(apiFormat: string) {
|
||||||
|
if (collapsedGroups.value.has(apiFormat)) {
|
||||||
|
collapsedGroups.value.delete(apiFormat)
|
||||||
|
} else {
|
||||||
|
collapsedGroups.value.add(apiFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加映射项
|
||||||
|
function addAliasItem() {
|
||||||
|
const maxPriority = formData.value.aliases.length > 0
|
||||||
|
? Math.max(...formData.value.aliases.map(a => a.priority))
|
||||||
|
: 0
|
||||||
|
formData.value.aliases.push({ name: '', priority: maxPriority + 1, isEditing: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除映射项
|
||||||
|
function removeAliasItem(index: number) {
|
||||||
|
formData.value.aliases.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 拖拽排序 =====
|
||||||
|
function handleDragStart(index: number, event: DragEvent) {
|
||||||
|
draggedIndex.value = index
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
draggedIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(index: number) {
|
||||||
|
if (draggedIndex.value !== null && draggedIndex.value !== index) {
|
||||||
|
dragOverIndex.value = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(targetIndex: number) {
|
||||||
|
const dragIndex = draggedIndex.value
|
||||||
|
if (dragIndex === null || dragIndex === targetIndex) {
|
||||||
|
dragOverIndex.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...formData.value.aliases]
|
||||||
|
const draggedItem = items[dragIndex]
|
||||||
|
|
||||||
|
const originalPriorityMap = new Map<number, number>()
|
||||||
|
items.forEach((alias, idx) => {
|
||||||
|
originalPriorityMap.set(idx, alias.priority)
|
||||||
|
})
|
||||||
|
|
||||||
|
items.splice(dragIndex, 1)
|
||||||
|
items.splice(targetIndex, 0, draggedItem)
|
||||||
|
|
||||||
|
const groupNewPriority = new Map<number, number>()
|
||||||
|
let currentPriority = 1
|
||||||
|
|
||||||
|
items.forEach((alias) => {
|
||||||
|
const originalIdx = formData.value.aliases.findIndex(a => a === alias)
|
||||||
|
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
||||||
|
|
||||||
|
if (alias === draggedItem) {
|
||||||
|
alias.priority = currentPriority
|
||||||
|
currentPriority++
|
||||||
|
} else {
|
||||||
|
if (groupNewPriority.has(originalPriority)) {
|
||||||
|
alias.priority = groupNewPriority.get(originalPriority)!
|
||||||
|
} else {
|
||||||
|
groupNewPriority.set(originalPriority, currentPriority)
|
||||||
|
alias.priority = currentPriority
|
||||||
|
currentPriority++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
formData.value.aliases = items
|
||||||
|
draggedIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 优先级编辑 =====
|
||||||
|
function startEditPriority(index: number) {
|
||||||
|
editingPriorityIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishEditPriority(index: number, event: FocusEvent) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const newPriority = parseInt(input.value) || 1
|
||||||
|
formData.value.aliases[index].priority = Math.max(1, newPriority)
|
||||||
|
editingPriorityIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditPriority() {
|
||||||
|
editingPriorityIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 快速添加(上游模型)=====
|
||||||
|
async function fetchUpstreamModels() {
|
||||||
|
if (!props.providerId) return
|
||||||
|
|
||||||
|
upstreamModelSearch.value = ''
|
||||||
|
fetchingUpstreamModels.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchCachedModels(props.providerId)
|
||||||
|
if (result) {
|
||||||
|
if (result.error) {
|
||||||
|
showError(result.error, '错误')
|
||||||
|
} else {
|
||||||
|
upstreamModels.value = result.models
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
// 默认折叠所有分组
|
||||||
|
for (const model of result.models) {
|
||||||
|
if (model.api_format) {
|
||||||
|
collapsedGroups.value.add(model.api_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fetchingUpstreamModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUpstreamModel(modelId: string) {
|
||||||
|
if (formData.value.aliases.some(a => a.name === modelId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPriority = formData.value.aliases.length > 0
|
||||||
|
? Math.max(...formData.value.aliases.map(a => a.priority))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
formData.value.aliases.push({ name: modelId, priority: maxPriority + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUpstreamModels() {
|
||||||
|
if (!props.providerId || refreshingUpstreamModels.value) return
|
||||||
|
|
||||||
|
refreshingUpstreamModels.value = true
|
||||||
|
clearCache(props.providerId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchCachedModels(props.providerId, true)
|
||||||
|
if (result) {
|
||||||
|
if (result.error) {
|
||||||
|
showError(result.error, '错误')
|
||||||
|
} else {
|
||||||
|
upstreamModels.value = result.models
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshingUpstreamModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成作用域唯一键
|
||||||
|
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||||
|
if (!formats || formats.length === 0) return ''
|
||||||
|
return [...formats].sort().join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (submitting.value) return
|
||||||
|
if (!formData.value.modelId || formData.value.aliases.length === 0) return
|
||||||
|
|
||||||
|
const validAliases = formData.value.aliases.filter(a => a.name.trim())
|
||||||
|
if (validAliases.length === 0) {
|
||||||
|
showError('请至少添加一个有效的映射名称', '错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const targetModel = props.models.find(m => m.id === formData.value.modelId)
|
||||||
|
if (!targetModel) {
|
||||||
|
showError('模型不存在', '错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAliases = targetModel.provider_model_mappings || []
|
||||||
|
let newAliases: ProviderModelAlias[]
|
||||||
|
|
||||||
|
const buildAlias = (a: FormAlias): ProviderModelAlias => ({
|
||||||
|
name: a.name.trim(),
|
||||||
|
priority: a.priority,
|
||||||
|
...(formData.value.apiFormats.length > 0 ? { api_formats: formData.value.apiFormats } : {})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (props.editingGroup) {
|
||||||
|
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
|
||||||
|
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
|
||||||
|
|
||||||
|
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||||
|
const currentKey = getApiFormatsKey(a.api_formats)
|
||||||
|
return !(currentKey === oldApiFormatsKey && oldAliasNames.has(a.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingNames = new Set(filteredAliases.map((a: ProviderModelAlias) => a.name))
|
||||||
|
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newAliases = [
|
||||||
|
...filteredAliases,
|
||||||
|
...validAliases.map(buildAlias)
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
const existingNames = new Set(currentAliases.map((a: ProviderModelAlias) => a.name))
|
||||||
|
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newAliases = [
|
||||||
|
...currentAliases,
|
||||||
|
...validAliases.map(buildAlias)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateModel(props.providerId, targetModel.id, {
|
||||||
|
provider_model_mappings: newAliases
|
||||||
|
})
|
||||||
|
|
||||||
|
showSuccess(props.editingGroup ? '映射组已更新' : '映射已添加')
|
||||||
|
emit('update:open', false)
|
||||||
|
emit('saved')
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -337,8 +337,40 @@
|
|||||||
{{ key.is_active ? '活跃' : '禁用' }}
|
{{ key.is_active ? '活跃' : '禁用' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] font-mono text-muted-foreground truncate">
|
<div class="flex items-center gap-1">
|
||||||
{{ key.api_key_masked }}
|
<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>
|
||||||
|
<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
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-5 w-5 shrink-0"
|
||||||
|
title="复制密钥"
|
||||||
|
@click.stop="copyFullKey(key)"
|
||||||
|
>
|
||||||
|
<Copy class="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 ml-auto shrink-0">
|
<div class="flex items-center gap-1.5 ml-auto shrink-0">
|
||||||
@@ -483,9 +515,9 @@
|
|||||||
<span
|
<span
|
||||||
v-if="key.max_concurrent || key.is_adaptive"
|
v-if="key.max_concurrent || key.is_adaptive"
|
||||||
class="text-muted-foreground"
|
class="text-muted-foreground"
|
||||||
:title="key.is_adaptive ? `自适应并发限制(学习值: ${key.learned_max_concurrent ?? '未学习'})` : '固定并发限制'"
|
:title="key.is_adaptive ? `自适应并发限制(学习值: ${key.learned_max_concurrent ?? '未学习'})` : `固定并发限制: ${key.max_concurrent}`"
|
||||||
>
|
>
|
||||||
{{ key.is_adaptive ? '自适应' : '固定' }}并发: {{ key.learned_max_concurrent || key.max_concurrent || 3 }}
|
{{ key.is_adaptive ? '自适应' : '固定' }}并发: {{ key.is_adaptive ? (key.learned_max_concurrent ?? '学习中') : key.max_concurrent }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -531,6 +563,7 @@
|
|||||||
<!-- 模型名称映射 -->
|
<!-- 模型名称映射 -->
|
||||||
<ModelAliasesTab
|
<ModelAliasesTab
|
||||||
v-if="provider"
|
v-if="provider"
|
||||||
|
ref="modelAliasesTabRef"
|
||||||
:key="`aliases-${provider.id}`"
|
:key="`aliases-${provider.id}`"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
@refresh="handleRelatedDataRefresh"
|
@refresh="handleRelatedDataRefresh"
|
||||||
@@ -653,12 +686,16 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
Layers,
|
Layers,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Copy
|
Copy,
|
||||||
|
Eye,
|
||||||
|
EyeOff
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
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 Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||||
import {
|
import {
|
||||||
KeyFormDialog,
|
KeyFormDialog,
|
||||||
@@ -678,6 +715,7 @@ import {
|
|||||||
updateEndpoint,
|
updateEndpoint,
|
||||||
updateEndpointKey,
|
updateEndpointKey,
|
||||||
batchUpdateKeyPriority,
|
batchUpdateKeyPriority,
|
||||||
|
revealEndpointKey,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type Model
|
type Model
|
||||||
@@ -704,6 +742,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { error: showError, success: showSuccess } = useToast()
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const provider = ref<any>(null)
|
const provider = ref<any>(null)
|
||||||
@@ -727,6 +766,10 @@ const recoveringEndpointId = ref<string | null>(null)
|
|||||||
const togglingEndpointId = ref<string | null>(null)
|
const togglingEndpointId = ref<string | null>(null)
|
||||||
const togglingKeyId = ref<string | null>(null)
|
const togglingKeyId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 密钥显示状态:key_id -> 完整密钥
|
||||||
|
const revealedKeys = ref<Map<string, string>>(new Map())
|
||||||
|
const revealingKeyId = ref<string | null>(null)
|
||||||
|
|
||||||
// 模型相关状态
|
// 模型相关状态
|
||||||
const modelFormDialogOpen = ref(false)
|
const modelFormDialogOpen = ref(false)
|
||||||
const editingModel = ref<Model | null>(null)
|
const editingModel = ref<Model | null>(null)
|
||||||
@@ -734,6 +777,9 @@ const deleteModelConfirmOpen = ref(false)
|
|||||||
const modelToDelete = ref<Model | null>(null)
|
const modelToDelete = ref<Model | null>(null)
|
||||||
const batchAssignDialogOpen = ref(false)
|
const batchAssignDialogOpen = ref(false)
|
||||||
|
|
||||||
|
// ModelAliasesTab 组件引用
|
||||||
|
const modelAliasesTabRef = ref<InstanceType<typeof ModelAliasesTab> | null>(null)
|
||||||
|
|
||||||
// 拖动排序相关状态
|
// 拖动排序相关状态
|
||||||
const dragState = ref({
|
const dragState = ref({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@@ -755,7 +801,9 @@ const hasBlockingDialogOpen = computed(() =>
|
|||||||
deleteKeyConfirmOpen.value ||
|
deleteKeyConfirmOpen.value ||
|
||||||
modelFormDialogOpen.value ||
|
modelFormDialogOpen.value ||
|
||||||
deleteModelConfirmOpen.value ||
|
deleteModelConfirmOpen.value ||
|
||||||
batchAssignDialogOpen.value
|
batchAssignDialogOpen.value ||
|
||||||
|
// 检测 ModelAliasesTab 子组件的 Dialog 是否打开
|
||||||
|
modelAliasesTabRef.value?.dialogOpen
|
||||||
)
|
)
|
||||||
|
|
||||||
// 监听 providerId 变化
|
// 监听 providerId 变化
|
||||||
@@ -791,6 +839,9 @@ watch(() => props.open, (newOpen) => {
|
|||||||
currentEndpoint.value = null
|
currentEndpoint.value = null
|
||||||
editingKey.value = null
|
editingKey.value = null
|
||||||
keyToDelete.value = null
|
keyToDelete.value = null
|
||||||
|
|
||||||
|
// 清除已显示的密钥(安全考虑)
|
||||||
|
revealedKeys.value.clear()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -879,6 +930,43 @@ function handleConfigKeyModels(key: EndpointAPIKey) {
|
|||||||
keyAllowedModelsDialogOpen.value = true
|
keyAllowedModelsDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换密钥显示/隐藏
|
||||||
|
async function toggleKeyReveal(key: EndpointAPIKey) {
|
||||||
|
if (revealedKeys.value.has(key.id)) {
|
||||||
|
// 已显示,隐藏它
|
||||||
|
revealedKeys.value.delete(key.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未显示,调用 API 获取完整密钥
|
||||||
|
revealingKeyId.value = key.id
|
||||||
|
try {
|
||||||
|
const result = await revealEndpointKey(key.id)
|
||||||
|
revealedKeys.value.set(key.id, result.api_key)
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||||
|
} finally {
|
||||||
|
revealingKeyId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制完整密钥
|
||||||
|
async function copyFullKey(key: EndpointAPIKey) {
|
||||||
|
// 如果已经显示了,直接复制
|
||||||
|
if (revealedKeys.value.has(key.id)) {
|
||||||
|
copyToClipboard(revealedKeys.value.get(key.id)!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则先获取再复制
|
||||||
|
try {
|
||||||
|
const result = await revealEndpointKey(key.id)
|
||||||
|
copyToClipboard(result.api_key)
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleDeleteKey(key: EndpointAPIKey) {
|
function handleDeleteKey(key: EndpointAPIKey) {
|
||||||
keyToDelete.value = key
|
keyToDelete.value = key
|
||||||
deleteKeyConfirmOpen.value = true
|
deleteKeyConfirmOpen.value = true
|
||||||
@@ -1243,16 +1331,6 @@ function getHealthScoreBarColor(score: number): string {
|
|||||||
return 'bg-red-500 dark:bg-red-400'
|
return 'bg-red-500 dark:bg-red-400'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制到剪贴板
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
showSuccess('已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败', '错误')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载 Provider 信息
|
// 加载 Provider 信息
|
||||||
async function loadProvider() {
|
async function loadProvider() {
|
||||||
if (!props.providerId) return
|
if (!props.providerId) return
|
||||||
@@ -1296,6 +1374,16 @@ async function loadEndpoints() {
|
|||||||
showError(err.response?.data?.detail || '加载端点失败', '错误')
|
showError(err.response?.data?.detail || '加载端点失败', '错误')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (props.open) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -213,6 +213,7 @@ import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image
|
|||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { getProviderModels, type Model } from '@/api/endpoints'
|
import { getProviderModels, type Model } from '@/api/endpoints'
|
||||||
import { updateModel } from '@/api/endpoints/models'
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
|
|
||||||
@@ -227,6 +228,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { error: showError, success: showSuccess } = useToast()
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -244,12 +246,7 @@ const sortedModels = computed(() => {
|
|||||||
|
|
||||||
// 复制模型 ID 到剪贴板
|
// 复制模型 ID 到剪贴板
|
||||||
async function copyModelId(modelId: string) {
|
async function copyModelId(modelId: string) {
|
||||||
try {
|
await copyToClipboard(modelId)
|
||||||
await navigator.clipboard.writeText(modelId)
|
|
||||||
showSuccess('已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败', '错误')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载模型
|
// 加载模型
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 上游模型缓存 - 共享缓存,避免重复请求
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { adminApi } from '@/api/admin'
|
||||||
|
import type { UpstreamModel } from '@/api/endpoints/types'
|
||||||
|
|
||||||
|
// 扩展类型,包含可能的额外字段
|
||||||
|
export type { UpstreamModel }
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
models: UpstreamModel[]
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchResult = { models: UpstreamModel[]; error?: string }
|
||||||
|
|
||||||
|
// 全局缓存(模块级别,所有组件共享)
|
||||||
|
const cache = new Map<string, CacheEntry>()
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000 // 5分钟
|
||||||
|
|
||||||
|
// 进行中的请求(用于去重并发请求)
|
||||||
|
const pendingRequests = new Map<string, Promise<FetchResult>>()
|
||||||
|
|
||||||
|
// 请求状态
|
||||||
|
const loadingMap = ref<Map<string, boolean>>(new Map())
|
||||||
|
|
||||||
|
export function useUpstreamModelsCache() {
|
||||||
|
/**
|
||||||
|
* 获取上游模型列表
|
||||||
|
* @param providerId 提供商ID
|
||||||
|
* @param forceRefresh 是否强制刷新
|
||||||
|
* @returns 模型列表或 null(如果请求失败)
|
||||||
|
*/
|
||||||
|
async function fetchModels(
|
||||||
|
providerId: string,
|
||||||
|
forceRefresh = false
|
||||||
|
): Promise<FetchResult> {
|
||||||
|
// 检查缓存
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = cache.get(providerId)
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return { models: cached.models }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有进行中的请求(非强制刷新时复用)
|
||||||
|
if (!forceRefresh && pendingRequests.has(providerId)) {
|
||||||
|
return pendingRequests.get(providerId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新请求
|
||||||
|
const requestPromise = (async (): Promise<FetchResult> => {
|
||||||
|
try {
|
||||||
|
loadingMap.value.set(providerId, true)
|
||||||
|
const response = await adminApi.queryProviderModels(providerId)
|
||||||
|
|
||||||
|
if (response.success && response.data?.models) {
|
||||||
|
// 存入缓存
|
||||||
|
cache.set(providerId, {
|
||||||
|
models: response.data.models,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
return { models: response.data.models }
|
||||||
|
} else {
|
||||||
|
return { models: [], error: response.data?.error || '获取上游模型失败' }
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return { models: [], error: err.response?.data?.detail || '获取上游模型失败' }
|
||||||
|
} finally {
|
||||||
|
loadingMap.value.set(providerId, false)
|
||||||
|
pendingRequests.delete(providerId)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
pendingRequests.set(providerId, requestPromise)
|
||||||
|
return requestPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的模型(不发起请求)
|
||||||
|
*/
|
||||||
|
function getCachedModels(providerId: string): UpstreamModel[] | null {
|
||||||
|
const cached = cache.get(providerId)
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.models
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定提供商的缓存
|
||||||
|
*/
|
||||||
|
function clearCache(providerId: string) {
|
||||||
|
cache.delete(providerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否正在加载
|
||||||
|
*/
|
||||||
|
function isLoading(providerId: string): boolean {
|
||||||
|
return loadingMap.value.get(providerId) || false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchModels,
|
||||||
|
getCachedModels,
|
||||||
|
clearCache,
|
||||||
|
isLoading,
|
||||||
|
loadingMap
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -472,6 +472,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Separator from '@/components/ui/separator.vue'
|
import Separator from '@/components/ui/separator.vue'
|
||||||
@@ -504,6 +506,7 @@ const copiedStates = ref<Record<string, boolean>>({})
|
|||||||
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
|
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
|
||||||
const currentExpandDepth = ref(1)
|
const currentExpandDepth = ref(1)
|
||||||
const dataSource = ref<'client' | 'provider'>('client')
|
const dataSource = ref<'client' | 'provider'>('client')
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
const historicalPricing = ref<{
|
const historicalPricing = ref<{
|
||||||
input_price: string
|
input_price: string
|
||||||
output_price: string
|
output_price: string
|
||||||
@@ -783,7 +786,7 @@ function copyJsonToClipboard(tabName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
|
copyToClipboard(JSON.stringify(data, null, 2), false)
|
||||||
copiedStates.value[tabName] = true
|
copiedStates.value[tabName] = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copiedStates.value[tabName] = false
|
copiedStates.value[tabName] = false
|
||||||
@@ -897,6 +900,16 @@ const providerHeadersWithDiff = computed(() => {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (props.isOpen) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -136,11 +136,20 @@
|
|||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 自动刷新按钮 -->
|
||||||
<RefreshButton
|
<Button
|
||||||
:loading="loading"
|
variant="ghost"
|
||||||
@click="$emit('refresh')"
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
:class="autoRefresh ? 'text-primary' : ''"
|
||||||
|
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新(每10秒刷新)'"
|
||||||
|
@click="$emit('update:autoRefresh', !autoRefresh)"
|
||||||
|
>
|
||||||
|
<RefreshCcw
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
:class="autoRefresh ? 'animate-spin' : ''"
|
||||||
/>
|
/>
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
@@ -357,14 +366,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right py-4 w-[70px]">
|
<TableCell class="text-right py-4 w-[70px]">
|
||||||
|
<!-- pending 状态:只显示增长的总时间 -->
|
||||||
<div
|
<div
|
||||||
v-if="record.status === 'pending' || record.status === 'streaming'"
|
v-if="record.status === 'pending'"
|
||||||
class="flex flex-col items-end text-xs gap-0.5"
|
class="flex flex-col items-end text-xs gap-0.5"
|
||||||
>
|
>
|
||||||
|
<span class="text-muted-foreground">-</span>
|
||||||
<span class="text-primary tabular-nums">
|
<span class="text-primary tabular-nums">
|
||||||
{{ getElapsedTime(record) }}
|
{{ getElapsedTime(record) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- streaming 状态:首字固定 + 总时间增长 -->
|
||||||
|
<div
|
||||||
|
v-else-if="record.status === 'streaming'"
|
||||||
|
class="flex flex-col items-end text-xs gap-0.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="record.first_byte_time_ms != null"
|
||||||
|
class="tabular-nums"
|
||||||
|
>{{ (record.first_byte_time_ms / 1000).toFixed(2) }}s</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>-</span>
|
||||||
|
<span class="text-primary tabular-nums">
|
||||||
|
{{ getElapsedTime(record) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 已完成状态:首字 + 总耗时 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="record.response_time_ms != null"
|
v-else-if="record.response_time_ms != null"
|
||||||
class="flex flex-col items-end text-xs gap-0.5"
|
class="flex flex-col items-end text-xs gap-0.5"
|
||||||
@@ -408,6 +437,7 @@ import { ref, computed, onUnmounted, watch } from 'vue'
|
|||||||
import {
|
import {
|
||||||
TableCard,
|
TableCard,
|
||||||
Badge,
|
Badge,
|
||||||
|
Button,
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
@@ -420,8 +450,8 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableCell,
|
TableCell,
|
||||||
Pagination,
|
Pagination,
|
||||||
RefreshButton,
|
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
|
import { RefreshCcw } 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'
|
||||||
@@ -453,6 +483,8 @@ const props = defineProps<{
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
totalRecords: number
|
totalRecords: number
|
||||||
pageSizeOptions: number[]
|
pageSizeOptions: number[]
|
||||||
|
// 自动刷新
|
||||||
|
autoRefresh: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -463,6 +495,7 @@ const emit = defineEmits<{
|
|||||||
'update:filterStatus': [value: string]
|
'update:filterStatus': [value: string]
|
||||||
'update:currentPage': [value: number]
|
'update:currentPage': [value: number]
|
||||||
'update:pageSize': [value: number]
|
'update:pageSize': [value: number]
|
||||||
|
'update:autoRefresh': [value: boolean]
|
||||||
'refresh': []
|
'refresh': []
|
||||||
'showDetail': [id: string]
|
'showDetail': [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -64,9 +64,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 +90,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 +140,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) => ({
|
||||||
@@ -305,7 +302,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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型统计
|
// 模型统计
|
||||||
@@ -115,7 +112,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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,34 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isEditMode && form.password.length > 0"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<Label class="text-sm font-medium">
|
||||||
|
确认新密码 <span class="text-muted-foreground">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
:id="`pwd-confirm-${formNonce}`"
|
||||||
|
v-model="form.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
:name="`confirm-${formNonce}`"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
class="h-10"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="form.confirmPassword.length > 0 && form.password !== form.confirmPassword"
|
||||||
|
class="text-xs text-destructive"
|
||||||
|
>
|
||||||
|
两次输入的密码不一致
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
for="form-email"
|
for="form-email"
|
||||||
@@ -288,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>
|
||||||
@@ -376,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
|
||||||
@@ -412,17 +397,17 @@ 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 }>>([])
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const form = ref({
|
const form = ref({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
email: '',
|
email: '',
|
||||||
quota: 10,
|
quota: 10,
|
||||||
role: 'user' as 'admin' | 'user',
|
role: 'user' as 'admin' | 'user',
|
||||||
@@ -443,6 +428,7 @@ function resetForm() {
|
|||||||
form.value = {
|
form.value = {
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
email: '',
|
email: '',
|
||||||
quota: 10,
|
quota: 10,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -461,6 +447,7 @@ function loadUserData() {
|
|||||||
form.value = {
|
form.value = {
|
||||||
username: props.user.username,
|
username: props.user.username,
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
email: props.user.email || '',
|
email: props.user.email || '',
|
||||||
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
||||||
role: props.user.role,
|
role: props.user.role,
|
||||||
@@ -486,7 +473,9 @@ const isFormValid = computed(() => {
|
|||||||
const hasUsername = form.value.username.trim().length > 0
|
const hasUsername = form.value.username.trim().length > 0
|
||||||
const hasEmail = form.value.email.trim().length > 0
|
const hasEmail = form.value.email.trim().length > 0
|
||||||
const hasPassword = isEditMode.value || form.value.password.length >= 6
|
const hasPassword = isEditMode.value || form.value.password.length >= 6
|
||||||
return hasUsername && hasEmail && hasPassword
|
// 编辑模式下如果填写了密码,必须确认密码一致
|
||||||
|
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
|
||||||
|
return hasUsername && hasEmail && hasPassword && passwordConfirmed
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载访问控制选项
|
// 加载访问控制选项
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ import {
|
|||||||
Megaphone,
|
Megaphone,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
|
Mail,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -421,6 +422,7 @@ const navigation = computed(() => {
|
|||||||
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
|
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
|
||||||
{ 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/system', icon: Cog },
|
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import type { User, LoginResponse } from '@/api/auth'
|
import type { User, LoginResponse } from '@/api/auth'
|
||||||
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
|
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
|
||||||
import type { User as AdminUser, ApiKey } from '@/api/users'
|
import type { User as AdminUser } from '@/api/users'
|
||||||
import type { AdminApiKeysResponse } from '@/api/admin'
|
import type { AdminApiKeysResponse } from '@/api/admin'
|
||||||
import type { Profile, UsageResponse } from '@/api/me'
|
import type { Profile, UsageResponse } from '@/api/me'
|
||||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||||
@@ -185,18 +185,20 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
|
|||||||
output: 700000,
|
output: 700000,
|
||||||
cache_creation: 50000,
|
cache_creation: 50000,
|
||||||
cache_read: 200000
|
cache_read: 200000
|
||||||
}
|
},
|
||||||
|
// 普通用户专用字段
|
||||||
|
monthly_cost: 45.67
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
|
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
|
||||||
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' },
|
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
|
||||||
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' },
|
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
|
||||||
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' },
|
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
|
||||||
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' },
|
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
|
||||||
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' },
|
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
|
||||||
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' },
|
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
|
||||||
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' },
|
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
|
||||||
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' }
|
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
|
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
|
||||||
@@ -231,11 +233,11 @@ function generateDailyStats(): DailyStatsResponse {
|
|||||||
unique_models: 8 + Math.floor(Math.random() * 5),
|
unique_models: 8 + Math.floor(Math.random() * 5),
|
||||||
unique_providers: 4 + Math.floor(Math.random() * 3),
|
unique_providers: 4 + Math.floor(Math.random() * 3),
|
||||||
model_breakdown: [
|
model_breakdown: [
|
||||||
{ model: 'claude-sonnet-4-20250514', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
|
{ model: 'claude-sonnet-4-5-20250929', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
|
||||||
{ model: 'gpt-4o', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
|
{ model: 'gpt-5.1', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
|
||||||
{ model: 'claude-opus-4-20250514', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
|
{ model: 'claude-opus-4-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
|
||||||
{ model: 'gemini-2.0-flash', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
|
{ model: 'gemini-3-pro-preview', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
|
||||||
{ model: 'claude-haiku-3-5-20241022', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
|
{ model: 'claude-haiku-4-5-20251001', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -243,11 +245,11 @@ function generateDailyStats(): DailyStatsResponse {
|
|||||||
return {
|
return {
|
||||||
daily_stats: dailyStats,
|
daily_stats: dailyStats,
|
||||||
model_summary: [
|
model_summary: [
|
||||||
{ model: 'claude-sonnet-4-20250514', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
|
{ model: 'claude-sonnet-4-5-20250929', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
|
||||||
{ model: 'gpt-4o', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
|
{ model: 'gpt-5.1', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
|
||||||
{ model: 'claude-opus-4-20250514', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
|
{ model: 'claude-opus-4-5-20251101', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
|
||||||
{ model: 'gemini-2.0-flash', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
|
{ model: 'gemini-3-pro-preview', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
|
||||||
{ model: 'claude-haiku-3-5-20241022', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
|
{ model: 'claude-haiku-4-5-20251001', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
|
||||||
],
|
],
|
||||||
period: {
|
period: {
|
||||||
start_date: dailyStats[0].date,
|
start_date: dailyStats[0].date,
|
||||||
@@ -336,7 +338,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
|||||||
|
|
||||||
// ========== API Key 数据 ==========
|
// ========== API Key 数据 ==========
|
||||||
|
|
||||||
export const MOCK_USER_API_KEYS: ApiKey[] = [
|
export const MOCK_USER_API_KEYS = [
|
||||||
{
|
{
|
||||||
id: 'key-uuid-001',
|
id: 'key-uuid-001',
|
||||||
key_display: 'sk-ae...x7f9',
|
key_display: 'sk-ae...x7f9',
|
||||||
@@ -346,7 +348,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
is_standalone: false,
|
is_standalone: false,
|
||||||
total_requests: 1234,
|
total_requests: 1234,
|
||||||
total_cost_usd: 45.67
|
total_cost_usd: 45.67,
|
||||||
|
force_capabilities: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'key-uuid-002',
|
id: 'key-uuid-002',
|
||||||
@@ -357,7 +360,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
is_standalone: false,
|
is_standalone: false,
|
||||||
total_requests: 5678,
|
total_requests: 5678,
|
||||||
total_cost_usd: 123.45
|
total_cost_usd: 123.45,
|
||||||
|
force_capabilities: { cache_1h: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'key-uuid-003',
|
id: 'key-uuid-003',
|
||||||
@@ -367,7 +371,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
|||||||
is_active: false,
|
is_active: false,
|
||||||
is_standalone: false,
|
is_standalone: false,
|
||||||
total_requests: 100,
|
total_requests: 100,
|
||||||
total_cost_usd: 2.34
|
total_cost_usd: 2.34,
|
||||||
|
force_capabilities: null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -813,16 +818,16 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
|||||||
quota_usd: 100,
|
quota_usd: 100,
|
||||||
used_usd: 45.32,
|
used_usd: 45.32,
|
||||||
summary_by_model: [
|
summary_by_model: [
|
||||||
{ model: 'claude-sonnet-4-20250514', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
|
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
|
||||||
{ model: 'gpt-4o', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
|
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
|
||||||
{ model: 'claude-haiku-3-5-20241022', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
|
{ model: 'claude-haiku-4-5-20251001', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
|
||||||
{ model: 'gemini-2.0-flash', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
|
{ model: 'gemini-3-pro-preview', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
|
||||||
],
|
],
|
||||||
records: [
|
records: [
|
||||||
{
|
{
|
||||||
id: 'usage-001',
|
id: 'usage-001',
|
||||||
provider: 'anthropic',
|
provider: 'anthropic',
|
||||||
model: 'claude-sonnet-4-20250514',
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
input_tokens: 1500,
|
input_tokens: 1500,
|
||||||
output_tokens: 800,
|
output_tokens: 800,
|
||||||
total_tokens: 2300,
|
total_tokens: 2300,
|
||||||
@@ -837,7 +842,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
|||||||
{
|
{
|
||||||
id: 'usage-002',
|
id: 'usage-002',
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
model: 'gpt-4o',
|
model: 'gpt-5.1',
|
||||||
input_tokens: 2000,
|
input_tokens: 2000,
|
||||||
output_tokens: 500,
|
output_tokens: 500,
|
||||||
total_tokens: 2500,
|
total_tokens: 2500,
|
||||||
|
|||||||
@@ -403,12 +403,12 @@ function getUsageRecords() {
|
|||||||
return cachedUsageRecords
|
return cachedUsageRecords
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock 别名数据
|
// Mock 映射数据
|
||||||
const MOCK_ALIASES = [
|
const MOCK_ALIASES = [
|
||||||
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-001', target_global_model_name: 'claude-sonnet-4-20250514', target_global_model_display_name: 'Claude Sonnet 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-003', target_global_model_name: 'claude-sonnet-4-5-20250929', target_global_model_display_name: 'Claude Sonnet 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||||
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-20250514', target_global_model_display_name: 'Claude Opus 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-5-20251101', target_global_model_display_name: 'Claude Opus 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||||
{ id: 'alias-003', source_model: 'gpt4o', target_global_model_id: 'gm-004', target_global_model_name: 'gpt-4o', target_global_model_display_name: 'GPT-4o', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
{ id: 'alias-003', source_model: 'gpt5', target_global_model_id: 'gm-006', target_global_model_name: 'gpt-5.1', target_global_model_display_name: 'GPT-5.1', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||||
{ id: 'alias-004', source_model: 'gemini-flash', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-2.0-flash', target_global_model_display_name: 'Gemini 2.0 Flash', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
|
{ id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Mock Endpoint Keys
|
// Mock Endpoint Keys
|
||||||
@@ -1682,7 +1682,7 @@ registerDynamicRoute('GET', '/api/admin/models/mappings/:mappingId', async (_con
|
|||||||
requireAdmin()
|
requireAdmin()
|
||||||
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
||||||
if (!alias) {
|
if (!alias) {
|
||||||
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
|
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
|
||||||
}
|
}
|
||||||
return createMockResponse(alias)
|
return createMockResponse(alias)
|
||||||
})
|
})
|
||||||
@@ -1693,7 +1693,7 @@ registerDynamicRoute('PATCH', '/api/admin/models/mappings/:mappingId', async (co
|
|||||||
requireAdmin()
|
requireAdmin()
|
||||||
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
||||||
if (!alias) {
|
if (!alias) {
|
||||||
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
|
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
|
||||||
}
|
}
|
||||||
const body = JSON.parse(config.data || '{}')
|
const body = JSON.parse(config.data || '{}')
|
||||||
return createMockResponse({ ...alias, ...body, updated_at: new Date().toISOString() })
|
return createMockResponse({ ...alias, ...body, updated_at: new Date().toISOString() })
|
||||||
@@ -1705,7 +1705,7 @@ registerDynamicRoute('DELETE', '/api/admin/models/mappings/:mappingId', async (_
|
|||||||
requireAdmin()
|
requireAdmin()
|
||||||
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
||||||
if (!alias) {
|
if (!alias) {
|
||||||
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
|
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
|
||||||
}
|
}
|
||||||
return createMockResponse({ message: '删除成功(演示模式)' })
|
return createMockResponse({ message: '删除成功(演示模式)' })
|
||||||
})
|
})
|
||||||
@@ -2172,10 +2172,10 @@ function generateIntervalTimelineData(
|
|||||||
|
|
||||||
// 模型列表(用于按模型区分颜色)
|
// 模型列表(用于按模型区分颜色)
|
||||||
const models = [
|
const models = [
|
||||||
'claude-sonnet-4-20250514',
|
'claude-sonnet-4-5-20250929',
|
||||||
'claude-3-5-sonnet-20241022',
|
'claude-haiku-4-5-20251001',
|
||||||
'claude-3-5-haiku-20241022',
|
'claude-opus-4-5-20251101',
|
||||||
'claude-opus-4-20250514'
|
'gpt-5.1'
|
||||||
]
|
]
|
||||||
|
|
||||||
// 生成模拟的请求间隔数据
|
// 生成模拟的请求间隔数据
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'SystemSettings',
|
name: 'SystemSettings',
|
||||||
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
|
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'email',
|
||||||
|
name: 'EmailSettings',
|
||||||
|
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'audit-logs',
|
path: 'audit-logs',
|
||||||
name: 'AuditLogs',
|
name: 'AuditLogs',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
try {
|
try {
|
||||||
users.value = await usersApi.getAllUsers()
|
users.value = await usersApi.getAllUsers()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '获取用户列表失败'
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取用户列表失败'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
users.value.push(newUser)
|
users.value.push(newUser)
|
||||||
return newUser
|
return newUser
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '创建用户失败'
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '创建用户失败'
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -52,7 +52,7 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
}
|
}
|
||||||
return updatedUser
|
return updatedUser
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '更新用户失败'
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '更新用户失败'
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -67,7 +67,7 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
await usersApi.deleteUser(userId)
|
await usersApi.deleteUser(userId)
|
||||||
users.value = users.value.filter(u => u.id !== userId)
|
users.value = users.value.filter(u => u.id !== userId)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '删除用户失败'
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '删除用户失败'
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -78,7 +78,7 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
try {
|
try {
|
||||||
return await usersApi.getUserApiKeys(userId)
|
return await usersApi.getUserApiKeys(userId)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '获取 API Keys 失败'
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取 API Keys 失败'
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
try {
|
try {
|
||||||
return await usersApi.createApiKey(userId, name)
|
return await usersApi.createApiKey(userId, name)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '创建 API Key 失败'
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '创建 API Key 失败'
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
try {
|
try {
|
||||||
await usersApi.deleteApiKey(userId, keyId)
|
await usersApi.deleteApiKey(userId, keyId)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '删除 API Key 失败'
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '删除 API Key 失败'
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
// 刷新用户列表以获取最新数据
|
// 刷新用户列表以获取最新数据
|
||||||
await fetchUsers()
|
await fetchUsers()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '重置配额失败'
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '重置配额失败'
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -1191,4 +1191,11 @@ body[theme-mode='dark'] .literary-annotation {
|
|||||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Password masking without type="password" to prevent browser autofill */
|
||||||
|
.-webkit-text-security-disc {
|
||||||
|
-webkit-text-security: disc;
|
||||||
|
-moz-text-security: disc;
|
||||||
|
text-security: disc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,3 +198,49 @@ export function parseApiErrorShort(err: unknown, defaultMessage: string = '操
|
|||||||
const lines = fullError.split('\n')
|
const lines = fullError.split('\n')
|
||||||
return lines[0] || defaultMessage
|
return lines[0] || defaultMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析模型测试响应的错误信息
|
||||||
|
* @param result 测试响应结果
|
||||||
|
* @returns 格式化的错误信息
|
||||||
|
*/
|
||||||
|
export function parseTestModelError(result: {
|
||||||
|
error?: string
|
||||||
|
data?: {
|
||||||
|
response?: {
|
||||||
|
status_code?: number
|
||||||
|
error?: string | { message?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}): string {
|
||||||
|
let errorMsg = result.error || '测试失败'
|
||||||
|
|
||||||
|
// 检查HTTP状态码错误
|
||||||
|
if (result.data?.response?.status_code) {
|
||||||
|
const status = result.data.response.status_code
|
||||||
|
if (status === 403) {
|
||||||
|
errorMsg = '认证失败: API密钥无效或客户端类型不被允许'
|
||||||
|
} else if (status === 401) {
|
||||||
|
errorMsg = '认证失败: API密钥无效或已过期'
|
||||||
|
} else if (status === 404) {
|
||||||
|
errorMsg = '模型不存在: 请检查模型名称是否正确'
|
||||||
|
} else if (status === 429) {
|
||||||
|
errorMsg = '请求频率过高: 请稍后重试'
|
||||||
|
} else if (status >= 500) {
|
||||||
|
errorMsg = `服务器错误: HTTP ${status}`
|
||||||
|
} else {
|
||||||
|
errorMsg = `请求失败: HTTP ${status}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从错误响应中提取更多信息
|
||||||
|
if (result.data?.response?.error) {
|
||||||
|
if (typeof result.data.response.error === 'string') {
|
||||||
|
errorMsg = result.data.response.error
|
||||||
|
} else if (result.data.response.error?.message) {
|
||||||
|
errorMsg = result.data.response.error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorMsg
|
||||||
|
}
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -693,6 +694,7 @@ import { log } from '@/utils/logger'
|
|||||||
|
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
const { confirmDanger } = useConfirm()
|
const { confirmDanger } = useConfirm()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
const apiKeys = ref<AdminApiKey[]>([])
|
const apiKeys = ref<AdminApiKey[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -848,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 || [],
|
||||||
@@ -927,20 +921,14 @@ function selectKey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyKey() {
|
async function copyKey() {
|
||||||
try {
|
await copyToClipboard(newKeyValue.value)
|
||||||
await navigator.clipboard.writeText(newKeyValue.value)
|
|
||||||
success('API Key 已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
error('复制失败,请手动复制')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyKeyPrefix(apiKey: AdminApiKey) {
|
async function copyKeyPrefix(apiKey: AdminApiKey) {
|
||||||
try {
|
try {
|
||||||
// 调用后端 API 获取完整密钥
|
// 调用后端 API 获取完整密钥
|
||||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||||
await navigator.clipboard.writeText(response.key)
|
await copyToClipboard(response.key)
|
||||||
success('完整密钥已复制到剪贴板')
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('复制密钥失败:', err)
|
log.error('复制密钥失败:', err)
|
||||||
error('复制失败,请重试')
|
error('复制失败,请重试')
|
||||||
@@ -1037,18 +1025,30 @@ 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,
|
||||||
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
|
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
|
||||||
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
|
allowed_providers: data.allowed_providers,
|
||||||
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
|
allowed_api_formats: data.allowed_api_formats,
|
||||||
|
allowed_models: data.allowed_models
|
||||||
}
|
}
|
||||||
await adminApi.updateApiKey(data.id, updateData)
|
await adminApi.updateApiKey(data.id, updateData)
|
||||||
success('API Key 更新成功')
|
success('API Key 更新成功')
|
||||||
@@ -1061,12 +1061,13 @@ 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,
|
||||||
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
|
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
|
||||||
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
|
allowed_providers: data.allowed_providers,
|
||||||
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
|
allowed_api_formats: data.allowed_api_formats,
|
||||||
|
allowed_models: data.allowed_models
|
||||||
}
|
}
|
||||||
const response = await adminApi.createStandaloneApiKey(createData)
|
const response = await adminApi.createStandaloneApiKey(createData)
|
||||||
newKeyValue.value = response.key
|
newKeyValue.value = response.key
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const clearingRowAffinityKey = ref<string | null>(null)
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const currentTime = ref(Math.floor(Date.now() / 1000))
|
const currentTime = ref(Math.floor(Date.now() / 1000))
|
||||||
|
const analysisHoursSelectOpen = ref(false)
|
||||||
|
|
||||||
// ==================== 模型映射缓存 ====================
|
// ==================== 模型映射缓存 ====================
|
||||||
|
|
||||||
@@ -1056,7 +1057,10 @@ onBeforeUnmount(() => {
|
|||||||
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔,推荐合适的缓存 TTL</span>
|
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔,推荐合适的缓存 TTL</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Select v-model="analysisHours">
|
<Select
|
||||||
|
v-model="analysisHours"
|
||||||
|
v-model:open="analysisHoursSelectOpen"
|
||||||
|
>
|
||||||
<SelectTrigger class="w-24 sm:w-28 h-8">
|
<SelectTrigger class="w-24 sm:w-28 h-8">
|
||||||
<SelectValue placeholder="时间段" />
|
<SelectValue placeholder="时间段" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
856
frontend/src/views/admin/EmailSettings.vue
Normal file
856
frontend/src/views/admin/EmailSettings.vue
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
<template>
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="邮件配置"
|
||||||
|
description="配置邮件发送服务和注册邮箱限制"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-6">
|
||||||
|
<!-- SMTP 邮件配置 -->
|
||||||
|
<CardSection
|
||||||
|
title="SMTP 邮件配置"
|
||||||
|
description="配置 SMTP 服务用于发送验证码邮件"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="testSmtpLoading"
|
||||||
|
@click="handleTestSmtp"
|
||||||
|
>
|
||||||
|
{{ testSmtpLoading ? '测试中...' : '测试连接' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="smtpSaveLoading"
|
||||||
|
@click="saveSmtpConfig"
|
||||||
|
>
|
||||||
|
{{ smtpSaveLoading ? '保存中...' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="smtp-host"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
SMTP 服务器地址
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp-host"
|
||||||
|
v-model="emailConfig.smtp_host"
|
||||||
|
type="text"
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
邮件服务器地址
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="smtp-port"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
SMTP 端口
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp-port"
|
||||||
|
v-model.number="emailConfig.smtp_port"
|
||||||
|
type="number"
|
||||||
|
placeholder="587"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
常用端口: 587 (TLS), 465 (SSL), 25 (无加密)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="smtp-user"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
SMTP 用户名
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp-user"
|
||||||
|
v-model="emailConfig.smtp_user"
|
||||||
|
type="text"
|
||||||
|
placeholder="your-email@example.com"
|
||||||
|
class="mt-1"
|
||||||
|
autocomplete="off"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
data-form-type="other"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
通常是您的邮箱地址
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="smtp-password"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
SMTP 密码
|
||||||
|
</Label>
|
||||||
|
<div class="relative mt-1">
|
||||||
|
<Input
|
||||||
|
id="smtp-password"
|
||||||
|
v-model="emailConfig.smtp_password"
|
||||||
|
type="text"
|
||||||
|
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
|
||||||
|
class="-webkit-text-security-disc"
|
||||||
|
:class="smtpPasswordIsSet ? 'pr-8' : ''"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
data-form-type="other"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="smtpPasswordIsSet"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="清除已保存的密码"
|
||||||
|
@click="handleClearSmtpPassword"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
邮箱密码或应用专用密码
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="smtp-from-email"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
发件人邮箱
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp-from-email"
|
||||||
|
v-model="emailConfig.smtp_from_email"
|
||||||
|
type="email"
|
||||||
|
placeholder="noreply@example.com"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
显示为发件人的邮箱地址
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="smtp-from-name"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
发件人名称
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp-from-name"
|
||||||
|
v-model="emailConfig.smtp_from_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Aether"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
显示为发件人的名称
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="smtp-encryption"
|
||||||
|
class="block text-sm font-medium mb-2"
|
||||||
|
>
|
||||||
|
加密方式
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
v-model="smtpEncryption"
|
||||||
|
v-model:open="smtpEncryptionSelectOpen"
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="smtp-encryption"
|
||||||
|
class="mt-1"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ssl">
|
||||||
|
SSL (隐式加密)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="tls">
|
||||||
|
TLS / STARTTLS
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="none">
|
||||||
|
无加密
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Gmail 等服务推荐使用 SSL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
|
|
||||||
|
<!-- 邮件模板配置 -->
|
||||||
|
<CardSection
|
||||||
|
title="邮件模板"
|
||||||
|
description="配置不同类型邮件的 HTML 模板"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="templateSaveLoading"
|
||||||
|
@click="handleSaveTemplate"
|
||||||
|
>
|
||||||
|
{{ templateSaveLoading ? '保存中...' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<!-- 模板类型选择 -->
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
v-for="tpl in templateTypes"
|
||||||
|
:key="tpl.type"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors"
|
||||||
|
:class="activeTemplateType === tpl.type
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:text-foreground'"
|
||||||
|
@click="handleTemplateTypeChange(tpl.type)"
|
||||||
|
>
|
||||||
|
{{ tpl.name }}
|
||||||
|
<span
|
||||||
|
v-if="tpl.is_custom"
|
||||||
|
class="ml-1 text-xs opacity-70"
|
||||||
|
>(已自定义)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前模板编辑区 -->
|
||||||
|
<div
|
||||||
|
v-if="currentTemplate"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<!-- 可用变量提示 -->
|
||||||
|
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
|
||||||
|
可用变量:
|
||||||
|
<code
|
||||||
|
v-for="(v, i) in currentTemplate.variables"
|
||||||
|
:key="v"
|
||||||
|
class="mx-1 px-1.5 py-0.5 bg-background rounded text-foreground"
|
||||||
|
>{{ formatVariable(v) }}<span v-if="i < currentTemplate.variables.length - 1">,</span></code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 邮件主题 -->
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="template-subject"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
邮件主题
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="template-subject"
|
||||||
|
v-model="templateSubject"
|
||||||
|
type="text"
|
||||||
|
:placeholder="currentTemplate.default_subject || '验证码'"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HTML 模板编辑 -->
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="template-html"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
HTML 模板
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="template-html"
|
||||||
|
v-model="templateHtml"
|
||||||
|
rows="16"
|
||||||
|
class="mt-1 w-full font-mono text-sm bg-muted/30 border border-border rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y"
|
||||||
|
:placeholder="currentTemplate.default_html || '<!DOCTYPE html>...'"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="previewLoading"
|
||||||
|
@click="handlePreviewTemplate"
|
||||||
|
>
|
||||||
|
{{ previewLoading ? '加载中...' : '预览' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="!currentTemplate.is_custom"
|
||||||
|
@click="handleResetTemplate"
|
||||||
|
>
|
||||||
|
重置为默认
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载中状态 -->
|
||||||
|
<div
|
||||||
|
v-else-if="templateLoading"
|
||||||
|
class="py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
正在加载模板...
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
|
|
||||||
|
<!-- 预览对话框 -->
|
||||||
|
<Dialog
|
||||||
|
v-model:open="previewDialogOpen"
|
||||||
|
no-padding
|
||||||
|
max-width="xl"
|
||||||
|
>
|
||||||
|
<!-- 自定义窗口布局 -->
|
||||||
|
<div class="flex flex-col max-h-[80vh]">
|
||||||
|
<!-- 窗口标题栏 -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/50 border-b border-border/50 flex-shrink-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex gap-1.5 group"
|
||||||
|
title="关闭"
|
||||||
|
@click="previewDialogOpen = false"
|
||||||
|
>
|
||||||
|
<div class="w-2.5 h-2.5 rounded-full bg-red-400/80 group-hover:bg-red-500" />
|
||||||
|
<div class="w-2.5 h-2.5 rounded-full bg-yellow-400/80" />
|
||||||
|
<div class="w-2.5 h-2.5 rounded-full bg-green-400/80" />
|
||||||
|
</button>
|
||||||
|
<span class="text-sm font-medium text-foreground/80">邮件预览</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground font-mono">
|
||||||
|
{{ currentTemplate?.name || '模板' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 邮件头部信息 -->
|
||||||
|
<div class="px-4 py-3 bg-muted/30 border-b border-border/30 space-y-1.5 flex-shrink-0">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-muted-foreground w-14">主题:</span>
|
||||||
|
<span class="font-medium text-foreground">{{ templateSubject || '(无主题)' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-muted-foreground w-14">收件人:</span>
|
||||||
|
<span class="text-foreground/80">example@example.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 邮件内容区域 - 直接显示邮件模板 -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<iframe
|
||||||
|
v-if="previewHtml"
|
||||||
|
ref="previewIframe"
|
||||||
|
:srcdoc="previewHtml"
|
||||||
|
class="w-full border-0"
|
||||||
|
style="min-height: 400px;"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
@load="adjustIframeHeight"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 注册邮箱限制 -->
|
||||||
|
<CardSection
|
||||||
|
title="注册邮箱限制"
|
||||||
|
description="控制允许注册的邮箱后缀,支持白名单或黑名单模式"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="emailSuffixSaveLoading"
|
||||||
|
@click="saveEmailSuffixConfig"
|
||||||
|
>
|
||||||
|
{{ emailSuffixSaveLoading ? '保存中...' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="email-suffix-mode"
|
||||||
|
class="block text-sm font-medium mb-2"
|
||||||
|
>
|
||||||
|
限制模式
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
v-model="emailConfig.email_suffix_mode"
|
||||||
|
v-model:open="emailSuffixModeSelectOpen"
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="email-suffix-mode"
|
||||||
|
class="mt-1"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">
|
||||||
|
不限制 - 允许所有邮箱
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="whitelist">
|
||||||
|
白名单 - 仅允许列出的后缀
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="blacklist">
|
||||||
|
黑名单 - 拒绝列出的后缀
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
<template v-if="emailConfig.email_suffix_mode === 'none'">
|
||||||
|
不限制邮箱后缀,所有邮箱均可注册
|
||||||
|
</template>
|
||||||
|
<template v-else-if="emailConfig.email_suffix_mode === 'whitelist'">
|
||||||
|
仅允许下方列出后缀的邮箱注册
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
拒绝下方列出后缀的邮箱注册
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="emailConfig.email_suffix_mode !== 'none'">
|
||||||
|
<Label
|
||||||
|
for="email-suffix-list"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
邮箱后缀列表
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email-suffix-list"
|
||||||
|
v-model="emailSuffixListStr"
|
||||||
|
placeholder="gmail.com, outlook.com, qq.com"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
逗号分隔,例如: gmail.com, outlook.com, qq.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import Input from '@/components/ui/input.vue'
|
||||||
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import Select from '@/components/ui/select.vue'
|
||||||
|
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||||
|
import SelectValue from '@/components/ui/select-value.vue'
|
||||||
|
import SelectContent from '@/components/ui/select-content.vue'
|
||||||
|
import SelectItem from '@/components/ui/select-item.vue'
|
||||||
|
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||||
|
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { adminApi, type EmailTemplateInfo } from '@/api/admin'
|
||||||
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
|
const { success, error } = useToast()
|
||||||
|
|
||||||
|
interface EmailConfig {
|
||||||
|
// SMTP 邮件配置
|
||||||
|
smtp_host: string | null
|
||||||
|
smtp_port: number
|
||||||
|
smtp_user: string | null
|
||||||
|
smtp_password: string | null
|
||||||
|
smtp_use_tls: boolean
|
||||||
|
smtp_use_ssl: boolean
|
||||||
|
smtp_from_email: string | null
|
||||||
|
smtp_from_name: string
|
||||||
|
// 注册邮箱限制
|
||||||
|
email_suffix_mode: 'none' | 'whitelist' | 'blacklist'
|
||||||
|
email_suffix_list: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpSaveLoading = ref(false)
|
||||||
|
const emailSuffixSaveLoading = ref(false)
|
||||||
|
const smtpEncryptionSelectOpen = ref(false)
|
||||||
|
const emailSuffixModeSelectOpen = ref(false)
|
||||||
|
const testSmtpLoading = ref(false)
|
||||||
|
const smtpPasswordIsSet = ref(false)
|
||||||
|
|
||||||
|
// 邮件模板相关状态
|
||||||
|
const templateLoading = ref(false)
|
||||||
|
const templateSaveLoading = ref(false)
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const previewDialogOpen = ref(false)
|
||||||
|
const previewHtml = ref('')
|
||||||
|
const templateTypes = ref<EmailTemplateInfo[]>([])
|
||||||
|
const activeTemplateType = ref('verification')
|
||||||
|
const templateSubject = ref('')
|
||||||
|
const templateHtml = ref('')
|
||||||
|
const previewIframe = ref<HTMLIFrameElement | null>(null)
|
||||||
|
|
||||||
|
// 当前选中的模板
|
||||||
|
const currentTemplate = computed(() => {
|
||||||
|
return templateTypes.value.find(t => t.type === activeTemplateType.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化变量显示(避免 Vue 模板中的双花括号语法冲突)
|
||||||
|
function formatVariable(name: string): string {
|
||||||
|
return `{{${name}}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整 iframe 高度以适应内容
|
||||||
|
function adjustIframeHeight() {
|
||||||
|
if (previewIframe.value) {
|
||||||
|
try {
|
||||||
|
const doc = previewIframe.value.contentDocument || previewIframe.value.contentWindow?.document
|
||||||
|
if (doc && doc.body) {
|
||||||
|
// 获取内容实际高度,添加一点余量
|
||||||
|
const height = doc.body.scrollHeight + 20
|
||||||
|
// 限制最大高度为视口的 70%
|
||||||
|
const maxHeight = window.innerHeight * 0.7
|
||||||
|
previewIframe.value.style.height = `${Math.min(height, maxHeight)}px`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 跨域限制时使用默认高度
|
||||||
|
previewIframe.value.style.height = '500px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailConfig = ref<EmailConfig>({
|
||||||
|
// SMTP 邮件配置
|
||||||
|
smtp_host: null,
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_user: null,
|
||||||
|
smtp_password: null,
|
||||||
|
smtp_use_tls: true,
|
||||||
|
smtp_use_ssl: false,
|
||||||
|
smtp_from_email: null,
|
||||||
|
smtp_from_name: 'Aether',
|
||||||
|
// 注册邮箱限制
|
||||||
|
email_suffix_mode: 'none',
|
||||||
|
email_suffix_list: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算属性:邮箱后缀列表数组和字符串之间的转换
|
||||||
|
const emailSuffixListStr = computed({
|
||||||
|
get: () => emailConfig.value.email_suffix_list.join(', '),
|
||||||
|
set: (val: string) => {
|
||||||
|
emailConfig.value.email_suffix_list = val
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim().toLowerCase())
|
||||||
|
.filter(s => s.length > 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算属性:SMTP 加密方式(ssl/tls/none)
|
||||||
|
const smtpEncryption = computed({
|
||||||
|
get: () => {
|
||||||
|
if (emailConfig.value.smtp_use_ssl) return 'ssl'
|
||||||
|
if (emailConfig.value.smtp_use_tls) return 'tls'
|
||||||
|
return 'none'
|
||||||
|
},
|
||||||
|
set: (val: string) => {
|
||||||
|
emailConfig.value.smtp_use_ssl = val === 'ssl'
|
||||||
|
emailConfig.value.smtp_use_tls = val === 'tls'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
loadEmailConfig(),
|
||||||
|
loadEmailTemplates()
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadEmailTemplates() {
|
||||||
|
templateLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await adminApi.getEmailTemplates()
|
||||||
|
templateTypes.value = response.templates
|
||||||
|
|
||||||
|
// 设置第一个模板为当前模板
|
||||||
|
if (response.templates.length > 0) {
|
||||||
|
const firstTemplate = response.templates[0]
|
||||||
|
activeTemplateType.value = firstTemplate.type
|
||||||
|
templateSubject.value = firstTemplate.subject
|
||||||
|
templateHtml.value = firstTemplate.html
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error('加载邮件模板失败')
|
||||||
|
log.error('加载邮件模板失败:', err)
|
||||||
|
} finally {
|
||||||
|
templateLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTemplateTypeChange(type: string) {
|
||||||
|
activeTemplateType.value = type
|
||||||
|
const template = templateTypes.value.find(t => t.type === type)
|
||||||
|
if (template) {
|
||||||
|
templateSubject.value = template.subject
|
||||||
|
templateHtml.value = template.html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveTemplate() {
|
||||||
|
templateSaveLoading.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.updateEmailTemplate(activeTemplateType.value, {
|
||||||
|
subject: templateSubject.value,
|
||||||
|
html: templateHtml.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新本地状态
|
||||||
|
const idx = templateTypes.value.findIndex(t => t.type === activeTemplateType.value)
|
||||||
|
if (idx !== -1) {
|
||||||
|
templateTypes.value[idx].subject = templateSubject.value
|
||||||
|
templateTypes.value[idx].html = templateHtml.value
|
||||||
|
templateTypes.value[idx].is_custom = true
|
||||||
|
}
|
||||||
|
|
||||||
|
success('模板保存成功')
|
||||||
|
} catch (err) {
|
||||||
|
error('保存模板失败')
|
||||||
|
log.error('保存模板失败:', err)
|
||||||
|
} finally {
|
||||||
|
templateSaveLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreviewTemplate() {
|
||||||
|
previewLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await adminApi.previewEmailTemplate(activeTemplateType.value, {
|
||||||
|
html: templateHtml.value
|
||||||
|
})
|
||||||
|
previewHtml.value = response.html
|
||||||
|
previewDialogOpen.value = true
|
||||||
|
} catch (err) {
|
||||||
|
error('预览模板失败')
|
||||||
|
log.error('预览模板失败:', err)
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetTemplate() {
|
||||||
|
try {
|
||||||
|
const response = await adminApi.resetEmailTemplate(activeTemplateType.value)
|
||||||
|
|
||||||
|
// 更新本地状态
|
||||||
|
const idx = templateTypes.value.findIndex(t => t.type === activeTemplateType.value)
|
||||||
|
if (idx !== -1) {
|
||||||
|
templateTypes.value[idx].subject = response.template.subject
|
||||||
|
templateTypes.value[idx].html = response.template.html
|
||||||
|
templateTypes.value[idx].is_custom = false
|
||||||
|
}
|
||||||
|
|
||||||
|
templateSubject.value = response.template.subject
|
||||||
|
templateHtml.value = response.template.html
|
||||||
|
|
||||||
|
success('模板已重置为默认值')
|
||||||
|
} catch (err) {
|
||||||
|
error('重置模板失败')
|
||||||
|
log.error('重置模板失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmailConfig() {
|
||||||
|
try {
|
||||||
|
const configs = [
|
||||||
|
// SMTP 邮件配置
|
||||||
|
'smtp_host',
|
||||||
|
'smtp_port',
|
||||||
|
'smtp_user',
|
||||||
|
'smtp_password',
|
||||||
|
'smtp_use_tls',
|
||||||
|
'smtp_use_ssl',
|
||||||
|
'smtp_from_email',
|
||||||
|
'smtp_from_name',
|
||||||
|
// 注册邮箱限制
|
||||||
|
'email_suffix_mode',
|
||||||
|
'email_suffix_list',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const key of configs) {
|
||||||
|
try {
|
||||||
|
const response = await adminApi.getSystemConfig(key)
|
||||||
|
// 特殊处理敏感字段:只记录是否已设置,不填充值
|
||||||
|
if (key === 'smtp_password') {
|
||||||
|
smtpPasswordIsSet.value = response.is_set === true
|
||||||
|
// 不设置 smtp_password 的值,保持为 null
|
||||||
|
} else if (response.value !== null && response.value !== undefined) {
|
||||||
|
(emailConfig.value as any)[key] = response.value
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 配置不存在时使用默认值,无需处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error('加载邮件配置失败')
|
||||||
|
log.error('加载邮件配置失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 SMTP 配置
|
||||||
|
async function saveSmtpConfig() {
|
||||||
|
smtpSaveLoading.value = true
|
||||||
|
try {
|
||||||
|
const configItems = [
|
||||||
|
{
|
||||||
|
key: 'smtp_host',
|
||||||
|
value: emailConfig.value.smtp_host,
|
||||||
|
description: 'SMTP 服务器地址'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smtp_port',
|
||||||
|
value: emailConfig.value.smtp_port,
|
||||||
|
description: 'SMTP 端口'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smtp_user',
|
||||||
|
value: emailConfig.value.smtp_user,
|
||||||
|
description: 'SMTP 用户名'
|
||||||
|
},
|
||||||
|
// 只有输入了新密码才提交(空值表示保持原密码)
|
||||||
|
...(emailConfig.value.smtp_password
|
||||||
|
? [{
|
||||||
|
key: 'smtp_password',
|
||||||
|
value: emailConfig.value.smtp_password,
|
||||||
|
description: 'SMTP 密码'
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: 'smtp_use_tls',
|
||||||
|
value: emailConfig.value.smtp_use_tls,
|
||||||
|
description: '是否使用 TLS 加密'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smtp_use_ssl',
|
||||||
|
value: emailConfig.value.smtp_use_ssl,
|
||||||
|
description: '是否使用 SSL 加密'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smtp_from_email',
|
||||||
|
value: emailConfig.value.smtp_from_email,
|
||||||
|
description: '发件人邮箱'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smtp_from_name',
|
||||||
|
value: emailConfig.value.smtp_from_name,
|
||||||
|
description: '发件人名称'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const promises = configItems.map(item =>
|
||||||
|
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
success('SMTP 配置已保存')
|
||||||
|
} catch (err) {
|
||||||
|
error('保存配置失败')
|
||||||
|
log.error('保存 SMTP 配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
smtpSaveLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存邮箱后缀限制配置
|
||||||
|
async function saveEmailSuffixConfig() {
|
||||||
|
emailSuffixSaveLoading.value = true
|
||||||
|
try {
|
||||||
|
const configItems = [
|
||||||
|
{
|
||||||
|
key: 'email_suffix_mode',
|
||||||
|
value: emailConfig.value.email_suffix_mode,
|
||||||
|
description: '邮箱后缀限制模式(none/whitelist/blacklist)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email_suffix_list',
|
||||||
|
value: emailConfig.value.email_suffix_list,
|
||||||
|
description: '邮箱后缀列表'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const promises = configItems.map(item =>
|
||||||
|
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
success('邮箱限制配置已保存')
|
||||||
|
} catch (err) {
|
||||||
|
error('保存配置失败')
|
||||||
|
log.error('保存邮箱限制配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
emailSuffixSaveLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除 SMTP 密码
|
||||||
|
async function handleClearSmtpPassword() {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteSystemConfig('smtp_password')
|
||||||
|
smtpPasswordIsSet.value = false
|
||||||
|
emailConfig.value.smtp_password = null
|
||||||
|
success('SMTP 密码已清除')
|
||||||
|
} catch (err) {
|
||||||
|
error('清除密码失败')
|
||||||
|
log.error('清除 SMTP 密码失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 SMTP 连接
|
||||||
|
async function handleTestSmtp() {
|
||||||
|
testSmtpLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果没有输入新密码,不发送(后端会使用数据库中的密码)
|
||||||
|
const result = await adminApi.testSmtpConnection({
|
||||||
|
smtp_host: emailConfig.value.smtp_host,
|
||||||
|
smtp_port: emailConfig.value.smtp_port,
|
||||||
|
smtp_user: emailConfig.value.smtp_user,
|
||||||
|
smtp_password: emailConfig.value.smtp_password || undefined,
|
||||||
|
smtp_use_tls: emailConfig.value.smtp_use_tls,
|
||||||
|
smtp_use_ssl: emailConfig.value.smtp_use_ssl,
|
||||||
|
smtp_from_email: emailConfig.value.smtp_from_email,
|
||||||
|
smtp_from_name: emailConfig.value.smtp_from_name
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
success('SMTP 连接测试成功')
|
||||||
|
} else {
|
||||||
|
error(result.message || '未知错误', 'SMTP 连接测试失败')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
log.error('SMTP 连接测试失败:', err)
|
||||||
|
const errMsg = err.response?.data?.detail || err.message || '未知错误'
|
||||||
|
error(errMsg, 'SMTP 连接测试失败')
|
||||||
|
} finally {
|
||||||
|
testSmtpLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -713,6 +713,7 @@ import ProviderModelFormDialog from '@/features/providers/components/ProviderMod
|
|||||||
import type { Model } from '@/api/endpoints'
|
import type { Model } from '@/api/endpoints'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
@@ -736,6 +737,7 @@ import {
|
|||||||
updateGlobalModel,
|
updateGlobalModel,
|
||||||
deleteGlobalModel,
|
deleteGlobalModel,
|
||||||
batchAssignToProviders,
|
batchAssignToProviders,
|
||||||
|
getGlobalModelProviders,
|
||||||
type GlobalModelResponse,
|
type GlobalModelResponse,
|
||||||
} from '@/api/global-models'
|
} from '@/api/global-models'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
@@ -743,6 +745,7 @@ import { getProvidersSummary } from '@/api/endpoints/providers'
|
|||||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -1066,16 +1069,6 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
|
|||||||
selectModel(model)
|
selectModel(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制到剪贴板
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
success('已复制')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectModel(model: GlobalModelResponse) {
|
async function selectModel(model: GlobalModelResponse) {
|
||||||
selectedModel.value = model
|
selectedModel.value = model
|
||||||
detailTab.value = 'basic'
|
detailTab.value = 'basic'
|
||||||
@@ -1088,18 +1081,11 @@ async function selectModel(model: GlobalModelResponse) {
|
|||||||
async function loadModelProviders(_globalModelId: string) {
|
async function loadModelProviders(_globalModelId: string) {
|
||||||
loadingModelProviders.value = true
|
loadingModelProviders.value = true
|
||||||
try {
|
try {
|
||||||
// 使用 ModelCatalog API 获取详细的关联提供商信息
|
// 使用新的 API 获取所有关联提供商(包括非活跃的)
|
||||||
const { getModelCatalog } = await import('@/api/endpoints')
|
const response = await getGlobalModelProviders(_globalModelId)
|
||||||
const catalogResponse = await getModelCatalog()
|
|
||||||
|
|
||||||
// 查找当前 GlobalModel 对应的 catalog item
|
// 转换为展示格式
|
||||||
const catalogItem = catalogResponse.models.find(
|
selectedModelProviders.value = response.providers.map(p => ({
|
||||||
m => m.global_model_name === selectedModel.value?.name
|
|
||||||
)
|
|
||||||
|
|
||||||
if (catalogItem) {
|
|
||||||
// 转换为展示格式,包含完整的模型实现信息
|
|
||||||
selectedModelProviders.value = catalogItem.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,
|
display_name: p.provider_display_name || p.provider_name,
|
||||||
@@ -1121,9 +1107,6 @@ async function loadModelProviders(_globalModelId: string) {
|
|||||||
supports_function_calling: p.supports_function_calling,
|
supports_function_calling: p.supports_function_calling,
|
||||||
supports_streaming: p.supports_streaming
|
supports_streaming: p.supports_streaming
|
||||||
}))
|
}))
|
||||||
} else {
|
|
||||||
selectedModelProviders.value = []
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
log.error('加载关联提供商失败:', err)
|
log.error('加载关联提供商失败:', err)
|
||||||
showError(parseApiError(err, '加载关联提供商失败'), '错误')
|
showError(parseApiError(err, '加载关联提供商失败'), '错误')
|
||||||
|
|||||||
@@ -723,9 +723,19 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
|||||||
// 切换提供商状态
|
// 切换提供商状态
|
||||||
async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
|
async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
|
||||||
try {
|
try {
|
||||||
await updateProvider(provider.id, { is_active: !provider.is_active })
|
const newStatus = !provider.is_active
|
||||||
provider.is_active = !provider.is_active
|
await updateProvider(provider.id, { is_active: newStatus })
|
||||||
showSuccess(provider.is_active ? '提供商已启用' : '提供商已停用')
|
|
||||||
|
// 更新抽屉内部的 provider 对象
|
||||||
|
provider.is_active = newStatus
|
||||||
|
|
||||||
|
// 同时更新主页面 providers 数组中的对象,实现无感更新
|
||||||
|
const targetProvider = providers.value.find(p => p.id === provider.id)
|
||||||
|
if (targetProvider) {
|
||||||
|
targetProvider.is_active = newStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(newStatus ? '提供商已启用' : '提供商已停用')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,7 +464,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 导入配置对话框 -->
|
<!-- 导入配置对话框 -->
|
||||||
@@ -770,7 +769,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { Download, Upload } from 'lucide-vue-next'
|
import { Download, Upload } from 'lucide-vue-next'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
|
|||||||
@@ -701,6 +701,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||||||
import { useUsersStore } from '@/stores/users'
|
import { useUsersStore } from '@/stores/users'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { usageApi, type UsageByUser } from '@/api/usage'
|
import { usageApi, type UsageByUser } from '@/api/usage'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
|
|
||||||
@@ -748,6 +749,7 @@ import { log } from '@/utils/logger'
|
|||||||
|
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
const { confirmDanger, confirmWarning } = useConfirm()
|
const { confirmDanger, confirmWarning } = useConfirm()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
const usersStore = useUsersStore()
|
const usersStore = useUsersStore()
|
||||||
|
|
||||||
// 用户表单对话框状态
|
// 用户表单对话框状态
|
||||||
@@ -875,7 +877,8 @@ async function toggleUserStatus(user: any) {
|
|||||||
const action = user.is_active ? '禁用' : '启用'
|
const action = user.is_active ? '禁用' : '启用'
|
||||||
const confirmed = await confirmDanger(
|
const confirmed = await confirmDanger(
|
||||||
`确定要${action}用户 ${user.username} 吗?`,
|
`确定要${action}用户 ${user.username} 吗?`,
|
||||||
`${action}用户`
|
`${action}用户`,
|
||||||
|
action
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -884,7 +887,7 @@ async function toggleUserStatus(user: any) {
|
|||||||
await usersStore.updateUser(user.id, { is_active: !user.is_active })
|
await usersStore.updateUser(user.id, { is_active: !user.is_active })
|
||||||
success(`用户已${action}`)
|
success(`用户已${action}`)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error(err.response?.data?.detail || '未知错误', `${action}用户失败`)
|
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', `${action}用户失败`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,7 +958,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
|||||||
closeUserFormDialog()
|
closeUserFormDialog()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const title = data.id ? '更新用户失败' : '创建用户失败'
|
const title = data.id ? '更新用户失败' : '创建用户失败'
|
||||||
error(err.response?.data?.detail || '未知错误', title)
|
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', title)
|
||||||
} finally {
|
} finally {
|
||||||
userFormDialogRef.value?.setSaving(false)
|
userFormDialogRef.value?.setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -989,7 +992,7 @@ async function createApiKey() {
|
|||||||
showNewApiKeyDialog.value = true
|
showNewApiKeyDialog.value = true
|
||||||
await loadUserApiKeys(selectedUser.value.id)
|
await loadUserApiKeys(selectedUser.value.id)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error(err.response?.data?.detail || '未知错误', '创建 API Key 失败')
|
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '创建 API Key 失败')
|
||||||
} finally {
|
} finally {
|
||||||
creatingApiKey.value = false
|
creatingApiKey.value = false
|
||||||
}
|
}
|
||||||
@@ -1000,12 +1003,7 @@ function selectApiKey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyApiKey() {
|
async function copyApiKey() {
|
||||||
try {
|
await copyToClipboard(newApiKey.value)
|
||||||
await navigator.clipboard.writeText(newApiKey.value)
|
|
||||||
success('API Key已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
error('复制失败,请手动复制')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeNewApiKeyDialog() {
|
async function closeNewApiKeyDialog() {
|
||||||
@@ -1026,7 +1024,7 @@ async function deleteApiKey(apiKey: any) {
|
|||||||
await loadUserApiKeys(selectedUser.value.id)
|
await loadUserApiKeys(selectedUser.value.id)
|
||||||
success('API Key已删除')
|
success('API Key已删除')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error(err.response?.data?.detail || '未知错误', '删除 API Key 失败')
|
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '删除 API Key 失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,11 +1032,10 @@ async function copyFullKey(apiKey: any) {
|
|||||||
try {
|
try {
|
||||||
// 调用后端 API 获取完整密钥
|
// 调用后端 API 获取完整密钥
|
||||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||||
await navigator.clipboard.writeText(response.key)
|
await copyToClipboard(response.key)
|
||||||
success('完整密钥已复制到剪贴板')
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
log.error('复制密钥失败:', err)
|
log.error('复制密钥失败:', err)
|
||||||
error(err.response?.data?.detail || '未知错误', '复制密钥失败')
|
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '复制密钥失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,7 +1051,7 @@ async function resetQuota(user: any) {
|
|||||||
await usersStore.resetUserQuota(user.id)
|
await usersStore.resetUserQuota(user.id)
|
||||||
success('配额已重置')
|
success('配额已重置')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error(err.response?.data?.detail || '未知错误', '重置配额失败')
|
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '重置配额失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,7 +1067,7 @@ async function deleteUser(user: any) {
|
|||||||
await usersStore.deleteUser(user.id)
|
await usersStore.deleteUser(user.id)
|
||||||
success('用户已删除')
|
success('用户已删除')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error(err.response?.data?.detail || '未知错误', '删除用户失败')
|
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '删除用户失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -102,9 +102,9 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="relative z-10">
|
<main class="relative z-10">
|
||||||
<!-- Fixed Logo Container -->
|
<!-- Fixed Logo Container -->
|
||||||
<div class="fixed inset-0 z-20 pointer-events-none flex items-center justify-center overflow-hidden">
|
<div class="mt-4 fixed inset-0 z-20 pointer-events-none flex items-center justify-center overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="transform-gpu logo-container"
|
class="mt-16 transform-gpu logo-container"
|
||||||
:class="[currentSection === SECTIONS.HOME ? 'home-section' : '', `logo-transition-${scrollDirection}`]"
|
:class="[currentSection === SECTIONS.HOME ? 'home-section' : '', `logo-transition-${scrollDirection}`]"
|
||||||
:style="fixedLogoStyle"
|
:style="fixedLogoStyle"
|
||||||
>
|
>
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
class="min-h-screen snap-start flex items-center justify-center px-16 lg:px-20 py-20"
|
class="min-h-screen snap-start flex items-center justify-center px-16 lg:px-20 py-20"
|
||||||
>
|
>
|
||||||
<div class="max-w-4xl mx-auto text-center">
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
<div class="h-80 w-full mb-16" />
|
<div class="h-80 w-full mb-16 mt-8" />
|
||||||
<h1
|
<h1
|
||||||
class="mb-6 text-5xl md:text-7xl font-bold text-[#191919] dark:text-white leading-tight transition-all duration-700"
|
class="mb-6 text-5xl md:text-7xl font-bold text-[#191919] dark:text-white leading-tight transition-all duration-700"
|
||||||
:style="getTitleStyle(SECTIONS.HOME)"
|
:style="getTitleStyle(SECTIONS.HOME)"
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
整合 Claude Code、Codex CLI、Gemini CLI 等多个 AI 编程助手
|
整合 Claude Code、Codex CLI、Gemini CLI 等多个 AI 编程助手
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
class="mt-16 transition-all duration-700 cursor-pointer hover:scale-110"
|
class="mt-8 transition-all duration-700 cursor-pointer hover:scale-110"
|
||||||
:style="getScrollIndicatorStyle(SECTIONS.HOME)"
|
:style="getScrollIndicatorStyle(SECTIONS.HOME)"
|
||||||
@click="scrollToSection(SECTIONS.CLAUDE)"
|
@click="scrollToSection(SECTIONS.CLAUDE)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -145,10 +145,10 @@
|
|||||||
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
实际成本
|
本月费用
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
||||||
{{ formatCurrency(costStats.total_actual_cost) }}
|
{{ formatCurrency(costStats.total_cost) }}
|
||||||
</p>
|
</p>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="costStats.cost_savings > 0"
|
v-if="costStats.cost_savings > 0"
|
||||||
@@ -162,14 +162,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 普通用户:缓存统计 -->
|
<!-- 普通用户:月度统计 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0"
|
v-else-if="!isAdmin && (hasCacheData || (userMonthlyCost !== null && userMonthlyCost > 0))"
|
||||||
class="mt-6"
|
class="mt-6"
|
||||||
>
|
>
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<h3 class="text-sm font-medium text-foreground">
|
<h3 class="text-sm font-medium text-foreground">
|
||||||
本月缓存使用
|
本月统计
|
||||||
</h3>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -178,8 +178,16 @@
|
|||||||
Monthly
|
Monthly
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
<div
|
||||||
<Card class="relative p-3 sm:p-4 border-book-cloth/30">
|
class="grid gap-2 sm:gap-3"
|
||||||
|
:class="[
|
||||||
|
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
v-if="cacheStats"
|
||||||
|
class="relative p-3 sm:p-4 border-book-cloth/30"
|
||||||
|
>
|
||||||
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
@@ -190,7 +198,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="relative p-3 sm:p-4 border-kraft/30">
|
<Card
|
||||||
|
v-if="cacheStats"
|
||||||
|
class="relative p-3 sm:p-4 border-kraft/30"
|
||||||
|
>
|
||||||
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
@@ -201,7 +212,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="relative p-3 sm:p-4 border-book-cloth/25">
|
<Card
|
||||||
|
v-if="cacheStats"
|
||||||
|
class="relative p-3 sm:p-4 border-book-cloth/25"
|
||||||
|
>
|
||||||
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
@@ -213,19 +227,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card
|
<Card
|
||||||
v-if="tokenBreakdown"
|
v-if="userMonthlyCost !== null"
|
||||||
class="relative p-3 sm:p-4 border-manilla/40"
|
class="relative p-3 sm:p-4 border-manilla/40"
|
||||||
>
|
>
|
||||||
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
总Token
|
本月费用
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
||||||
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}
|
{{ formatCurrency(userMonthlyCost) }}
|
||||||
</p>
|
|
||||||
<p class="mt-0.5 sm:mt-1 text-[9px] sm:text-[10px] text-muted-foreground">
|
|
||||||
输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -831,6 +842,12 @@ const cacheStats = ref<{
|
|||||||
total_cache_tokens: number
|
total_cache_tokens: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
const userMonthlyCost = ref<number | null>(null)
|
||||||
|
|
||||||
|
const hasCacheData = computed(() =>
|
||||||
|
cacheStats.value && cacheStats.value.total_cache_tokens > 0
|
||||||
|
)
|
||||||
|
|
||||||
const tokenBreakdown = ref<{
|
const tokenBreakdown = ref<{
|
||||||
input: number
|
input: number
|
||||||
output: number
|
output: number
|
||||||
@@ -1086,6 +1103,7 @@ async function loadDashboardData() {
|
|||||||
} else {
|
} else {
|
||||||
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
||||||
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
||||||
|
if (statsData.monthly_cost !== undefined) userMonthlyCost.value = statsData.monthly_cost
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -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 ? '请求间隔时间线' : '我的请求间隔'"
|
||||||
@@ -65,6 +67,7 @@
|
|||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:total-records="totalRecords"
|
:total-records="totalRecords"
|
||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
|
:auto-refresh="globalAutoRefresh"
|
||||||
@update:selected-period="handlePeriodChange"
|
@update:selected-period="handlePeriodChange"
|
||||||
@update:filter-user="handleFilterUserChange"
|
@update:filter-user="handleFilterUserChange"
|
||||||
@update:filter-model="handleFilterModelChange"
|
@update:filter-model="handleFilterModelChange"
|
||||||
@@ -72,6 +75,7 @@
|
|||||||
@update:filter-status="handleFilterStatusChange"
|
@update:filter-status="handleFilterStatusChange"
|
||||||
@update:current-page="handlePageChange"
|
@update:current-page="handlePageChange"
|
||||||
@update:page-size="handlePageSizeChange"
|
@update:page-size="handlePageSizeChange"
|
||||||
|
@update:auto-refresh="handleAutoRefreshChange"
|
||||||
@refresh="refreshData"
|
@refresh="refreshData"
|
||||||
@export="exportData"
|
@export="exportData"
|
||||||
@show-detail="showRequestDetail"
|
@show-detail="showRequestDetail"
|
||||||
@@ -110,8 +114,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()
|
||||||
|
|
||||||
// 判断是否是管理员页面
|
// 判断是否是管理员页面
|
||||||
@@ -142,13 +149,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) {
|
||||||
@@ -214,7 +243,10 @@ const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
|||||||
|
|
||||||
// 自动刷新定时器
|
// 自动刷新定时器
|
||||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次
|
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
||||||
|
const GLOBAL_AUTO_REFRESH_INTERVAL = 10000 // 10秒刷新一次(全局自动刷新)
|
||||||
|
const globalAutoRefresh = ref(false) // 全局自动刷新开关
|
||||||
|
|
||||||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||||||
async function pollActiveRequests() {
|
async function pollActiveRequests() {
|
||||||
@@ -227,27 +259,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) {
|
||||||
@@ -278,9 +323,35 @@ watch(hasActiveRequests, (hasActive) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 启动全局自动刷新
|
||||||
|
function startGlobalAutoRefresh() {
|
||||||
|
if (globalAutoRefreshTimer) return
|
||||||
|
globalAutoRefreshTimer = setInterval(refreshData, GLOBAL_AUTO_REFRESH_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止全局自动刷新
|
||||||
|
function stopGlobalAutoRefresh() {
|
||||||
|
if (globalAutoRefreshTimer) {
|
||||||
|
clearInterval(globalAutoRefreshTimer)
|
||||||
|
globalAutoRefreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理自动刷新开关变化
|
||||||
|
function handleAutoRefreshChange(value: boolean) {
|
||||||
|
globalAutoRefresh.value = value
|
||||||
|
if (value) {
|
||||||
|
refreshData() // 立即刷新一次
|
||||||
|
startGlobalAutoRefresh()
|
||||||
|
} else {
|
||||||
|
stopGlobalAutoRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 组件卸载时清理定时器
|
// 组件卸载时清理定时器
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
|
stopGlobalAutoRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 用户页面的前端分页
|
// 用户页面的前端分页
|
||||||
@@ -304,7 +375,22 @@ 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) {
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Table,
|
Table,
|
||||||
@@ -370,6 +371,7 @@ import { useRowClick } from '@/composables/useRowClick'
|
|||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -565,16 +567,6 @@ function hasTieredPricing(model: PublicGlobalModel): boolean {
|
|||||||
return (tiered?.tiers?.length || 0) > 1
|
return (tiered?.tiers?.length || 0) > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
success('已复制')
|
|
||||||
} catch (err) {
|
|
||||||
log.error('复制失败:', err)
|
|
||||||
showError('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshData()
|
refreshData()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -477,8 +477,8 @@ async function changePassword() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordForm.value.new_password.length < 8) {
|
if (passwordForm.value.new_password.length < 6) {
|
||||||
showError('密码长度至少8位')
|
showError('密码长度至少6位')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -350,7 +350,9 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Image as ImageIcon
|
Image as ImageIcon
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
@@ -374,6 +376,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { success: showSuccess, error: showError } = useToast()
|
const { success: showSuccess, error: showError } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
model: PublicGlobalModel | null
|
model: PublicGlobalModel | null
|
||||||
@@ -407,15 +410,6 @@ function handleClose() {
|
|||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
showSuccess('已复制')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFirstTierPrice(
|
function getFirstTierPrice(
|
||||||
tieredPricing: TieredPricingConfig | undefined | null,
|
tieredPricing: TieredPricingConfig | undefined | null,
|
||||||
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
|
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
|
||||||
@@ -453,6 +447,16 @@ function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | n
|
|||||||
if (!tieredPricing?.tiers?.length) return '-'
|
if (!tieredPricing?.tiers?.length) return '-'
|
||||||
return get1hCachePrice(tieredPricing.tiers[0])
|
return get1hCachePrice(tieredPricing.tiers[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (props.open) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ authors = [
|
|||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: Other/Proprietary License",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -215,6 +257,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 +269,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 +316,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 +334,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 更新
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ async def get_keys_grouped_by_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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/keys/{key_id}/reveal")
|
||||||
|
async def reveal_endpoint_key(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""获取完整的 API Key(用于查看和复制)"""
|
||||||
|
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/keys/{key_id}")
|
@router.delete("/keys/{key_id}")
|
||||||
async def delete_endpoint_key(
|
async def delete_endpoint_key(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
@@ -246,6 +257,15 @@ 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"
|
||||||
|
# 当 max_concurrent 被显式设置时(在 model_fields_set 中),即使值为 None 也应该更新
|
||||||
|
if "max_concurrent" in self.key_data.model_fields_set:
|
||||||
|
update_data["max_concurrent"] = self.key_data.max_concurrent
|
||||||
|
# 切换到自适应模式时,清空学习到的并发限制,让系统重新学习
|
||||||
|
if self.key_data.max_concurrent is None:
|
||||||
|
update_data["learned_max_concurrent"] = None
|
||||||
|
logger.info("Key %s 切换为自适应并发模式", self.key_id)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(key, field, value)
|
setattr(key, field, value)
|
||||||
key.updated_at = datetime.now(timezone.utc)
|
key.updated_at = datetime.now(timezone.utc)
|
||||||
@@ -253,7 +273,7 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(key)
|
db.refresh(key)
|
||||||
|
|
||||||
logger.info(f"[OK] 更新 Key: ID={self.key_id}, Updates={list(update_data.keys())}")
|
logger.info("[OK] 更新 Key: ID=%s, Updates=%s", self.key_id, list(update_data.keys()))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||||
@@ -284,6 +304,30 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
|||||||
return EndpointAPIKeyResponse(**response_dict)
|
return EndpointAPIKeyResponse(**response_dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
|
||||||
|
"""获取完整的 API Key(用于查看和复制)"""
|
||||||
|
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
|
||||||
|
raise InvalidRequestException(
|
||||||
|
"无法解密 API Key,可能是加密密钥已更改。请重新添加该密钥。"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[REVEAL] 查看完整 Key: ID={self.key_id}, Name={key.name}")
|
||||||
|
return {"api_key": decrypted_key}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
||||||
key_id: str
|
key_id: str
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ 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,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ GlobalModel Admin API
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -19,9 +19,11 @@ from src.models.pydantic_models import (
|
|||||||
BatchAssignToProvidersResponse,
|
BatchAssignToProvidersResponse,
|
||||||
GlobalModelCreate,
|
GlobalModelCreate,
|
||||||
GlobalModelListResponse,
|
GlobalModelListResponse,
|
||||||
|
GlobalModelProvidersResponse,
|
||||||
GlobalModelResponse,
|
GlobalModelResponse,
|
||||||
GlobalModelUpdate,
|
GlobalModelUpdate,
|
||||||
GlobalModelWithStats,
|
GlobalModelWithStats,
|
||||||
|
ModelCatalogProviderDetail,
|
||||||
)
|
)
|
||||||
from src.services.model.global_model import GlobalModelService
|
from src.services.model.global_model import GlobalModelService
|
||||||
|
|
||||||
@@ -108,6 +110,17 @@ async def batch_assign_to_providers(
|
|||||||
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("/{global_model_id}/providers", response_model=GlobalModelProvidersResponse)
|
||||||
|
async def get_global_model_providers(
|
||||||
|
request: Request,
|
||||||
|
global_model_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> GlobalModelProvidersResponse:
|
||||||
|
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
|
||||||
|
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
# ========== Adapters ==========
|
# ========== Adapters ==========
|
||||||
|
|
||||||
|
|
||||||
@@ -133,20 +146,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(
|
||||||
@@ -275,3 +293,61 @@ class AdminBatchAssignToProvidersAdapter(AdminApiAdapter):
|
|||||||
logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}")
|
logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}")
|
||||||
|
|
||||||
return BatchAssignToProvidersResponse(**result)
|
return BatchAssignToProvidersResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminGetGlobalModelProvidersAdapter(AdminApiAdapter):
|
||||||
|
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
|
||||||
|
|
||||||
|
global_model_id: str
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from src.models.database import Model
|
||||||
|
|
||||||
|
global_model = GlobalModelService.get_global_model(context.db, self.global_model_id)
|
||||||
|
|
||||||
|
# 获取所有关联的 Model(包括非活跃的)
|
||||||
|
models = (
|
||||||
|
context.db.query(Model)
|
||||||
|
.options(joinedload(Model.provider), joinedload(Model.global_model))
|
||||||
|
.filter(Model.global_model_id == global_model.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_entries = []
|
||||||
|
for model in models:
|
||||||
|
provider = model.provider
|
||||||
|
if not provider:
|
||||||
|
continue
|
||||||
|
|
||||||
|
effective_tiered = model.get_effective_tiered_pricing()
|
||||||
|
tier_count = len(effective_tiered.get("tiers", [])) if effective_tiered else 1
|
||||||
|
|
||||||
|
provider_entries.append(
|
||||||
|
ModelCatalogProviderDetail(
|
||||||
|
provider_id=provider.id,
|
||||||
|
provider_name=provider.name,
|
||||||
|
provider_display_name=provider.display_name,
|
||||||
|
model_id=model.id,
|
||||||
|
target_model=model.provider_model_name,
|
||||||
|
input_price_per_1m=model.get_effective_input_price(),
|
||||||
|
output_price_per_1m=model.get_effective_output_price(),
|
||||||
|
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
|
||||||
|
cache_read_price_per_1m=model.get_effective_cache_read_price(),
|
||||||
|
cache_1h_creation_price_per_1m=model.get_effective_1h_cache_creation_price(),
|
||||||
|
price_per_request=model.get_effective_price_per_request(),
|
||||||
|
effective_tiered_pricing=effective_tiered,
|
||||||
|
tier_count=tier_count,
|
||||||
|
supports_vision=model.get_effective_supports_vision(),
|
||||||
|
supports_function_calling=model.get_effective_supports_function_calling(),
|
||||||
|
supports_streaming=model.get_effective_supports_streaming(),
|
||||||
|
is_active=bool(model.is_active),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return GlobalModelProvidersResponse(
|
||||||
|
providers=provider_entries,
|
||||||
|
total=len(provider_entries),
|
||||||
|
)
|
||||||
|
|||||||
@@ -947,7 +947,7 @@ class AdminClearProviderCacheAdapter(AdminApiAdapter):
|
|||||||
class AdminCacheConfigAdapter(AdminApiAdapter):
|
class AdminCacheConfigAdapter(AdminApiAdapter):
|
||||||
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
|
||||||
from src.services.cache.affinity_manager import CacheAffinityManager
|
from src.services.cache.affinity_manager import CacheAffinityManager
|
||||||
from src.services.cache.aware_scheduler import CacheAwareScheduler
|
from src.config.constants import ConcurrencyDefaults
|
||||||
from src.services.rate_limit.adaptive_reservation import get_adaptive_reservation_manager
|
from src.services.rate_limit.adaptive_reservation import get_adaptive_reservation_manager
|
||||||
|
|
||||||
# 获取动态预留管理器的配置
|
# 获取动态预留管理器的配置
|
||||||
@@ -958,7 +958,7 @@ class AdminCacheConfigAdapter(AdminApiAdapter):
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
"cache_ttl_seconds": CacheAffinityManager.DEFAULT_CACHE_TTL,
|
"cache_ttl_seconds": CacheAffinityManager.DEFAULT_CACHE_TTL,
|
||||||
"cache_reservation_ratio": CacheAwareScheduler.CACHE_RESERVATION_RATIO,
|
"cache_reservation_ratio": ConcurrencyDefaults.CACHE_RESERVATION_RATIO,
|
||||||
"dynamic_reservation": {
|
"dynamic_reservation": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"config": reservation_stats["config"],
|
"config": reservation_stats["config"],
|
||||||
@@ -981,7 +981,7 @@ class AdminCacheConfigAdapter(AdminApiAdapter):
|
|||||||
context.add_audit_metadata(
|
context.add_audit_metadata(
|
||||||
action="cache_config",
|
action="cache_config",
|
||||||
cache_ttl_seconds=CacheAffinityManager.DEFAULT_CACHE_TTL,
|
cache_ttl_seconds=CacheAffinityManager.DEFAULT_CACHE_TTL,
|
||||||
cache_reservation_ratio=CacheAwareScheduler.CACHE_RESERVATION_RATIO,
|
cache_reservation_ratio=ConcurrencyDefaults.CACHE_RESERVATION_RATIO,
|
||||||
dynamic_reservation_enabled=True,
|
dynamic_reservation_enabled=True,
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
@@ -1167,14 +1167,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
|||||||
provider.display_name or provider.name
|
provider.display_name or provider.name
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
# 检查是否在别名列表中
|
# 检查是否在映射列表中
|
||||||
if model.provider_model_aliases:
|
if model.provider_model_mappings:
|
||||||
alias_names = [
|
mapping_list = [
|
||||||
a.get("name")
|
a.get("name")
|
||||||
for a in model.provider_model_aliases
|
for a in model.provider_model_mappings
|
||||||
if isinstance(a, dict)
|
if isinstance(a, dict)
|
||||||
]
|
]
|
||||||
if mapping_name in alias_names:
|
if mapping_name in mapping_list:
|
||||||
provider_names.append(
|
provider_names.append(
|
||||||
provider.display_name or provider.name
|
provider.display_name or provider.name
|
||||||
)
|
)
|
||||||
@@ -1236,19 +1236,19 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
|||||||
try:
|
try:
|
||||||
cached_data = json.loads(cached_str)
|
cached_data = json.loads(cached_str)
|
||||||
provider_model_name = cached_data.get("provider_model_name")
|
provider_model_name = cached_data.get("provider_model_name")
|
||||||
provider_model_aliases = cached_data.get("provider_model_aliases", [])
|
cached_model_mappings = cached_data.get("provider_model_mappings", [])
|
||||||
|
|
||||||
# 获取 Provider 和 GlobalModel 信息
|
# 获取 Provider 和 GlobalModel 信息
|
||||||
provider = provider_map.get(provider_id)
|
provider = provider_map.get(provider_id)
|
||||||
global_model = global_model_map.get(global_model_id)
|
global_model = global_model_map.get(global_model_id)
|
||||||
|
|
||||||
if provider and global_model:
|
if provider and global_model:
|
||||||
# 提取别名名称
|
# 提取映射名称
|
||||||
alias_names = []
|
mapping_names = []
|
||||||
if provider_model_aliases:
|
if cached_model_mappings:
|
||||||
for alias_entry in provider_model_aliases:
|
for mapping_entry in cached_model_mappings:
|
||||||
if isinstance(alias_entry, dict) and alias_entry.get("name"):
|
if isinstance(mapping_entry, dict) and mapping_entry.get("name"):
|
||||||
alias_names.append(alias_entry["name"])
|
mapping_names.append(mapping_entry["name"])
|
||||||
|
|
||||||
# provider_model_name 为空时跳过
|
# provider_model_name 为空时跳过
|
||||||
if not provider_model_name:
|
if not provider_model_name:
|
||||||
@@ -1256,14 +1256,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
# 只显示有实际映射的条目:
|
# 只显示有实际映射的条目:
|
||||||
# 1. 全局模型名 != Provider 模型名(模型名称映射)
|
# 1. 全局模型名 != Provider 模型名(模型名称映射)
|
||||||
# 2. 或者有别名配置
|
# 2. 或者有映射配置
|
||||||
has_name_mapping = global_model.name != provider_model_name
|
has_name_mapping = global_model.name != provider_model_name
|
||||||
has_aliases = len(alias_names) > 0
|
has_mappings = len(mapping_names) > 0
|
||||||
|
|
||||||
if has_name_mapping or has_aliases:
|
if has_name_mapping or has_mappings:
|
||||||
# 构建用于展示的别名列表
|
# 构建用于展示的映射列表
|
||||||
# 如果只有名称映射没有别名,则用 global_model_name 作为"请求名称"
|
# 如果只有名称映射没有额外映射,则用 global_model_name 作为"请求名称"
|
||||||
display_aliases = alias_names if alias_names else [global_model.name]
|
display_mappings = mapping_names if mapping_names else [global_model.name]
|
||||||
|
|
||||||
provider_model_mappings.append({
|
provider_model_mappings.append({
|
||||||
"provider_id": provider_id,
|
"provider_id": provider_id,
|
||||||
@@ -1272,7 +1272,7 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
|||||||
"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,
|
||||||
"provider_model_name": provider_model_name,
|
"provider_model_name": provider_model_name,
|
||||||
"aliases": display_aliases,
|
"aliases": display_mappings,
|
||||||
"ttl": ttl if ttl > 0 else None,
|
"ttl": ttl if ttl > 0 else None,
|
||||||
"hit_count": hit_count,
|
"hit_count": hit_count,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
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.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
|
||||||
@@ -30,145 +32,33 @@ class ModelsQueryRequest(BaseModel):
|
|||||||
api_key_id: Optional[str] = None
|
api_key_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelRequest(BaseModel):
|
||||||
|
"""模型测试请求"""
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
model_name: str
|
||||||
|
api_key_id: Optional[str] = None
|
||||||
|
stream: bool = False
|
||||||
|
message: Optional[str] = "你好"
|
||||||
|
api_format: Optional[str] = None # 指定使用的API格式,如果不指定则使用端点的默认格式
|
||||||
|
|
||||||
|
|
||||||
# ============ API Endpoints ============
|
# ============ API Endpoints ============
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_openai_models(
|
def _get_adapter_for_format(api_format: str):
|
||||||
client: httpx.AsyncClient,
|
"""根据 API 格式获取对应的 Adapter 类"""
|
||||||
base_url: str,
|
# 先检查 Chat Adapter 注册表
|
||||||
api_key: str,
|
adapter_class = get_adapter_class(api_format)
|
||||||
api_format: str,
|
if adapter_class:
|
||||||
extra_headers: Optional[dict] = None,
|
return adapter_class
|
||||||
) -> tuple[list, Optional[str]]:
|
|
||||||
"""获取 OpenAI 格式的模型列表
|
|
||||||
|
|
||||||
Returns:
|
# 再检查 CLI Adapter 注册表
|
||||||
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
cli_adapter_class = get_cli_adapter_class(api_format)
|
||||||
"""
|
if cli_adapter_class:
|
||||||
headers = {"Authorization": f"Bearer {api_key}"}
|
return cli_adapter_class
|
||||||
if extra_headers:
|
|
||||||
# 防止 extra_headers 覆盖 Authorization
|
|
||||||
safe_headers = {k: v for k, v in extra_headers.items() if k.lower() != "authorization"}
|
|
||||||
headers.update(safe_headers)
|
|
||||||
|
|
||||||
# 构建 /v1/models URL
|
return None
|
||||||
if base_url.endswith("/v1"):
|
|
||||||
models_url = f"{base_url}/models"
|
|
||||||
else:
|
|
||||||
models_url = f"{base_url}/v1/models"
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.get(models_url, headers=headers)
|
|
||||||
logger.debug(f"OpenAI models request to {models_url}: status={response.status_code}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
models = []
|
|
||||||
if "data" in data:
|
|
||||||
models = data["data"]
|
|
||||||
elif isinstance(data, list):
|
|
||||||
models = data
|
|
||||||
# 为每个模型添加 api_format 字段
|
|
||||||
for m in models:
|
|
||||||
m["api_format"] = api_format
|
|
||||||
return models, None
|
|
||||||
else:
|
|
||||||
# 记录详细的错误信息
|
|
||||||
error_body = response.text[:500] if response.text else "(empty)"
|
|
||||||
error_msg = f"HTTP {response.status_code}: {error_body}"
|
|
||||||
logger.warning(f"OpenAI models request to {models_url} failed: {error_msg}")
|
|
||||||
return [], error_msg
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Request error: {str(e)}"
|
|
||||||
logger.warning(f"Failed to fetch models from {models_url}: {e}")
|
|
||||||
return [], error_msg
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_claude_models(
|
|
||||||
client: httpx.AsyncClient, base_url: str, api_key: str, api_format: str
|
|
||||||
) -> tuple[list, Optional[str]]:
|
|
||||||
"""获取 Claude 格式的模型列表
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
|
||||||
"""
|
|
||||||
headers = {
|
|
||||||
"x-api-key": api_key,
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"anthropic-version": "2023-06-01",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 构建 /v1/models URL
|
|
||||||
if base_url.endswith("/v1"):
|
|
||||||
models_url = f"{base_url}/models"
|
|
||||||
else:
|
|
||||||
models_url = f"{base_url}/v1/models"
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.get(models_url, headers=headers)
|
|
||||||
logger.debug(f"Claude models request to {models_url}: status={response.status_code}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
models = []
|
|
||||||
if "data" in data:
|
|
||||||
models = data["data"]
|
|
||||||
elif isinstance(data, list):
|
|
||||||
models = data
|
|
||||||
# 为每个模型添加 api_format 字段
|
|
||||||
for m in models:
|
|
||||||
m["api_format"] = api_format
|
|
||||||
return models, None
|
|
||||||
else:
|
|
||||||
error_body = response.text[:500] if response.text else "(empty)"
|
|
||||||
error_msg = f"HTTP {response.status_code}: {error_body}"
|
|
||||||
logger.warning(f"Claude models request to {models_url} failed: {error_msg}")
|
|
||||||
return [], error_msg
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Request error: {str(e)}"
|
|
||||||
logger.warning(f"Failed to fetch Claude models from {models_url}: {e}")
|
|
||||||
return [], error_msg
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_gemini_models(
|
|
||||||
client: httpx.AsyncClient, base_url: str, api_key: str, api_format: str
|
|
||||||
) -> tuple[list, Optional[str]]:
|
|
||||||
"""获取 Gemini 格式的模型列表
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
|
||||||
"""
|
|
||||||
# 兼容 base_url 已包含 /v1beta 的情况
|
|
||||||
base_url_clean = base_url.rstrip("/")
|
|
||||||
if base_url_clean.endswith("/v1beta"):
|
|
||||||
models_url = f"{base_url_clean}/models?key={api_key}"
|
|
||||||
else:
|
|
||||||
models_url = f"{base_url_clean}/v1beta/models?key={api_key}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.get(models_url)
|
|
||||||
logger.debug(f"Gemini models request to {models_url}: status={response.status_code}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
if "models" in data:
|
|
||||||
# 转换为统一格式
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": m.get("name", "").replace("models/", ""),
|
|
||||||
"owned_by": "google",
|
|
||||||
"display_name": m.get("displayName", ""),
|
|
||||||
"api_format": api_format,
|
|
||||||
}
|
|
||||||
for m in data["models"]
|
|
||||||
], None
|
|
||||||
return [], None
|
|
||||||
else:
|
|
||||||
error_body = response.text[:500] if response.text else "(empty)"
|
|
||||||
error_msg = f"HTTP {response.status_code}: {error_body}"
|
|
||||||
logger.warning(f"Gemini models request to {models_url} failed: {error_msg}")
|
|
||||||
return [], error_msg
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Request error: {str(e)}"
|
|
||||||
logger.warning(f"Failed to fetch Gemini models from {models_url}: {e}")
|
|
||||||
return [], error_msg
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/models")
|
@router.post("/models")
|
||||||
@@ -180,10 +70,10 @@ async def query_available_models(
|
|||||||
"""
|
"""
|
||||||
查询提供商可用模型
|
查询提供商可用模型
|
||||||
|
|
||||||
遍历所有活跃端点,根据端点的 API 格式选择正确的请求方式:
|
遍历所有活跃端点,根据端点的 API 格式选择正确的 Adapter 进行请求:
|
||||||
- OPENAI/OPENAI_CLI: /v1/models (Bearer token)
|
- OPENAI/OPENAI_CLI: 使用 OpenAIChatAdapter.fetch_models
|
||||||
- CLAUDE/CLAUDE_CLI: /v1/models (x-api-key)
|
- CLAUDE/CLAUDE_CLI: 使用 ClaudeChatAdapter.fetch_models
|
||||||
- GEMINI/GEMINI_CLI: /v1beta/models (URL key parameter)
|
- GEMINI/GEMINI_CLI: 使用 GeminiChatAdapter.fetch_models
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: 查询请求
|
request: 查询请求
|
||||||
@@ -265,37 +155,53 @@ async def query_available_models(
|
|||||||
base_url = base_url.rstrip("/")
|
base_url = base_url.rstrip("/")
|
||||||
api_format = config["api_format"]
|
api_format = config["api_format"]
|
||||||
api_key_value = config["api_key"]
|
api_key_value = config["api_key"]
|
||||||
extra_headers = config["extra_headers"]
|
extra_headers = config.get("extra_headers")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if api_format in ["CLAUDE", "CLAUDE_CLI"]:
|
# 获取对应的 Adapter 类并调用 fetch_models
|
||||||
return await _fetch_claude_models(client, base_url, api_key_value, api_format)
|
adapter_class = _get_adapter_for_format(api_format)
|
||||||
elif api_format in ["GEMINI", "GEMINI_CLI"]:
|
if not adapter_class:
|
||||||
return await _fetch_gemini_models(client, base_url, api_key_value, api_format)
|
return [], f"Unknown API format: {api_format}"
|
||||||
else:
|
models, error = await adapter_class.fetch_models(
|
||||||
return await _fetch_openai_models(
|
client, base_url, api_key_value, extra_headers
|
||||||
client, base_url, api_key_value, api_format, extra_headers
|
|
||||||
)
|
)
|
||||||
|
# 确保所有模型都有 api_format 字段
|
||||||
|
for m in models:
|
||||||
|
if "api_format" not in m:
|
||||||
|
m["api_format"] = api_format
|
||||||
|
return models, error
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching models from {api_format} endpoint: {e}")
|
logger.error(f"Error fetching models from {api_format} endpoint: {e}")
|
||||||
return [], f"{api_format}: {str(e)}"
|
return [], f"{api_format}: {str(e)}"
|
||||||
|
|
||||||
|
# 限制并发请求数量,避免触发上游速率限制
|
||||||
|
MAX_CONCURRENT_REQUESTS = 5
|
||||||
|
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
|
||||||
|
|
||||||
|
async def fetch_with_semaphore(
|
||||||
|
client: httpx.AsyncClient, config: dict
|
||||||
|
) -> tuple[list, Optional[str]]:
|
||||||
|
async with semaphore:
|
||||||
|
return await fetch_endpoint_models(client, config)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
*[fetch_endpoint_models(client, c) for c in endpoint_configs]
|
*[fetch_with_semaphore(client, c) for c in endpoint_configs]
|
||||||
)
|
)
|
||||||
for models, error in results:
|
for models, error in results:
|
||||||
all_models.extend(models)
|
all_models.extend(models)
|
||||||
if error:
|
if error:
|
||||||
errors.append(error)
|
errors.append(error)
|
||||||
|
|
||||||
# 按 model id 去重(保留第一个)
|
# 按 model id + api_format 去重(保留第一个)
|
||||||
seen_ids: set[str] = set()
|
seen_keys: set[str] = set()
|
||||||
unique_models: list = []
|
unique_models: list = []
|
||||||
for model in all_models:
|
for model in all_models:
|
||||||
model_id = model.get("id")
|
model_id = model.get("id")
|
||||||
if model_id and model_id not in seen_ids:
|
api_format = model.get("api_format", "")
|
||||||
seen_ids.add(model_id)
|
unique_key = f"{model_id}:{api_format}"
|
||||||
|
if model_id and unique_key not in seen_keys:
|
||||||
|
seen_keys.add(unique_key)
|
||||||
unique_models.append(model)
|
unique_models.append(model)
|
||||||
|
|
||||||
error = "; ".join(errors) if errors else None
|
error = "; ".join(errors) if errors else None
|
||||||
@@ -311,3 +217,228 @@ async def query_available_models(
|
|||||||
"display_name": provider.display_name,
|
"display_name": provider.display_name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-model")
|
||||||
|
async def test_model(
|
||||||
|
request: TestModelRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
测试模型连接性
|
||||||
|
|
||||||
|
向指定提供商的指定模型发送测试请求,验证模型是否可用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: 测试请求
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
测试结果
|
||||||
|
"""
|
||||||
|
# 获取提供商及其端点
|
||||||
|
provider = (
|
||||||
|
db.query(Provider)
|
||||||
|
.options(joinedload(Provider.endpoints).joinedload(ProviderEndpoint.api_keys))
|
||||||
|
.filter(Provider.id == request.provider_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
|
# 找到合适的端点和API Key
|
||||||
|
endpoint_config = None
|
||||||
|
endpoint = None
|
||||||
|
api_key = None
|
||||||
|
|
||||||
|
if request.api_key_id:
|
||||||
|
# 使用指定的API Key
|
||||||
|
for ep in provider.endpoints:
|
||||||
|
for key in ep.api_keys:
|
||||||
|
if key.id == request.api_key_id and key.is_active and ep.is_active:
|
||||||
|
endpoint = ep
|
||||||
|
api_key = key
|
||||||
|
break
|
||||||
|
if endpoint:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# 使用第一个可用的端点和密钥
|
||||||
|
for ep in provider.endpoints:
|
||||||
|
if not ep.is_active or not ep.api_keys:
|
||||||
|
continue
|
||||||
|
for key in ep.api_keys:
|
||||||
|
if key.is_active:
|
||||||
|
endpoint = ep
|
||||||
|
api_key = key
|
||||||
|
break
|
||||||
|
if endpoint:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not endpoint or not api_key:
|
||||||
|
raise HTTPException(status_code=404, detail="No active endpoint or API key found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
api_key_value = crypto_service.decrypt(api_key.api_key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[test-model] Failed to decrypt API key: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
|
||||||
|
|
||||||
|
# 构建请求配置
|
||||||
|
endpoint_config = {
|
||||||
|
"api_key": api_key_value,
|
||||||
|
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
|
||||||
|
"base_url": endpoint.base_url,
|
||||||
|
"api_format": endpoint.api_format,
|
||||||
|
"extra_headers": endpoint.headers,
|
||||||
|
"timeout": endpoint.timeout or 30.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取对应的 Adapter 类
|
||||||
|
adapter_class = _get_adapter_for_format(endpoint.api_format)
|
||||||
|
if not adapter_class:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unknown API format: {endpoint.api_format}",
|
||||||
|
"provider": {
|
||||||
|
"id": provider.id,
|
||||||
|
"name": provider.name,
|
||||||
|
"display_name": provider.display_name,
|
||||||
|
},
|
||||||
|
"model": request.model_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"[test-model] 使用 Adapter: {adapter_class.__name__}")
|
||||||
|
logger.debug(f"[test-model] 端点 API Format: {endpoint.api_format}")
|
||||||
|
|
||||||
|
# 如果请求指定了 api_format,优先使用它
|
||||||
|
target_api_format = request.api_format or endpoint.api_format
|
||||||
|
if request.api_format and request.api_format != endpoint.api_format:
|
||||||
|
logger.debug(f"[test-model] 请求指定 API Format: {request.api_format}")
|
||||||
|
# 重新获取适配器
|
||||||
|
adapter_class = _get_adapter_for_format(request.api_format)
|
||||||
|
if not adapter_class:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unknown API format: {request.api_format}",
|
||||||
|
"provider": {
|
||||||
|
"id": provider.id,
|
||||||
|
"name": provider.name,
|
||||||
|
"display_name": provider.display_name,
|
||||||
|
},
|
||||||
|
"model": request.model_name,
|
||||||
|
}
|
||||||
|
logger.debug(f"[test-model] 重新选择 Adapter: {adapter_class.__name__}")
|
||||||
|
|
||||||
|
# 准备测试请求数据
|
||||||
|
check_request = {
|
||||||
|
"model": request.model_name,
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": request.message or "Hello! This is a test message."}
|
||||||
|
],
|
||||||
|
"max_tokens": 30,
|
||||||
|
"temperature": 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 发送测试请求
|
||||||
|
async with httpx.AsyncClient(timeout=endpoint_config["timeout"]) as client:
|
||||||
|
# 非流式测试
|
||||||
|
logger.debug(f"[test-model] 开始非流式测试...")
|
||||||
|
|
||||||
|
response = await adapter_class.check_endpoint(
|
||||||
|
client,
|
||||||
|
endpoint_config["base_url"],
|
||||||
|
endpoint_config["api_key"],
|
||||||
|
check_request,
|
||||||
|
endpoint_config.get("extra_headers"),
|
||||||
|
# 用量计算参数(现在强制记录)
|
||||||
|
db=db,
|
||||||
|
user=current_user,
|
||||||
|
provider_name=provider.name,
|
||||||
|
provider_id=provider.id,
|
||||||
|
api_key_id=endpoint_config.get("api_key_id"),
|
||||||
|
model_name=request.model_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录提供商返回信息
|
||||||
|
logger.debug(f"[test-model] 非流式测试结果:")
|
||||||
|
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
|
||||||
|
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
|
||||||
|
response_data = response.get('response', {})
|
||||||
|
response_body = response_data.get('response_body', {})
|
||||||
|
logger.debug(f"[test-model] Response Data: {response_data}")
|
||||||
|
logger.debug(f"[test-model] Response Body: {response_body}")
|
||||||
|
# 尝试解析 response_body (通常是 JSON 字符串)
|
||||||
|
parsed_body = response_body
|
||||||
|
import json
|
||||||
|
if isinstance(response_body, str):
|
||||||
|
try:
|
||||||
|
parsed_body = json.loads(response_body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if isinstance(parsed_body, dict) and 'error' in parsed_body:
|
||||||
|
error_obj = parsed_body['error']
|
||||||
|
# 兼容 error 可能是字典或字符串的情况
|
||||||
|
if isinstance(error_obj, dict):
|
||||||
|
logger.debug(f"[test-model] Error Message: {error_obj.get('message')}")
|
||||||
|
raise HTTPException(status_code=500, detail=error_obj.get('message'))
|
||||||
|
else:
|
||||||
|
logger.debug(f"[test-model] Error: {error_obj}")
|
||||||
|
raise HTTPException(status_code=500, detail=error_obj)
|
||||||
|
elif 'error' in response:
|
||||||
|
logger.debug(f"[test-model] Error: {response['error']}")
|
||||||
|
raise HTTPException(status_code=500, detail=response['error'])
|
||||||
|
else:
|
||||||
|
# 如果有选择或消息,记录内容预览
|
||||||
|
if isinstance(response_data, dict):
|
||||||
|
if 'choices' in response_data and response_data['choices']:
|
||||||
|
choice = response_data['choices'][0]
|
||||||
|
if 'message' in choice:
|
||||||
|
content = choice['message'].get('content', '')
|
||||||
|
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
|
||||||
|
elif 'content' in response_data and response_data['content']:
|
||||||
|
content = str(response_data['content'])
|
||||||
|
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
|
||||||
|
|
||||||
|
# 检查测试是否成功(基于HTTP状态码)
|
||||||
|
status_code = response.get('status_code', 0)
|
||||||
|
is_success = status_code == 200 and 'error' not in response
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": is_success,
|
||||||
|
"data": {
|
||||||
|
"stream": False,
|
||||||
|
"response": response,
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"id": provider.id,
|
||||||
|
"name": provider.name,
|
||||||
|
"display_name": provider.display_name,
|
||||||
|
},
|
||||||
|
"model": request.model_name,
|
||||||
|
"endpoint": {
|
||||||
|
"id": endpoint.id,
|
||||||
|
"api_format": endpoint.api_format,
|
||||||
|
"base_url": endpoint.base_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[test-model] Error testing model {request.model_name}: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"provider": {
|
||||||
|
"id": provider.id,
|
||||||
|
"name": provider.name,
|
||||||
|
"display_name": provider.display_name,
|
||||||
|
},
|
||||||
|
"model": request.model_name,
|
||||||
|
"endpoint": {
|
||||||
|
"id": endpoint.id,
|
||||||
|
"api_format": endpoint.api_format,
|
||||||
|
"base_url": endpoint.base_url,
|
||||||
|
} if endpoint else None,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -103,6 +103,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 +121,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 +156,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(
|
||||||
|
|||||||
@@ -22,16 +22,18 @@ from src.models.api import (
|
|||||||
from src.models.pydantic_models import (
|
from src.models.pydantic_models import (
|
||||||
BatchAssignModelsToProviderRequest,
|
BatchAssignModelsToProviderRequest,
|
||||||
BatchAssignModelsToProviderResponse,
|
BatchAssignModelsToProviderResponse,
|
||||||
|
ImportFromUpstreamRequest,
|
||||||
|
ImportFromUpstreamResponse,
|
||||||
|
ImportFromUpstreamSuccessItem,
|
||||||
|
ImportFromUpstreamErrorItem,
|
||||||
|
ProviderAvailableSourceModel,
|
||||||
|
ProviderAvailableSourceModelsResponse,
|
||||||
)
|
)
|
||||||
from src.models.database import (
|
from src.models.database import (
|
||||||
GlobalModel,
|
GlobalModel,
|
||||||
Model,
|
Model,
|
||||||
Provider,
|
Provider,
|
||||||
)
|
)
|
||||||
from src.models.pydantic_models import (
|
|
||||||
ProviderAvailableSourceModel,
|
|
||||||
ProviderAvailableSourceModelsResponse,
|
|
||||||
)
|
|
||||||
from src.services.model.service import ModelService
|
from src.services.model.service import ModelService
|
||||||
|
|
||||||
router = APIRouter(tags=["Model Management"])
|
router = APIRouter(tags=["Model Management"])
|
||||||
@@ -158,6 +160,28 @@ async def batch_assign_global_models_to_provider(
|
|||||||
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.post(
|
||||||
|
"/{provider_id}/import-from-upstream",
|
||||||
|
response_model=ImportFromUpstreamResponse,
|
||||||
|
)
|
||||||
|
async def import_models_from_upstream(
|
||||||
|
provider_id: str,
|
||||||
|
payload: ImportFromUpstreamRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ImportFromUpstreamResponse:
|
||||||
|
"""
|
||||||
|
从上游提供商导入模型
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 根据 model_ids 检查全局模型是否存在(按 name 匹配)
|
||||||
|
2. 如不存在,自动创建新的 GlobalModel(使用默认配置)
|
||||||
|
3. 创建 Model 关联到当前 Provider
|
||||||
|
"""
|
||||||
|
adapter = AdminImportFromUpstreamAdapter(provider_id=provider_id, payload=payload)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
# -------- Adapters --------
|
# -------- Adapters --------
|
||||||
|
|
||||||
|
|
||||||
@@ -425,3 +449,130 @@ class AdminBatchAssignModelsToProviderAdapter(AdminApiAdapter):
|
|||||||
await invalidate_models_list_cache()
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
return BatchAssignModelsToProviderResponse(success=success, errors=errors)
|
return BatchAssignModelsToProviderResponse(success=success, errors=errors)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminImportFromUpstreamAdapter(AdminApiAdapter):
|
||||||
|
"""从上游提供商导入模型"""
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
payload: ImportFromUpstreamRequest
|
||||||
|
|
||||||
|
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("Provider not found", "provider")
|
||||||
|
|
||||||
|
success: list[ImportFromUpstreamSuccessItem] = []
|
||||||
|
errors: list[ImportFromUpstreamErrorItem] = []
|
||||||
|
|
||||||
|
# 默认阶梯计费配置(免费)
|
||||||
|
default_tiered_pricing = {
|
||||||
|
"tiers": [
|
||||||
|
{
|
||||||
|
"up_to": None,
|
||||||
|
"input_price_per_1m": 0.0,
|
||||||
|
"output_price_per_1m": 0.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
for model_id in self.payload.model_ids:
|
||||||
|
# 输入验证:检查 model_id 长度
|
||||||
|
if not model_id or len(model_id) > 100:
|
||||||
|
errors.append(
|
||||||
|
ImportFromUpstreamErrorItem(
|
||||||
|
model_id=model_id[:50] + "..." if model_id and len(model_id) > 50 else model_id or "<empty>",
|
||||||
|
error="Invalid model_id: must be 1-100 characters",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 savepoint 确保单个模型导入的原子性
|
||||||
|
savepoint = db.begin_nested()
|
||||||
|
try:
|
||||||
|
# 1. 检查是否已存在同名的 GlobalModel
|
||||||
|
global_model = (
|
||||||
|
db.query(GlobalModel).filter(GlobalModel.name == model_id).first()
|
||||||
|
)
|
||||||
|
created_global_model = False
|
||||||
|
|
||||||
|
if not global_model:
|
||||||
|
# 2. 创建新的 GlobalModel
|
||||||
|
global_model = GlobalModel(
|
||||||
|
name=model_id,
|
||||||
|
display_name=model_id,
|
||||||
|
default_tiered_pricing=default_tiered_pricing,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(global_model)
|
||||||
|
db.flush()
|
||||||
|
created_global_model = True
|
||||||
|
logger.info(
|
||||||
|
f"Created new GlobalModel: {model_id} during upstream import"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 检查是否已存在关联
|
||||||
|
existing = (
|
||||||
|
db.query(Model)
|
||||||
|
.filter(
|
||||||
|
Model.provider_id == self.provider_id,
|
||||||
|
Model.global_model_id == global_model.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
# 已存在关联,提交 savepoint 并记录成功
|
||||||
|
savepoint.commit()
|
||||||
|
success.append(
|
||||||
|
ImportFromUpstreamSuccessItem(
|
||||||
|
model_id=model_id,
|
||||||
|
global_model_id=global_model.id,
|
||||||
|
global_model_name=global_model.name,
|
||||||
|
provider_model_id=existing.id,
|
||||||
|
created_global_model=created_global_model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 4. 创建新的 Model 记录
|
||||||
|
new_model = Model(
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
global_model_id=global_model.id,
|
||||||
|
provider_model_name=global_model.name,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(new_model)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 提交 savepoint
|
||||||
|
savepoint.commit()
|
||||||
|
success.append(
|
||||||
|
ImportFromUpstreamSuccessItem(
|
||||||
|
model_id=model_id,
|
||||||
|
global_model_id=global_model.id,
|
||||||
|
global_model_name=global_model.name,
|
||||||
|
provider_model_id=new_model.id,
|
||||||
|
created_global_model=created_global_model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 回滚到 savepoint
|
||||||
|
savepoint.rollback()
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error importing model {model_id}: {e}")
|
||||||
|
errors.append(ImportFromUpstreamErrorItem(model_id=model_id, error=str(e)))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Imported {len(success)} models from upstream to provider {provider.name} by {context.user.username}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
if success:
|
||||||
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
|
return ImportFromUpstreamResponse(success=success, errors=errors)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from src.core.exceptions import InvalidRequestException, NotFoundException, tran
|
|||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
|
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
|
||||||
from src.models.database import ApiKey, Provider, Usage, User
|
from src.models.database import ApiKey, Provider, Usage, User
|
||||||
|
from src.services.email.email_template import EmailTemplate
|
||||||
from src.services.system.config import SystemConfigService
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
|
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
|
||||||
@@ -119,6 +120,59 @@ async def import_users(request: Request, db: Session = Depends(get_db)):
|
|||||||
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.post("/smtp/test")
|
||||||
|
async def test_smtp(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""测试 SMTP 连接(管理员)"""
|
||||||
|
adapter = AdminTestSmtpAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- 邮件模板 API --------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email/templates")
|
||||||
|
async def get_email_templates(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""获取所有邮件模板(管理员)"""
|
||||||
|
adapter = AdminGetEmailTemplatesAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email/templates/{template_type}")
|
||||||
|
async def get_email_template(
|
||||||
|
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取指定类型的邮件模板(管理员)"""
|
||||||
|
adapter = AdminGetEmailTemplateAdapter(template_type=template_type)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/email/templates/{template_type}")
|
||||||
|
async def update_email_template(
|
||||||
|
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""更新邮件模板(管理员)"""
|
||||||
|
adapter = AdminUpdateEmailTemplateAdapter(template_type=template_type)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email/templates/{template_type}/preview")
|
||||||
|
async def preview_email_template(
|
||||||
|
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""预览邮件模板(管理员)"""
|
||||||
|
adapter = AdminPreviewEmailTemplateAdapter(template_type=template_type)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email/templates/{template_type}/reset")
|
||||||
|
async def reset_email_template(
|
||||||
|
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""重置邮件模板为默认值(管理员)"""
|
||||||
|
adapter = AdminResetEmailTemplateAdapter(template_type=template_type)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
# -------- 系统设置适配器 --------
|
# -------- 系统设置适配器 --------
|
||||||
|
|
||||||
|
|
||||||
@@ -196,10 +250,16 @@ class AdminGetAllConfigsAdapter(AdminApiAdapter):
|
|||||||
class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
||||||
key: str
|
key: str
|
||||||
|
|
||||||
|
# 敏感配置项,不返回实际值
|
||||||
|
SENSITIVE_KEYS = {"smtp_password"}
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
value = SystemConfigService.get_config(context.db, self.key)
|
value = SystemConfigService.get_config(context.db, self.key)
|
||||||
if value is None:
|
if value is None:
|
||||||
raise NotFoundException(f"配置项 '{self.key}' 不存在")
|
raise NotFoundException(f"配置项 '{self.key}' 不存在")
|
||||||
|
# 对敏感配置,只返回是否已设置的标志,不返回实际值
|
||||||
|
if self.key in self.SENSITIVE_KEYS:
|
||||||
|
return {"key": self.key, "value": None, "is_set": bool(value)}
|
||||||
return {"key": self.key, "value": value}
|
return {"key": self.key, "value": value}
|
||||||
|
|
||||||
|
|
||||||
@@ -207,18 +267,31 @@ class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
|||||||
class AdminSetSystemConfigAdapter(AdminApiAdapter):
|
class AdminSetSystemConfigAdapter(AdminApiAdapter):
|
||||||
key: str
|
key: str
|
||||||
|
|
||||||
|
# 需要加密存储的配置项
|
||||||
|
ENCRYPTED_KEYS = {"smtp_password"}
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
payload = context.ensure_json_body()
|
payload = context.ensure_json_body()
|
||||||
|
value = payload.get("value")
|
||||||
|
|
||||||
|
# 对敏感配置进行加密
|
||||||
|
if self.key in self.ENCRYPTED_KEYS and value:
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
value = crypto_service.encrypt(value)
|
||||||
|
|
||||||
config = SystemConfigService.set_config(
|
config = SystemConfigService.set_config(
|
||||||
context.db,
|
context.db,
|
||||||
self.key,
|
self.key,
|
||||||
payload.get("value"),
|
value,
|
||||||
payload.get("description"),
|
payload.get("description"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 返回时不暴露加密后的值
|
||||||
|
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"key": config.key,
|
"key": config.key,
|
||||||
"value": config.value,
|
"value": display_value,
|
||||||
"description": config.description,
|
"description": config.description,
|
||||||
"updated_at": config.updated_at.isoformat(),
|
"updated_at": config.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -436,7 +509,7 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
|||||||
{
|
{
|
||||||
"global_model_name": global_model.name if global_model else None,
|
"global_model_name": global_model.name if global_model else None,
|
||||||
"provider_model_name": model.provider_model_name,
|
"provider_model_name": model.provider_model_name,
|
||||||
"provider_model_aliases": model.provider_model_aliases,
|
"provider_model_mappings": model.provider_model_mappings,
|
||||||
"price_per_request": model.price_per_request,
|
"price_per_request": model.price_per_request,
|
||||||
"tiered_pricing": model.tiered_pricing,
|
"tiered_pricing": model.tiered_pricing,
|
||||||
"supports_vision": model.supports_vision,
|
"supports_vision": model.supports_vision,
|
||||||
@@ -790,8 +863,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
|||||||
)
|
)
|
||||||
elif merge_mode == "overwrite":
|
elif merge_mode == "overwrite":
|
||||||
existing_model.global_model_id = global_model_id
|
existing_model.global_model_id = global_model_id
|
||||||
existing_model.provider_model_aliases = model_data.get(
|
existing_model.provider_model_mappings = model_data.get(
|
||||||
"provider_model_aliases"
|
"provider_model_mappings"
|
||||||
)
|
)
|
||||||
existing_model.price_per_request = model_data.get(
|
existing_model.price_per_request = model_data.get(
|
||||||
"price_per_request"
|
"price_per_request"
|
||||||
@@ -824,8 +897,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
|||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
global_model_id=global_model_id,
|
global_model_id=global_model_id,
|
||||||
provider_model_name=model_data["provider_model_name"],
|
provider_model_name=model_data["provider_model_name"],
|
||||||
provider_model_aliases=model_data.get(
|
provider_model_mappings=model_data.get(
|
||||||
"provider_model_aliases"
|
"provider_model_mappings"
|
||||||
),
|
),
|
||||||
price_per_request=model_data.get("price_per_request"),
|
price_per_request=model_data.get("price_per_request"),
|
||||||
tiered_pricing=model_data.get("tiered_pricing"),
|
tiered_pricing=model_data.get("tiered_pricing"),
|
||||||
@@ -1060,7 +1133,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
|||||||
allowed_endpoints=key_data.get("allowed_endpoints"),
|
allowed_endpoints=key_data.get("allowed_endpoints"),
|
||||||
allowed_api_formats=key_data.get("allowed_api_formats"),
|
allowed_api_formats=key_data.get("allowed_api_formats"),
|
||||||
allowed_models=key_data.get("allowed_models"),
|
allowed_models=key_data.get("allowed_models"),
|
||||||
rate_limit=key_data.get("rate_limit", 100),
|
rate_limit=key_data.get("rate_limit"), # None = 无限制
|
||||||
concurrent_limit=key_data.get("concurrent_limit", 5),
|
concurrent_limit=key_data.get("concurrent_limit", 5),
|
||||||
force_capabilities=key_data.get("force_capabilities"),
|
force_capabilities=key_data.get("force_capabilities"),
|
||||||
is_active=key_data.get("is_active", True),
|
is_active=key_data.get("is_active", True),
|
||||||
@@ -1084,3 +1157,265 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise InvalidRequestException(f"导入失败: {str(e)}")
|
raise InvalidRequestException(f"导入失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTestSmtpAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
"""测试 SMTP 连接"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
from src.services.system.config import SystemConfigService
|
||||||
|
from src.services.email.email_sender import EmailSenderService
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
payload = context.ensure_json_body() or {}
|
||||||
|
|
||||||
|
# 获取密码:优先使用前端传入的明文密码,否则从数据库获取并解密
|
||||||
|
smtp_password = payload.get("smtp_password")
|
||||||
|
if not smtp_password:
|
||||||
|
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
|
||||||
|
if encrypted_password:
|
||||||
|
try:
|
||||||
|
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
|
||||||
|
except Exception:
|
||||||
|
# 解密失败,可能是旧的未加密密码
|
||||||
|
smtp_password = encrypted_password
|
||||||
|
|
||||||
|
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
|
||||||
|
config = {
|
||||||
|
"smtp_host": payload.get("smtp_host") or SystemConfigService.get_config(db, "smtp_host"),
|
||||||
|
"smtp_port": payload.get("smtp_port") or SystemConfigService.get_config(db, "smtp_port", default=587),
|
||||||
|
"smtp_user": payload.get("smtp_user") or SystemConfigService.get_config(db, "smtp_user"),
|
||||||
|
"smtp_password": smtp_password,
|
||||||
|
"smtp_use_tls": payload.get("smtp_use_tls")
|
||||||
|
if payload.get("smtp_use_tls") is not None
|
||||||
|
else SystemConfigService.get_config(db, "smtp_use_tls", default=True),
|
||||||
|
"smtp_use_ssl": payload.get("smtp_use_ssl")
|
||||||
|
if payload.get("smtp_use_ssl") is not None
|
||||||
|
else SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
|
||||||
|
"smtp_from_email": payload.get("smtp_from_email")
|
||||||
|
or SystemConfigService.get_config(db, "smtp_from_email"),
|
||||||
|
"smtp_from_name": payload.get("smtp_from_name")
|
||||||
|
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 验证必要配置
|
||||||
|
missing_fields = [
|
||||||
|
field for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"] if not config.get(field)
|
||||||
|
]
|
||||||
|
if missing_fields:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 测试连接
|
||||||
|
try:
|
||||||
|
success, error_msg = await EmailSenderService.test_smtp_connection(
|
||||||
|
db=db, override_config=config
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "SMTP 连接测试成功"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": error_msg
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -------- 邮件模板适配器 --------
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGetEmailTemplatesAdapter(AdminApiAdapter):
|
||||||
|
"""获取所有邮件模板"""
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
templates = []
|
||||||
|
|
||||||
|
for template_type, type_info in EmailTemplate.TEMPLATE_TYPES.items():
|
||||||
|
# 获取自定义模板或默认模板
|
||||||
|
template = EmailTemplate.get_template(db, template_type)
|
||||||
|
default_template = EmailTemplate.get_default_template(template_type)
|
||||||
|
|
||||||
|
# 检查是否使用了自定义模板
|
||||||
|
is_custom = (
|
||||||
|
template["subject"] != default_template["subject"]
|
||||||
|
or template["html"] != default_template["html"]
|
||||||
|
)
|
||||||
|
|
||||||
|
templates.append(
|
||||||
|
{
|
||||||
|
"type": template_type,
|
||||||
|
"name": type_info["name"],
|
||||||
|
"variables": type_info["variables"],
|
||||||
|
"subject": template["subject"],
|
||||||
|
"html": template["html"],
|
||||||
|
"is_custom": is_custom,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"templates": templates}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminGetEmailTemplateAdapter(AdminApiAdapter):
|
||||||
|
"""获取指定类型的邮件模板"""
|
||||||
|
|
||||||
|
template_type: str
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
# 验证模板类型
|
||||||
|
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||||
|
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||||
|
template = EmailTemplate.get_template(db, self.template_type)
|
||||||
|
default_template = EmailTemplate.get_default_template(self.template_type)
|
||||||
|
|
||||||
|
is_custom = (
|
||||||
|
template["subject"] != default_template["subject"]
|
||||||
|
or template["html"] != default_template["html"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": self.template_type,
|
||||||
|
"name": type_info["name"],
|
||||||
|
"variables": type_info["variables"],
|
||||||
|
"subject": template["subject"],
|
||||||
|
"html": template["html"],
|
||||||
|
"is_custom": is_custom,
|
||||||
|
"default_subject": default_template["subject"],
|
||||||
|
"default_html": default_template["html"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminUpdateEmailTemplateAdapter(AdminApiAdapter):
|
||||||
|
"""更新邮件模板"""
|
||||||
|
|
||||||
|
template_type: str
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
# 验证模板类型
|
||||||
|
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||||
|
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
subject = payload.get("subject")
|
||||||
|
html = payload.get("html")
|
||||||
|
|
||||||
|
# 至少需要提供一个字段
|
||||||
|
if subject is None and html is None:
|
||||||
|
raise InvalidRequestException("请提供 subject 或 html")
|
||||||
|
|
||||||
|
# 保存模板
|
||||||
|
subject_key = f"email_template_{self.template_type}_subject"
|
||||||
|
html_key = f"email_template_{self.template_type}_html"
|
||||||
|
|
||||||
|
if subject is not None:
|
||||||
|
if subject:
|
||||||
|
SystemConfigService.set_config(db, subject_key, subject)
|
||||||
|
else:
|
||||||
|
# 空字符串表示删除自定义值,恢复默认
|
||||||
|
SystemConfigService.delete_config(db, subject_key)
|
||||||
|
|
||||||
|
if html is not None:
|
||||||
|
if html:
|
||||||
|
SystemConfigService.set_config(db, html_key, html)
|
||||||
|
else:
|
||||||
|
SystemConfigService.delete_config(db, html_key)
|
||||||
|
|
||||||
|
return {"message": "模板保存成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminPreviewEmailTemplateAdapter(AdminApiAdapter):
|
||||||
|
"""预览邮件模板"""
|
||||||
|
|
||||||
|
template_type: str
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
# 验证模板类型
|
||||||
|
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||||
|
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
payload = context.ensure_json_body() or {}
|
||||||
|
|
||||||
|
# 获取模板 HTML(优先使用请求体中的,否则使用数据库中的)
|
||||||
|
html = payload.get("html")
|
||||||
|
if not html:
|
||||||
|
template = EmailTemplate.get_template(db, self.template_type)
|
||||||
|
html = template["html"]
|
||||||
|
|
||||||
|
# 获取预览变量
|
||||||
|
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||||
|
|
||||||
|
# 构建预览变量,使用请求中的值或默认示例值
|
||||||
|
preview_variables = {}
|
||||||
|
default_values = {
|
||||||
|
"app_name": SystemConfigService.get_config(db, "email_app_name")
|
||||||
|
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||||
|
"code": "123456",
|
||||||
|
"expire_minutes": "30",
|
||||||
|
"email": "example@example.com",
|
||||||
|
"reset_link": "https://example.com/reset?token=abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
for var in type_info["variables"]:
|
||||||
|
preview_variables[var] = payload.get(var, default_values.get(var, f"{{{{{var}}}}}"))
|
||||||
|
|
||||||
|
# 渲染模板
|
||||||
|
rendered_html = EmailTemplate.render_template(html, preview_variables)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"html": rendered_html,
|
||||||
|
"variables": preview_variables,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminResetEmailTemplateAdapter(AdminApiAdapter):
|
||||||
|
"""重置邮件模板为默认值"""
|
||||||
|
|
||||||
|
template_type: str
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
# 验证模板类型
|
||||||
|
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||||
|
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
|
||||||
|
# 删除自定义模板
|
||||||
|
subject_key = f"email_template_{self.template_type}_subject"
|
||||||
|
html_key = f"email_template_{self.template_type}_html"
|
||||||
|
|
||||||
|
SystemConfigService.delete_config(db, subject_key)
|
||||||
|
SystemConfigService.delete_config(db, html_key)
|
||||||
|
|
||||||
|
# 返回默认模板
|
||||||
|
default_template = EmailTemplate.get_default_template(self.template_type)
|
||||||
|
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "模板已重置为默认值",
|
||||||
|
"template": {
|
||||||
|
"type": self.template_type,
|
||||||
|
"name": type_info["name"],
|
||||||
|
"subject": default_template["subject"],
|
||||||
|
"html": default_template["html"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ async def get_usage_stats(
|
|||||||
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("/heatmap")
|
||||||
|
async def get_activity_heatmap(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get activity heatmap data for the past 365 days.
|
||||||
|
|
||||||
|
This endpoint is cached for 5 minutes to reduce database load.
|
||||||
|
"""
|
||||||
|
adapter = AdminActivityHeatmapAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/records")
|
@router.get("/records")
|
||||||
async def get_usage_records(
|
async def get_usage_records(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -168,12 +182,6 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
|
|||||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
activity_heatmap = UsageService.get_daily_activity(
|
|
||||||
db=db,
|
|
||||||
window_days=365,
|
|
||||||
include_actual_cost=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
context.add_audit_metadata(
|
context.add_audit_metadata(
|
||||||
action="usage_stats",
|
action="usage_stats",
|
||||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||||
@@ -204,10 +212,22 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
|
|||||||
),
|
),
|
||||||
"cache_read_cost": float(cache_stats.cache_read_cost or 0) if cache_stats else 0,
|
"cache_read_cost": float(cache_stats.cache_read_cost or 0) if cache_stats else 0,
|
||||||
},
|
},
|
||||||
"activity_heatmap": activity_heatmap,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminActivityHeatmapAdapter(AdminApiAdapter):
|
||||||
|
"""Activity heatmap adapter with Redis caching."""
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
result = await UsageService.get_cached_heatmap(
|
||||||
|
db=context.db,
|
||||||
|
user_id=None,
|
||||||
|
include_actual_cost=True,
|
||||||
|
)
|
||||||
|
context.add_audit_metadata(action="activity_heatmap")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class AdminUsageByModelAdapter(AdminApiAdapter):
|
class AdminUsageByModelAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: Optional[datetime], end_date: Optional[datetime], limit: int):
|
def __init__(self, start_date: Optional[datetime], end_date: Optional[datetime], limit: int):
|
||||||
self.start_date = start_date
|
self.start_date = start_date
|
||||||
@@ -670,7 +690,9 @@ class AdminActiveRequestsAdapter(AdminApiAdapter):
|
|||||||
if not id_list:
|
if not id_list:
|
||||||
return {"requests": []}
|
return {"requests": []}
|
||||||
|
|
||||||
requests = UsageService.get_active_requests_status(db=db, ids=id_list)
|
requests = UsageService.get_active_requests_status(
|
||||||
|
db=db, ids=id_list, include_admin_fields=True
|
||||||
|
)
|
||||||
return {"requests": requests}
|
return {"requests": requests}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
|
|||||||
raise InvalidRequestException("请求数据验证失败")
|
raise InvalidRequestException("请求数据验证失败")
|
||||||
|
|
||||||
update_data = request.model_dump(exclude_unset=True)
|
update_data = request.model_dump(exclude_unset=True)
|
||||||
|
old_role = existing_user.role
|
||||||
if "role" in update_data and update_data["role"]:
|
if "role" in update_data and update_data["role"]:
|
||||||
if hasattr(update_data["role"], "value"):
|
if hasattr(update_data["role"], "value"):
|
||||||
update_data["role"] = update_data["role"]
|
update_data["role"] = update_data["role"]
|
||||||
@@ -258,6 +259,12 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
|
|||||||
if not user:
|
if not user:
|
||||||
raise NotFoundException("用户不存在", "user")
|
raise NotFoundException("用户不存在", "user")
|
||||||
|
|
||||||
|
# 角色变更时清除热力图缓存(影响 include_actual_cost 权限)
|
||||||
|
if "role" in update_data and update_data["role"] != old_role:
|
||||||
|
from src.services.usage.service import UsageService
|
||||||
|
|
||||||
|
await UsageService.clear_user_heatmap_cache(self.user_id)
|
||||||
|
|
||||||
changed_fields = list(update_data.keys())
|
changed_fields = list(update_data.keys())
|
||||||
context.add_audit_metadata(
|
context.add_audit_metadata(
|
||||||
action="update_user",
|
action="update_user",
|
||||||
@@ -424,7 +431,7 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
|
|||||||
name=key_data.name,
|
name=key_data.name,
|
||||||
allowed_providers=key_data.allowed_providers,
|
allowed_providers=key_data.allowed_providers,
|
||||||
allowed_models=key_data.allowed_models,
|
allowed_models=key_data.allowed_models,
|
||||||
rate_limit=key_data.rate_limit or 100,
|
rate_limit=key_data.rate_limit, # None = 无限制
|
||||||
expire_days=key_data.expire_days,
|
expire_days=key_data.expire_days,
|
||||||
initial_balance_usd=None, # 普通Key不设置余额限制
|
initial_balance_usd=None, # 普通Key不设置余额限制
|
||||||
is_standalone=False, # 不是独立Key
|
is_standalone=False, # 不是独立Key
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
认证相关API端点
|
认证相关API端点
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
@@ -23,21 +23,82 @@ from src.models.api import (
|
|||||||
RefreshTokenResponse,
|
RefreshTokenResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
|
RegistrationSettingsResponse,
|
||||||
|
SendVerificationCodeRequest,
|
||||||
|
SendVerificationCodeResponse,
|
||||||
|
VerificationStatusRequest,
|
||||||
|
VerificationStatusResponse,
|
||||||
|
VerifyEmailRequest,
|
||||||
|
VerifyEmailResponse,
|
||||||
)
|
)
|
||||||
from src.models.database import AuditEventType, User, UserRole
|
from src.models.database import AuditEventType, User, UserRole
|
||||||
from src.services.auth.service import AuthService
|
from src.services.auth.service import AuthService
|
||||||
from src.services.rate_limit.ip_limiter import IPRateLimiter
|
from src.services.rate_limit.ip_limiter import IPRateLimiter
|
||||||
from src.services.system.audit import AuditService
|
from src.services.system.audit import AuditService
|
||||||
|
from src.services.system.config import SystemConfigService
|
||||||
from src.services.user.service import UserService
|
from src.services.user.service import UserService
|
||||||
|
from src.services.email import EmailSenderService, EmailVerificationService
|
||||||
from src.utils.request_utils import get_client_ip, get_user_agent
|
from src.utils.request_utils import get_client_ip, get_user_agent
|
||||||
|
|
||||||
|
|
||||||
|
def validate_email_suffix(db: Session, email: str) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
验证邮箱后缀是否允许注册
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
email: 邮箱地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否允许, 错误信息)
|
||||||
|
"""
|
||||||
|
# 获取邮箱后缀限制配置
|
||||||
|
mode = SystemConfigService.get_config(db, "email_suffix_mode", default="none")
|
||||||
|
|
||||||
|
if mode == "none":
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
# 获取邮箱后缀列表
|
||||||
|
suffix_list = SystemConfigService.get_config(db, "email_suffix_list", default=[])
|
||||||
|
if not suffix_list:
|
||||||
|
# 没有配置后缀列表时,不限制
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
# 确保 suffix_list 是列表类型
|
||||||
|
if isinstance(suffix_list, str):
|
||||||
|
suffix_list = [s.strip().lower() for s in suffix_list.split(",") if s.strip()]
|
||||||
|
|
||||||
|
# 获取邮箱后缀
|
||||||
|
if "@" not in email:
|
||||||
|
return False, "邮箱格式无效"
|
||||||
|
|
||||||
|
email_suffix = email.split("@")[1].lower()
|
||||||
|
|
||||||
|
if mode == "whitelist":
|
||||||
|
# 白名单模式:只允许列出的后缀
|
||||||
|
if email_suffix not in suffix_list:
|
||||||
|
return False, f"该邮箱后缀不在允许列表中,仅支持: {', '.join(suffix_list)}"
|
||||||
|
elif mode == "blacklist":
|
||||||
|
# 黑名单模式:拒绝列出的后缀
|
||||||
|
if email_suffix in suffix_list:
|
||||||
|
return False, f"该邮箱后缀 ({email_suffix}) 不允许注册"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
# API端点
|
# API端点
|
||||||
|
@router.get("/registration-settings", response_model=RegistrationSettingsResponse)
|
||||||
|
async def registration_settings(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""公开获取注册相关配置"""
|
||||||
|
adapter = AuthRegistrationSettingsAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(request: Request, db: Session = Depends(get_db)):
|
async def login(request: Request, db: Session = Depends(get_db)):
|
||||||
adapter = AuthLoginAdapter()
|
adapter = AuthLoginAdapter()
|
||||||
@@ -75,6 +136,27 @@ async def logout(request: Request, db: Session = Depends(get_db)):
|
|||||||
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.post("/send-verification-code", response_model=SendVerificationCodeResponse)
|
||||||
|
async def send_verification_code(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""发送邮箱验证码"""
|
||||||
|
adapter = AuthSendVerificationCodeAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-email", response_model=VerifyEmailResponse)
|
||||||
|
async def verify_email(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""验证邮箱验证码"""
|
||||||
|
adapter = AuthVerifyEmailAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verification-status", response_model=VerificationStatusResponse)
|
||||||
|
async def verification_status(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""查询邮箱验证状态"""
|
||||||
|
adapter = AuthVerificationStatusAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
# ============== 适配器实现 ==============
|
# ============== 适配器实现 ==============
|
||||||
|
|
||||||
|
|
||||||
@@ -209,6 +291,20 @@ class AuthRefreshAdapter(AuthPublicAdapter):
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
"""公开返回注册相关配置"""
|
||||||
|
db = context.db
|
||||||
|
|
||||||
|
enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False)
|
||||||
|
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
|
||||||
|
|
||||||
|
return RegistrationSettingsResponse(
|
||||||
|
enable_registration=bool(enable_registration),
|
||||||
|
require_email_verification=bool(require_verification),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
class AuthRegisterAdapter(AuthPublicAdapter):
|
class AuthRegisterAdapter(AuthPublicAdapter):
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
from src.models.database import SystemConfig
|
from src.models.database import SystemConfig
|
||||||
@@ -241,6 +337,37 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
|||||||
db.commit()
|
db.commit()
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
|
||||||
|
|
||||||
|
# 检查邮箱后缀是否允许
|
||||||
|
suffix_allowed, suffix_error = validate_email_suffix(db, register_request.email)
|
||||||
|
if not suffix_allowed:
|
||||||
|
logger.warning(f"注册失败:邮箱后缀不允许: {register_request.email}")
|
||||||
|
AuditService.log_event(
|
||||||
|
db=db,
|
||||||
|
event_type=AuditEventType.UNAUTHORIZED_ACCESS,
|
||||||
|
description=f"Registration attempt rejected - email suffix not allowed: {register_request.email}",
|
||||||
|
ip_address=client_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
metadata={"email": register_request.email, "reason": "email_suffix_not_allowed"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=suffix_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否需要邮箱验证
|
||||||
|
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
|
||||||
|
|
||||||
|
if require_verification:
|
||||||
|
# 检查邮箱是否已验证
|
||||||
|
is_verified = await EmailVerificationService.is_email_verified(register_request.email)
|
||||||
|
if not is_verified:
|
||||||
|
logger.warning(f"注册失败:邮箱未验证: {register_request.email}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="请先完成邮箱验证。请发送验证码并验证后再注册。",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = UserService.create_user(
|
user = UserService.create_user(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -258,7 +385,16 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
|||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
metadata={"email": user.email, "username": user.username, "role": user.role.value},
|
metadata={"email": user.email, "username": user.username, "role": user.role.value},
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果)
|
||||||
|
if require_verification:
|
||||||
|
try:
|
||||||
|
await EmailVerificationService.clear_verification(register_request.email)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"清理验证状态失败: {e}")
|
||||||
|
|
||||||
return RegisterResponse(
|
return RegisterResponse(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
@@ -308,8 +444,8 @@ class AuthChangePasswordAdapter(AuthenticatedApiAdapter):
|
|||||||
user = context.user
|
user = context.user
|
||||||
if not user.verify_password(old_password):
|
if not user.verify_password(old_password):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
|
||||||
if len(new_password) < 8:
|
if len(new_password) < 6:
|
||||||
raise InvalidRequestException("密码长度至少8位")
|
raise InvalidRequestException("密码长度至少6位")
|
||||||
user.set_password(new_password)
|
user.set_password(new_password)
|
||||||
context.db.commit()
|
context.db.commit()
|
||||||
logger.info(f"用户修改密码: {user.email}")
|
logger.info(f"用户修改密码: {user.email}")
|
||||||
@@ -351,3 +487,177 @@ class AuthLogoutAdapter(AuthenticatedApiAdapter):
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"用户登出失败(Redis不可用): {user.email}")
|
logger.warning(f"用户登出失败(Redis不可用): {user.email}")
|
||||||
return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump()
|
return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
"""发送邮箱验证码"""
|
||||||
|
db = context.db
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_request = SendVerificationCodeRequest.model_validate(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
errors = []
|
||||||
|
for error in exc.errors():
|
||||||
|
field = " -> ".join(str(x) for x in error["loc"])
|
||||||
|
errors.append(f"{field}: {error['msg']}")
|
||||||
|
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||||
|
|
||||||
|
client_ip = get_client_ip(context.request)
|
||||||
|
email = send_request.email
|
||||||
|
|
||||||
|
# IP 速率限制检查(验证码发送:3次/分钟)
|
||||||
|
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||||
|
client_ip, "verification_send"
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
logger.warning(f"验证码发送请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查邮箱是否已注册
|
||||||
|
existing_user = db.query(User).filter(User.email == email).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="该邮箱已被注册,请直接登录或使用其他邮箱",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查邮箱后缀是否允许
|
||||||
|
suffix_allowed, suffix_error = validate_email_suffix(db, email)
|
||||||
|
if not suffix_allowed:
|
||||||
|
logger.warning(f"邮箱后缀不允许: {email}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=suffix_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成并发送验证码(使用服务中的默认配置)
|
||||||
|
success, code_or_error, error_detail = await EmailVerificationService.send_verification_code(
|
||||||
|
email
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.error(f"发送验证码失败: {email}, 错误: {code_or_error}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=error_detail or code_or_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送邮件
|
||||||
|
expire_minutes = EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES
|
||||||
|
email_success, email_error = await EmailSenderService.send_verification_code(
|
||||||
|
db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
if not email_success:
|
||||||
|
logger.error(f"发送验证码邮件失败: {email}, 错误: {email_error}")
|
||||||
|
# 不向用户暴露 SMTP 详细错误信息,防止信息泄露
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="发送验证码失败,请稍后重试",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"验证码已发送: {email}")
|
||||||
|
|
||||||
|
return SendVerificationCodeResponse(
|
||||||
|
message="验证码已发送,请查收邮件",
|
||||||
|
success=True,
|
||||||
|
expire_minutes=expire_minutes,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthVerifyEmailAdapter(AuthPublicAdapter):
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
"""验证邮箱验证码"""
|
||||||
|
db = context.db
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
try:
|
||||||
|
verify_request = VerifyEmailRequest.model_validate(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
errors = []
|
||||||
|
for error in exc.errors():
|
||||||
|
field = " -> ".join(str(x) for x in error["loc"])
|
||||||
|
errors.append(f"{field}: {error['msg']}")
|
||||||
|
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||||
|
|
||||||
|
client_ip = get_client_ip(context.request)
|
||||||
|
email = verify_request.email
|
||||||
|
code = verify_request.code
|
||||||
|
|
||||||
|
# IP 速率限制检查(验证码验证:10次/分钟)
|
||||||
|
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||||
|
client_ip, "verification_verify"
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
logger.warning(f"验证码验证请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证验证码
|
||||||
|
success, message = await EmailVerificationService.verify_code(email, code)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.warning(f"验证码验证失败: {email}, 原因: {message}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||||
|
|
||||||
|
logger.info(f"邮箱验证成功: {email}")
|
||||||
|
|
||||||
|
return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthVerificationStatusAdapter(AuthPublicAdapter):
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
"""查询邮箱验证状态"""
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_request = VerificationStatusRequest.model_validate(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
errors = []
|
||||||
|
for error in exc.errors():
|
||||||
|
field = " -> ".join(str(x) for x in error["loc"])
|
||||||
|
errors.append(f"{field}: {error['msg']}")
|
||||||
|
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||||
|
|
||||||
|
client_ip = get_client_ip(context.request)
|
||||||
|
email = status_request.email
|
||||||
|
|
||||||
|
# IP 速率限制检查(验证状态查询:20次/分钟)
|
||||||
|
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||||
|
client_ip, "verification_status", limit=20
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
logger.warning(f"验证状态查询请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取验证状态
|
||||||
|
status_data = await EmailVerificationService.get_verification_status(email)
|
||||||
|
|
||||||
|
# 计算冷却剩余时间
|
||||||
|
cooldown_remaining = None
|
||||||
|
if status_data.get("has_pending_code") and status_data.get("created_at"):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
created_at = datetime.fromisoformat(status_data["created_at"])
|
||||||
|
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
|
||||||
|
cooldown = EmailVerificationService.SEND_COOLDOWN_SECONDS - int(elapsed)
|
||||||
|
if cooldown > 0:
|
||||||
|
cooldown_remaining = cooldown
|
||||||
|
|
||||||
|
return VerificationStatusResponse(
|
||||||
|
email=email,
|
||||||
|
has_pending_code=status_data.get("has_pending_code", False),
|
||||||
|
is_verified=status_data.get("is_verified", False),
|
||||||
|
cooldown_remaining=cooldown_remaining,
|
||||||
|
code_expires_in=status_data.get("code_expires_in"),
|
||||||
|
).model_dump()
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ from sqlalchemy.orm import Session, joinedload
|
|||||||
from src.config.constants import CacheTTL
|
from src.config.constants import CacheTTL
|
||||||
from src.core.cache_service import CacheService
|
from src.core.cache_service import CacheService
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.models.database import GlobalModel, Model, Provider, ProviderAPIKey, ProviderEndpoint
|
from src.models.database import (
|
||||||
|
ApiKey,
|
||||||
|
GlobalModel,
|
||||||
|
Model,
|
||||||
|
Provider,
|
||||||
|
ProviderAPIKey,
|
||||||
|
ProviderEndpoint,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
# 缓存 key 前缀
|
# 缓存 key 前缀
|
||||||
_CACHE_KEY_PREFIX = "models:list"
|
_CACHE_KEY_PREFIX = "models:list"
|
||||||
@@ -82,6 +90,7 @@ class ModelInfo:
|
|||||||
created_at: Optional[str] # ISO 格式
|
created_at: Optional[str] # ISO 格式
|
||||||
created_timestamp: int # Unix 时间戳
|
created_timestamp: int # Unix 时间戳
|
||||||
provider_name: str
|
provider_name: str
|
||||||
|
provider_id: str = "" # Provider ID,用于权限过滤
|
||||||
# 能力配置
|
# 能力配置
|
||||||
streaming: bool = True
|
streaming: bool = True
|
||||||
vision: bool = False
|
vision: bool = False
|
||||||
@@ -99,6 +108,92 @@ class ModelInfo:
|
|||||||
output_modalities: Optional[list[str]] = None
|
output_modalities: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccessRestrictions:
|
||||||
|
"""API Key 或 User 的访问限制"""
|
||||||
|
|
||||||
|
allowed_providers: Optional[list[str]] = None # 允许的 Provider ID 列表
|
||||||
|
allowed_models: Optional[list[str]] = None # 允许的模型名称列表
|
||||||
|
allowed_api_formats: Optional[list[str]] = None # 允许的 API 格式列表
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_key_and_user(
|
||||||
|
cls, api_key: Optional[ApiKey], user: Optional[User]
|
||||||
|
) -> "AccessRestrictions":
|
||||||
|
"""
|
||||||
|
从 API Key 和 User 合并访问限制
|
||||||
|
|
||||||
|
限制逻辑:
|
||||||
|
- API Key 的限制优先于 User 的限制
|
||||||
|
- 如果 API Key 有限制,使用 API Key 的限制
|
||||||
|
- 如果 API Key 无限制但 User 有限制,使用 User 的限制
|
||||||
|
- 两者都无限制则返回空限制
|
||||||
|
"""
|
||||||
|
allowed_providers: Optional[list[str]] = None
|
||||||
|
allowed_models: Optional[list[str]] = None
|
||||||
|
allowed_api_formats: Optional[list[str]] = None
|
||||||
|
|
||||||
|
# 优先使用 API Key 的限制
|
||||||
|
if api_key:
|
||||||
|
if api_key.allowed_providers is not None:
|
||||||
|
allowed_providers = api_key.allowed_providers
|
||||||
|
if api_key.allowed_models is not None:
|
||||||
|
allowed_models = api_key.allowed_models
|
||||||
|
if api_key.allowed_api_formats is not None:
|
||||||
|
allowed_api_formats = api_key.allowed_api_formats
|
||||||
|
|
||||||
|
# 如果 API Key 没有限制,检查 User 的限制
|
||||||
|
# 注意: User 没有 allowed_api_formats 字段
|
||||||
|
if user:
|
||||||
|
if allowed_providers is None and user.allowed_providers is not None:
|
||||||
|
allowed_providers = user.allowed_providers
|
||||||
|
if allowed_models is None and user.allowed_models is not None:
|
||||||
|
allowed_models = user.allowed_models
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
allowed_providers=allowed_providers,
|
||||||
|
allowed_models=allowed_models,
|
||||||
|
allowed_api_formats=allowed_api_formats,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_api_format_allowed(self, api_format: str) -> bool:
|
||||||
|
"""
|
||||||
|
检查 API 格式是否被允许
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_format: API 格式 (如 "OPENAI", "CLAUDE", "GEMINI")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 如果格式被允许,False 否则
|
||||||
|
"""
|
||||||
|
if self.allowed_api_formats is None:
|
||||||
|
return True
|
||||||
|
return api_format in self.allowed_api_formats
|
||||||
|
|
||||||
|
def is_model_allowed(self, model_id: str, provider_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
检查模型是否被允许访问
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: 模型 ID
|
||||||
|
provider_id: Provider ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 如果模型被允许,False 否则
|
||||||
|
"""
|
||||||
|
# 检查 Provider 限制
|
||||||
|
if self.allowed_providers is not None:
|
||||||
|
if provider_id not in self.allowed_providers:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查模型限制
|
||||||
|
if self.allowed_models is not None:
|
||||||
|
if model_id not in self.allowed_models:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_available_provider_ids(db: Session, api_formats: list[str]) -> set[str]:
|
def get_available_provider_ids(db: Session, api_formats: list[str]) -> set[str]:
|
||||||
"""
|
"""
|
||||||
返回有可用端点的 Provider IDs
|
返回有可用端点的 Provider IDs
|
||||||
@@ -218,6 +313,7 @@ def _extract_model_info(model: Any) -> ModelInfo:
|
|||||||
)
|
)
|
||||||
created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0
|
created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0
|
||||||
provider_name: str = model.provider.name if model.provider else "unknown"
|
provider_name: str = model.provider.name if model.provider else "unknown"
|
||||||
|
provider_id: str = model.provider_id or ""
|
||||||
|
|
||||||
# 从 GlobalModel.config 提取配置信息
|
# 从 GlobalModel.config 提取配置信息
|
||||||
config: dict = {}
|
config: dict = {}
|
||||||
@@ -233,6 +329,7 @@ def _extract_model_info(model: Any) -> ModelInfo:
|
|||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
created_timestamp=created_timestamp,
|
created_timestamp=created_timestamp,
|
||||||
provider_name=provider_name,
|
provider_name=provider_name,
|
||||||
|
provider_id=provider_id,
|
||||||
# 能力配置
|
# 能力配置
|
||||||
streaming=config.get("streaming", True),
|
streaming=config.get("streaming", True),
|
||||||
vision=config.get("vision", False),
|
vision=config.get("vision", False),
|
||||||
@@ -255,6 +352,7 @@ async def list_available_models(
|
|||||||
db: Session,
|
db: Session,
|
||||||
available_provider_ids: set[str],
|
available_provider_ids: set[str],
|
||||||
api_formats: Optional[list[str]] = None,
|
api_formats: Optional[list[str]] = None,
|
||||||
|
restrictions: Optional[AccessRestrictions] = None,
|
||||||
) -> list[ModelInfo]:
|
) -> list[ModelInfo]:
|
||||||
"""
|
"""
|
||||||
获取可用模型列表(已去重,带缓存)
|
获取可用模型列表(已去重,带缓存)
|
||||||
@@ -263,6 +361,7 @@ async def list_available_models(
|
|||||||
db: 数据库会话
|
db: 数据库会话
|
||||||
available_provider_ids: 有可用端点的 Provider ID 集合
|
available_provider_ids: 有可用端点的 Provider ID 集合
|
||||||
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
||||||
|
restrictions: API Key/User 的访问限制
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
去重后的 ModelInfo 列表,按创建时间倒序
|
去重后的 ModelInfo 列表,按创建时间倒序
|
||||||
@@ -270,8 +369,16 @@ async def list_available_models(
|
|||||||
if not available_provider_ids:
|
if not available_provider_ids:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# 缓存策略:只有完全无访问限制时才使用缓存
|
||||||
|
# - restrictions is None: 未传入限制对象
|
||||||
|
# - restrictions 的两个字段都为 None: 传入了限制对象但无实际限制
|
||||||
|
# 以上两种情况返回的结果相同,可以共享全局缓存
|
||||||
|
use_cache = restrictions is None or (
|
||||||
|
restrictions.allowed_providers is None and restrictions.allowed_models is None
|
||||||
|
)
|
||||||
|
|
||||||
# 尝试从缓存获取
|
# 尝试从缓存获取
|
||||||
if api_formats:
|
if api_formats and use_cache:
|
||||||
cached = await _get_cached_models(api_formats)
|
cached = await _get_cached_models(api_formats)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
@@ -306,14 +413,19 @@ async def list_available_models(
|
|||||||
if available_model_ids is not None and info.id not in available_model_ids:
|
if available_model_ids is not None and info.id not in available_model_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 检查 API Key/User 访问限制
|
||||||
|
if restrictions is not None:
|
||||||
|
if not restrictions.is_model_allowed(info.id, info.provider_id):
|
||||||
|
continue
|
||||||
|
|
||||||
if info.id in seen_model_ids:
|
if info.id in seen_model_ids:
|
||||||
continue
|
continue
|
||||||
seen_model_ids.add(info.id)
|
seen_model_ids.add(info.id)
|
||||||
|
|
||||||
result.append(info)
|
result.append(info)
|
||||||
|
|
||||||
# 写入缓存
|
# 只有无限制的情况才写入缓存
|
||||||
if api_formats:
|
if api_formats and use_cache:
|
||||||
await _set_cached_models(api_formats, result)
|
await _set_cached_models(api_formats, result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -324,6 +436,7 @@ def find_model_by_id(
|
|||||||
model_id: str,
|
model_id: str,
|
||||||
available_provider_ids: set[str],
|
available_provider_ids: set[str],
|
||||||
api_formats: Optional[list[str]] = None,
|
api_formats: Optional[list[str]] = None,
|
||||||
|
restrictions: Optional[AccessRestrictions] = None,
|
||||||
) -> Optional[ModelInfo]:
|
) -> Optional[ModelInfo]:
|
||||||
"""
|
"""
|
||||||
按 ID 查找模型
|
按 ID 查找模型
|
||||||
@@ -338,6 +451,7 @@ def find_model_by_id(
|
|||||||
model_id: 模型 ID
|
model_id: 模型 ID
|
||||||
available_provider_ids: 有可用端点的 Provider ID 集合
|
available_provider_ids: 有可用端点的 Provider ID 集合
|
||||||
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
||||||
|
restrictions: API Key/User 的访问限制
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ModelInfo 或 None
|
ModelInfo 或 None
|
||||||
@@ -353,6 +467,11 @@ def find_model_by_id(
|
|||||||
if available_model_ids is not None and model_id not in available_model_ids:
|
if available_model_ids is not None and model_id not in available_model_ids:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# 快速检查:如果 restrictions 明确限制了模型列表且目标模型不在其中,直接返回 None
|
||||||
|
if restrictions is not None and restrictions.allowed_models is not None:
|
||||||
|
if model_id not in restrictions.allowed_models:
|
||||||
|
return None
|
||||||
|
|
||||||
# 先按 GlobalModel.name 查找
|
# 先按 GlobalModel.name 查找
|
||||||
models_by_global = (
|
models_by_global = (
|
||||||
db.query(Model)
|
db.query(Model)
|
||||||
@@ -368,8 +487,19 @@ def find_model_by_id(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_model_accessible(m: Model) -> bool:
|
||||||
|
"""检查模型是否可访问"""
|
||||||
|
if m.provider_id not in available_provider_ids:
|
||||||
|
return False
|
||||||
|
# 检查 API Key/User 访问限制
|
||||||
|
if restrictions is not None:
|
||||||
|
provider_id = m.provider_id or ""
|
||||||
|
if not restrictions.is_model_allowed(model_id, provider_id):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
model = next(
|
model = next(
|
||||||
(m for m in models_by_global if m.provider_id in available_provider_ids),
|
(m for m in models_by_global if is_model_accessible(m)),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -393,7 +523,7 @@ def find_model_by_id(
|
|||||||
)
|
)
|
||||||
|
|
||||||
model = next(
|
model = next(
|
||||||
(m for m in models_by_provider_name if m.provider_id in available_provider_ids),
|
(m for m in models_by_provider_name if is_model_accessible(m)),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ 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.enums import UserRole
|
from src.core.enums import UserRole
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, Usage
|
from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, StatsDailyModel, Usage
|
||||||
from src.models.database import User as DBUser
|
from src.models.database import User as DBUser
|
||||||
from src.services.system.stats_aggregator import StatsAggregatorService
|
from src.services.system.stats_aggregator import StatsAggregatorService
|
||||||
from src.utils.cache_decorator import cache_result
|
from src.utils.cache_decorator import cache_result
|
||||||
@@ -118,7 +118,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
|
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
|
||||||
today = today_local.astimezone(timezone.utc)
|
today = today_local.astimezone(timezone.utc)
|
||||||
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
||||||
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
|
# 本月第一天(自然月)
|
||||||
|
month_start_local = today_local.replace(day=1)
|
||||||
|
month_start = month_start_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
# ==================== 使用预聚合数据 ====================
|
# ==================== 使用预聚合数据 ====================
|
||||||
# 从 stats_summary + 今日实时数据获取全局统计
|
# 从 stats_summary + 今日实时数据获取全局统计
|
||||||
@@ -208,7 +210,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
|
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
|
||||||
func.sum(StatsDaily.fallback_count).label("fallback_count"),
|
func.sum(StatsDaily.fallback_count).label("fallback_count"),
|
||||||
)
|
)
|
||||||
.filter(StatsDaily.date >= last_month, StatsDaily.date < today)
|
.filter(StatsDaily.date >= month_start, StatsDaily.date < today)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,24 +229,24 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
else:
|
else:
|
||||||
# 回退到实时查询(没有预聚合数据时)
|
# 回退到实时查询(没有预聚合数据时)
|
||||||
total_requests = (
|
total_requests = (
|
||||||
db.query(func.count(Usage.id)).filter(Usage.created_at >= last_month).scalar() or 0
|
db.query(func.count(Usage.id)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||||
)
|
)
|
||||||
total_cost = (
|
total_cost = (
|
||||||
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= last_month).scalar() or 0
|
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||||
)
|
)
|
||||||
total_actual_cost = (
|
total_actual_cost = (
|
||||||
db.query(func.sum(Usage.actual_total_cost_usd))
|
db.query(func.sum(Usage.actual_total_cost_usd))
|
||||||
.filter(Usage.created_at >= last_month).scalar() or 0
|
.filter(Usage.created_at >= month_start).scalar() or 0
|
||||||
)
|
)
|
||||||
error_requests = (
|
error_requests = (
|
||||||
db.query(func.count(Usage.id))
|
db.query(func.count(Usage.id))
|
||||||
.filter(
|
.filter(
|
||||||
Usage.created_at >= last_month,
|
Usage.created_at >= month_start,
|
||||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
|
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
)
|
)
|
||||||
total_tokens = (
|
total_tokens = (
|
||||||
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= last_month).scalar() or 0
|
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||||
)
|
)
|
||||||
cache_stats = (
|
cache_stats = (
|
||||||
db.query(
|
db.query(
|
||||||
@@ -253,7 +255,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
||||||
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
||||||
)
|
)
|
||||||
.filter(Usage.created_at >= last_month)
|
.filter(Usage.created_at >= month_start)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
||||||
@@ -267,7 +269,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
|
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
RequestCandidate.created_at >= last_month,
|
RequestCandidate.created_at >= month_start,
|
||||||
RequestCandidate.status.in_(["success", "failed"]),
|
RequestCandidate.status.in_(["success", "failed"]),
|
||||||
)
|
)
|
||||||
.group_by(RequestCandidate.request_id)
|
.group_by(RequestCandidate.request_id)
|
||||||
@@ -447,7 +449,9 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
# 转换为 UTC 用于数据库查询
|
# 转换为 UTC 用于数据库查询
|
||||||
today = today_local.astimezone(timezone.utc)
|
today = today_local.astimezone(timezone.utc)
|
||||||
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
||||||
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
|
# 本月第一天(自然月)
|
||||||
|
month_start_local = today_local.replace(day=1)
|
||||||
|
month_start = month_start_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
|
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
|
||||||
active_keys = (
|
active_keys = (
|
||||||
@@ -483,12 +487,12 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
# 本月请求统计
|
# 本月请求统计
|
||||||
user_requests = (
|
user_requests = (
|
||||||
db.query(func.count(Usage.id))
|
db.query(func.count(Usage.id))
|
||||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||||
.scalar()
|
.scalar()
|
||||||
)
|
)
|
||||||
user_cost = (
|
user_cost = (
|
||||||
db.query(func.sum(Usage.total_cost_usd))
|
db.query(func.sum(Usage.total_cost_usd))
|
||||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
@@ -532,18 +536,19 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
||||||
func.sum(Usage.input_tokens).label("total_input_tokens"),
|
func.sum(Usage.input_tokens).label("total_input_tokens"),
|
||||||
)
|
)
|
||||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
||||||
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
|
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
|
||||||
|
monthly_input_tokens = int(cache_stats.total_input_tokens or 0) if cache_stats else 0
|
||||||
|
|
||||||
# 计算缓存命中率:cache_read / (input_tokens + cache_read)
|
# 计算本月缓存命中率:cache_read / (input_tokens + cache_read)
|
||||||
# input_tokens 是实际发送给模型的输入(不含缓存读取),cache_read 是从缓存读取的
|
# input_tokens 是实际发送给模型的输入(不含缓存读取),cache_read 是从缓存读取的
|
||||||
# 总输入 = input_tokens + cache_read,缓存命中率 = cache_read / 总输入
|
# 总输入 = input_tokens + cache_read,缓存命中率 = cache_read / 总输入
|
||||||
total_input_with_cache = all_time_input_tokens + all_time_cache_read
|
total_input_with_cache = monthly_input_tokens + cache_read_tokens
|
||||||
cache_hit_rate = (
|
cache_hit_rate = (
|
||||||
round((all_time_cache_read / total_input_with_cache) * 100, 1)
|
round((cache_read_tokens / total_input_with_cache) * 100, 1)
|
||||||
if total_input_with_cache > 0
|
if total_input_with_cache > 0
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
@@ -569,15 +574,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
quota_value = "无限制"
|
quota_value = "无限制"
|
||||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||||
quota_high = False
|
quota_high = False
|
||||||
elif user.quota_usd and user.quota_usd > 0:
|
elif user.quota_usd > 0:
|
||||||
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
|
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
|
||||||
quota_value = "无限制"
|
quota_value = f"${user.quota_usd:.0f}"
|
||||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||||
quota_high = percent > 80
|
quota_high = percent > 80
|
||||||
else:
|
else:
|
||||||
quota_value = "0%"
|
quota_value = "$0"
|
||||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||||
quota_high = False
|
quota_high = True
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"stats": [
|
"stats": [
|
||||||
@@ -605,9 +610,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
"icon": "TrendingUp",
|
"icon": "TrendingUp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "本月费用",
|
"name": "总Token",
|
||||||
"value": f"${user_cost:.2f}",
|
"value": format_tokens(
|
||||||
"icon": "DollarSign",
|
all_time_input_tokens
|
||||||
|
+ all_time_output_tokens
|
||||||
|
+ all_time_cache_creation
|
||||||
|
+ all_time_cache_read
|
||||||
|
),
|
||||||
|
"subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}",
|
||||||
|
"icon": "Hash",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"today": {
|
"today": {
|
||||||
@@ -631,6 +642,8 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
"cache_hit_rate": cache_hit_rate,
|
"cache_hit_rate": cache_hit_rate,
|
||||||
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
|
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
|
||||||
},
|
},
|
||||||
|
# 本月费用(用于下方缓存区域显示)
|
||||||
|
"monthly_cost": float(user_cost),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -893,12 +906,115 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
|
|||||||
})
|
})
|
||||||
current_date += timedelta(days=1)
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
# ==================== 模型统计(仍需实时查询)====================
|
# ==================== 模型统计 ====================
|
||||||
model_query = db.query(Usage)
|
if is_admin:
|
||||||
if not is_admin:
|
# 管理员:使用预聚合数据 + 今日实时数据
|
||||||
model_query = model_query.filter(Usage.user_id == user.id)
|
# 历史数据从 stats_daily_model 获取
|
||||||
model_query = model_query.filter(
|
historical_model_stats = (
|
||||||
and_(Usage.created_at >= start_date, Usage.created_at <= end_date)
|
db.query(StatsDailyModel)
|
||||||
|
.filter(and_(StatsDailyModel.date >= start_date, StatsDailyModel.date < today))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 按模型汇总历史数据
|
||||||
|
model_agg: dict = {}
|
||||||
|
daily_breakdown: dict = {}
|
||||||
|
|
||||||
|
for stat in historical_model_stats:
|
||||||
|
model = stat.model
|
||||||
|
if model not in model_agg:
|
||||||
|
model_agg[model] = {
|
||||||
|
"requests": 0, "tokens": 0, "cost": 0.0,
|
||||||
|
"total_response_time": 0.0, "response_count": 0
|
||||||
|
}
|
||||||
|
model_agg[model]["requests"] += stat.total_requests
|
||||||
|
tokens = (stat.input_tokens + stat.output_tokens +
|
||||||
|
stat.cache_creation_tokens + stat.cache_read_tokens)
|
||||||
|
model_agg[model]["tokens"] += tokens
|
||||||
|
model_agg[model]["cost"] += stat.total_cost
|
||||||
|
if stat.avg_response_time_ms is not None:
|
||||||
|
model_agg[model]["total_response_time"] += stat.avg_response_time_ms * stat.total_requests
|
||||||
|
model_agg[model]["response_count"] += stat.total_requests
|
||||||
|
|
||||||
|
# 按日期分组
|
||||||
|
if stat.date.tzinfo is None:
|
||||||
|
date_utc = stat.date.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
date_utc = stat.date.astimezone(timezone.utc)
|
||||||
|
date_str = date_utc.astimezone(app_tz).date().isoformat()
|
||||||
|
|
||||||
|
daily_breakdown.setdefault(date_str, []).append({
|
||||||
|
"model": model,
|
||||||
|
"requests": stat.total_requests,
|
||||||
|
"tokens": tokens,
|
||||||
|
"cost": stat.total_cost,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 今日实时模型统计
|
||||||
|
today_model_stats = (
|
||||||
|
db.query(
|
||||||
|
Usage.model,
|
||||||
|
func.count(Usage.id).label("requests"),
|
||||||
|
func.sum(Usage.total_tokens).label("tokens"),
|
||||||
|
func.sum(Usage.total_cost_usd).label("cost"),
|
||||||
|
func.avg(Usage.response_time_ms).label("avg_response_time"),
|
||||||
|
)
|
||||||
|
.filter(Usage.created_at >= today)
|
||||||
|
.group_by(Usage.model)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
today_str = today_local.date().isoformat()
|
||||||
|
for stat in today_model_stats:
|
||||||
|
model = stat.model
|
||||||
|
if model not in model_agg:
|
||||||
|
model_agg[model] = {
|
||||||
|
"requests": 0, "tokens": 0, "cost": 0.0,
|
||||||
|
"total_response_time": 0.0, "response_count": 0
|
||||||
|
}
|
||||||
|
model_agg[model]["requests"] += stat.requests or 0
|
||||||
|
model_agg[model]["tokens"] += int(stat.tokens or 0)
|
||||||
|
model_agg[model]["cost"] += float(stat.cost or 0)
|
||||||
|
if stat.avg_response_time is not None:
|
||||||
|
model_agg[model]["total_response_time"] += float(stat.avg_response_time) * (stat.requests or 0)
|
||||||
|
model_agg[model]["response_count"] += stat.requests or 0
|
||||||
|
|
||||||
|
# 今日 breakdown
|
||||||
|
daily_breakdown.setdefault(today_str, []).append({
|
||||||
|
"model": model,
|
||||||
|
"requests": stat.requests or 0,
|
||||||
|
"tokens": int(stat.tokens or 0),
|
||||||
|
"cost": float(stat.cost or 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 构建 model_summary
|
||||||
|
model_summary = []
|
||||||
|
for model, agg in model_agg.items():
|
||||||
|
avg_rt = (agg["total_response_time"] / agg["response_count"] / 1000.0
|
||||||
|
if agg["response_count"] > 0 else 0)
|
||||||
|
model_summary.append({
|
||||||
|
"model": model,
|
||||||
|
"requests": agg["requests"],
|
||||||
|
"tokens": agg["tokens"],
|
||||||
|
"cost": agg["cost"],
|
||||||
|
"avg_response_time": avg_rt,
|
||||||
|
"cost_per_request": agg["cost"] / max(agg["requests"], 1),
|
||||||
|
"tokens_per_request": agg["tokens"] / max(agg["requests"], 1),
|
||||||
|
})
|
||||||
|
model_summary.sort(key=lambda x: x["cost"], reverse=True)
|
||||||
|
|
||||||
|
# 填充 model_breakdown
|
||||||
|
for item in formatted:
|
||||||
|
item["model_breakdown"] = daily_breakdown.get(item["date"], [])
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 普通用户:实时查询(数据量较小)
|
||||||
|
model_query = db.query(Usage).filter(
|
||||||
|
and_(
|
||||||
|
Usage.user_id == user.id,
|
||||||
|
Usage.created_at >= start_date,
|
||||||
|
Usage.created_at <= end_date
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
model_stats = (
|
model_stats = (
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ if TYPE_CHECKING:
|
|||||||
from src.api.handlers.base.stream_context import StreamContext
|
from src.api.handlers.base.stream_context import StreamContext
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class MessageTelemetry:
|
class MessageTelemetry:
|
||||||
"""
|
"""
|
||||||
负责记录 Usage/Audit,避免处理器里重复代码。
|
负责记录 Usage/Audit,避免处理器里重复代码。
|
||||||
@@ -376,6 +375,9 @@ class BaseMessageHandler:
|
|||||||
|
|
||||||
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
|
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
|
||||||
|
|
||||||
|
注意:TTFB(首字节时间)由 StreamContext.record_first_byte_time() 记录,
|
||||||
|
并在最终 record_success 时传递到数据库,避免重复记录导致数据不一致。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request_id: 请求 ID,如果不传则使用 self.request_id
|
request_id: 请求 ID,如果不传则使用 self.request_id
|
||||||
"""
|
"""
|
||||||
@@ -403,12 +405,15 @@ class BaseMessageHandler:
|
|||||||
asyncio.create_task(_do_update())
|
asyncio.create_task(_do_update())
|
||||||
|
|
||||||
def _update_usage_to_streaming_with_ctx(self, ctx: "StreamContext") -> None:
|
def _update_usage_to_streaming_with_ctx(self, ctx: "StreamContext") -> None:
|
||||||
"""更新 Usage 状态为 streaming,同时更新 provider 和 target_model
|
"""更新 Usage 状态为 streaming,同时更新 provider 相关信息
|
||||||
|
|
||||||
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
|
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
|
||||||
|
|
||||||
|
注意:TTFB(首字节时间)由 StreamContext.record_first_byte_time() 记录,
|
||||||
|
并在最终 record_success 时传递到数据库,避免重复记录导致数据不一致。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: 流式上下文,包含 provider_name 和 mapped_model
|
ctx: 流式上下文,包含 provider 相关信息
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from src.database.database import get_db
|
from src.database.database import get_db
|
||||||
@@ -416,6 +421,17 @@ class BaseMessageHandler:
|
|||||||
target_request_id = self.request_id
|
target_request_id = self.request_id
|
||||||
provider = ctx.provider_name
|
provider = ctx.provider_name
|
||||||
target_model = ctx.mapped_model
|
target_model = ctx.mapped_model
|
||||||
|
provider_id = ctx.provider_id
|
||||||
|
endpoint_id = ctx.endpoint_id
|
||||||
|
key_id = ctx.key_id
|
||||||
|
first_byte_time_ms = ctx.first_byte_time_ms
|
||||||
|
|
||||||
|
# 如果 provider 为空,记录警告(不应该发生,但用于调试)
|
||||||
|
if not provider:
|
||||||
|
logger.warning(
|
||||||
|
f"[{target_request_id}] 更新 streaming 状态时 provider 为空: "
|
||||||
|
f"ctx.provider_name={ctx.provider_name}, ctx.provider_id={ctx.provider_id}"
|
||||||
|
)
|
||||||
|
|
||||||
async def _do_update() -> None:
|
async def _do_update() -> None:
|
||||||
try:
|
try:
|
||||||
@@ -428,6 +444,10 @@ class BaseMessageHandler:
|
|||||||
status="streaming",
|
status="streaming",
|
||||||
provider=provider,
|
provider=provider,
|
||||||
target_model=target_model,
|
target_model=target_model,
|
||||||
|
provider_id=provider_id,
|
||||||
|
provider_endpoint_id=endpoint_id,
|
||||||
|
provider_api_key_id=key_id,
|
||||||
|
first_byte_time_ms=first_byte_time_ms,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ Chat Adapter 通用基类
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import Any, Dict, Optional, Type
|
from typing import Any, Dict, Optional, Tuple, Type
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
@@ -62,6 +63,34 @@ class ChatAdapterBase(ApiAdapter):
|
|||||||
name: str = "chat.base"
|
name: str = "chat.base"
|
||||||
mode = ApiMode.STANDARD
|
mode = ApiMode.STANDARD
|
||||||
|
|
||||||
|
# 子类可以配置的特殊方法(用于check_endpoint)
|
||||||
|
@classmethod
|
||||||
|
def build_endpoint_url(cls, base_url: str) -> str:
|
||||||
|
"""构建端点URL,子类可以覆盖以自定义URL构建逻辑"""
|
||||||
|
# 默认实现:在base_url后添加特定路径
|
||||||
|
return base_url
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||||
|
"""构建基础请求头,子类可以覆盖以自定义认证头"""
|
||||||
|
# 默认实现:Bearer token认证
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_protected_header_keys(cls) -> tuple:
|
||||||
|
"""返回不应被extra_headers覆盖的头部key,子类可以覆盖"""
|
||||||
|
# 默认保护认证相关头部
|
||||||
|
return ("authorization", "content-type")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""构建请求体,子类可以覆盖以自定义请求格式转换"""
|
||||||
|
# 默认实现:直接使用请求数据
|
||||||
|
return request_data.copy()
|
||||||
|
|
||||||
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
|
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
|
||||||
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
|
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
|
||||||
|
|
||||||
@@ -620,6 +649,98 @@ class ChatAdapterBase(ApiAdapter):
|
|||||||
# 如果所有阶梯都有上限且都超过了,返回最后一个阶梯
|
# 如果所有阶梯都有上限且都超过了,返回最后一个阶梯
|
||||||
return tiers[-1] if tiers else None
|
return tiers[-1] if tiers else None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 模型列表查询 - 子类应覆盖此方法
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_models(
|
||||||
|
cls,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
extra_headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Tuple[list, Optional[str]]:
|
||||||
|
"""
|
||||||
|
查询上游 API 支持的模型列表
|
||||||
|
|
||||||
|
这是 Aether 内部发起的请求(非用户透传),用于:
|
||||||
|
- 管理后台查询提供商支持的模型
|
||||||
|
- 自动发现可用模型
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: httpx 异步客户端
|
||||||
|
base_url: API 基础 URL
|
||||||
|
api_key: API 密钥(已解密)
|
||||||
|
extra_headers: 端点配置的额外请求头
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(models, error): 模型列表和错误信息
|
||||||
|
- models: 模型信息列表,每个模型至少包含 id 字段
|
||||||
|
- error: 错误信息,成功时为 None
|
||||||
|
"""
|
||||||
|
# 默认实现返回空列表,子类应覆盖
|
||||||
|
return [], f"{cls.FORMAT_ID} adapter does not implement fetch_models"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def check_endpoint(
|
||||||
|
cls,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
request_data: Dict[str, Any],
|
||||||
|
extra_headers: Optional[Dict[str, str]] = None,
|
||||||
|
# 用量计算参数(现在强制记录)
|
||||||
|
db: Optional[Any] = None,
|
||||||
|
user: Optional[Any] = None,
|
||||||
|
provider_name: Optional[str] = None,
|
||||||
|
provider_id: Optional[str] = None,
|
||||||
|
api_key_id: Optional[str] = None,
|
||||||
|
model_name: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
测试模型连接性(非流式)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: httpx 异步客户端
|
||||||
|
base_url: API 基础 URL
|
||||||
|
api_key: API 密钥(已解密)
|
||||||
|
request_data: 请求数据
|
||||||
|
extra_headers: 端点配置的额外请求头
|
||||||
|
db: 数据库会话
|
||||||
|
user: 用户对象
|
||||||
|
provider_name: 提供商名称
|
||||||
|
provider_id: 提供商ID
|
||||||
|
api_key_id: API Key ID
|
||||||
|
model_name: 模型名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
测试响应数据
|
||||||
|
"""
|
||||||
|
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
|
||||||
|
|
||||||
|
# 使用子类配置方法构建请求组件
|
||||||
|
url = cls.build_endpoint_url(base_url)
|
||||||
|
base_headers = cls.build_base_headers(api_key)
|
||||||
|
protected_keys = cls.get_protected_header_keys()
|
||||||
|
headers = build_safe_headers(base_headers, extra_headers, protected_keys)
|
||||||
|
body = cls.build_request_body(request_data)
|
||||||
|
|
||||||
|
# 使用通用的endpoint checker执行请求
|
||||||
|
return await run_endpoint_check(
|
||||||
|
client=client,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
json_body=body,
|
||||||
|
api_format=cls.name,
|
||||||
|
# 用量计算参数(现在强制记录)
|
||||||
|
db=db,
|
||||||
|
user=user,
|
||||||
|
provider_name=provider_name,
|
||||||
|
provider_id=provider_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
model_name=model_name or request_data.get("model"),
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Adapter 注册表 - 用于根据 API format 获取 Adapter 实例
|
# Adapter 注册表 - 用于根据 API format 获取 Adapter 实例
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from src.api.handlers.base.stream_processor import StreamProcessor
|
|||||||
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
|
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
|
||||||
from src.api.handlers.base.utils import build_sse_headers
|
from src.api.handlers.base.utils import build_sse_headers
|
||||||
from src.config.settings import config
|
from src.config.settings import config
|
||||||
|
from src.core.error_utils import extract_error_message
|
||||||
from src.core.exceptions import (
|
from src.core.exceptions import (
|
||||||
EmbeddedErrorException,
|
EmbeddedErrorException,
|
||||||
ProviderAuthException,
|
ProviderAuthException,
|
||||||
@@ -260,9 +261,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
mapping = await mapper.get_mapping(source_model, provider_id)
|
mapping = await mapper.get_mapping(source_model, provider_id)
|
||||||
|
|
||||||
if mapping and mapping.model:
|
if mapping and mapping.model:
|
||||||
# 使用 select_provider_model_name 支持别名功能
|
# 使用 select_provider_model_name 支持映射功能
|
||||||
# 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一别名
|
# 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一映射
|
||||||
# 传入 api_format 用于过滤适用的别名作用域
|
# 传入 api_format 用于过滤适用的映射作用域
|
||||||
affinity_key = self.api_key.id if self.api_key else None
|
affinity_key = self.api_key.id if self.api_key else None
|
||||||
mapped_name = mapping.model.select_provider_model_name(
|
mapped_name = mapping.model.select_provider_model_name(
|
||||||
affinity_key, api_format=self.FORMAT_ID
|
affinity_key, api_format=self.FORMAT_ID
|
||||||
@@ -484,9 +485,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
|
|
||||||
stream_response.raise_for_status()
|
stream_response.raise_for_status()
|
||||||
|
|
||||||
# 使用字节流迭代器(避免 aiter_lines 的性能问题)
|
# 使用字节流迭代器(避免 aiter_lines 的性能问题, aiter_bytes 会自动解压 gzip/deflate)
|
||||||
# aiter_raw() 返回原始数据块,无缓冲,实现真正的流式传输
|
byte_iterator = stream_response.aiter_bytes()
|
||||||
byte_iterator = stream_response.aiter_raw()
|
|
||||||
|
|
||||||
# 预读检测嵌套错误
|
# 预读检测嵌套错误
|
||||||
prefetched_chunks = await stream_processor.prefetch_and_check_error(
|
prefetched_chunks = await stream_processor.prefetch_and_check_error(
|
||||||
@@ -501,6 +501,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
error_text = await self._extract_error_text(e)
|
error_text = await self._extract_error_text(e)
|
||||||
logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}")
|
logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}")
|
||||||
await http_client.aclose()
|
await http_client.aclose()
|
||||||
|
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
|
||||||
|
e.upstream_response = error_text # type: ignore[attr-defined]
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except EmbeddedErrorException:
|
except EmbeddedErrorException:
|
||||||
@@ -550,7 +552,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
model=ctx.model,
|
model=ctx.model,
|
||||||
response_time_ms=response_time_ms,
|
response_time_ms=response_time_ms,
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
error_message=str(error),
|
error_message=extract_error_message(error),
|
||||||
request_headers=original_headers,
|
request_headers=original_headers,
|
||||||
request_body=actual_request_body,
|
request_body=actual_request_body,
|
||||||
is_stream=True,
|
is_stream=True,
|
||||||
@@ -786,7 +788,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
model=model,
|
model=model,
|
||||||
response_time_ms=response_time_ms,
|
response_time_ms=response_time_ms,
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
error_message=str(e),
|
error_message=extract_error_message(e),
|
||||||
request_headers=original_headers,
|
request_headers=original_headers,
|
||||||
request_body=actual_request_body,
|
request_body=actual_request_body,
|
||||||
is_stream=False,
|
is_stream=False,
|
||||||
@@ -803,10 +805,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
try:
|
try:
|
||||||
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
|
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
|
||||||
error_bytes = await e.response.aread()
|
error_bytes = await e.response.aread()
|
||||||
return error_bytes.decode("utf-8", errors="replace")[:500]
|
return error_bytes.decode("utf-8", errors="replace")
|
||||||
else:
|
else:
|
||||||
return (
|
return (
|
||||||
e.response.text[:500] if hasattr(e.response, "_content") else "Unable to read"
|
e.response.text if hasattr(e.response, "_content") else "Unable to read"
|
||||||
)
|
)
|
||||||
except Exception as decode_error:
|
except Exception as decode_error:
|
||||||
return f"Unable to read error: {decode_error}"
|
return f"Unable to read error: {decode_error}"
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ CLI Adapter 通用基类
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Any, Dict, Optional, Type
|
from typing import Any, Dict, Optional, Tuple, Type
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
@@ -580,6 +581,179 @@ class CliAdapterBase(ApiAdapter):
|
|||||||
|
|
||||||
return tiers[-1] if tiers else None
|
return tiers[-1] if tiers else None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 模型列表查询 - 子类应覆盖此方法
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_models(
|
||||||
|
cls,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
extra_headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Tuple[list, Optional[str]]:
|
||||||
|
"""
|
||||||
|
查询上游 API 支持的模型列表
|
||||||
|
|
||||||
|
这是 Aether 内部发起的请求(非用户透传),用于:
|
||||||
|
- 管理后台查询提供商支持的模型
|
||||||
|
- 自动发现可用模型
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: httpx 异步客户端
|
||||||
|
base_url: API 基础 URL
|
||||||
|
api_key: API 密钥(已解密)
|
||||||
|
extra_headers: 端点配置的额外请求头
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(models, error): 模型列表和错误信息
|
||||||
|
- models: 模型信息列表,每个模型至少包含 id 字段
|
||||||
|
- error: 错误信息,成功时为 None
|
||||||
|
"""
|
||||||
|
# 默认实现返回空列表,子类应覆盖
|
||||||
|
return [], f"{cls.FORMAT_ID} adapter does not implement fetch_models"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def check_endpoint(
|
||||||
|
cls,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
request_data: Dict[str, Any],
|
||||||
|
extra_headers: Optional[Dict[str, str]] = None,
|
||||||
|
# 用量计算参数
|
||||||
|
db: Optional[Any] = None,
|
||||||
|
user: Optional[Any] = None,
|
||||||
|
provider_name: Optional[str] = None,
|
||||||
|
provider_id: Optional[str] = None,
|
||||||
|
api_key_id: Optional[str] = None,
|
||||||
|
model_name: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
测试模型连接性(非流式)
|
||||||
|
|
||||||
|
通用的CLI endpoint测试方法,使用配置方法模式:
|
||||||
|
- build_endpoint_url(): 构建请求URL
|
||||||
|
- build_base_headers(): 构建基础认证头
|
||||||
|
- get_protected_header_keys(): 获取受保护的头部key
|
||||||
|
- build_request_body(): 构建请求体
|
||||||
|
- get_cli_user_agent(): 获取CLI User-Agent(子类可覆盖)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: httpx 异步客户端
|
||||||
|
base_url: API 基础 URL
|
||||||
|
api_key: API 密钥(已解密)
|
||||||
|
request_data: 请求数据
|
||||||
|
extra_headers: 端点配置的额外请求头
|
||||||
|
db: 数据库会话
|
||||||
|
user: 用户对象
|
||||||
|
provider_name: 提供商名称
|
||||||
|
provider_id: 提供商ID
|
||||||
|
api_key_id: API密钥ID
|
||||||
|
model_name: 模型名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
测试响应数据
|
||||||
|
"""
|
||||||
|
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
|
||||||
|
|
||||||
|
# 构建请求组件
|
||||||
|
url = cls.build_endpoint_url(base_url, request_data, model_name)
|
||||||
|
base_headers = cls.build_base_headers(api_key)
|
||||||
|
protected_keys = cls.get_protected_header_keys()
|
||||||
|
|
||||||
|
# 添加CLI User-Agent
|
||||||
|
cli_user_agent = cls.get_cli_user_agent()
|
||||||
|
if cli_user_agent:
|
||||||
|
base_headers["User-Agent"] = cli_user_agent
|
||||||
|
protected_keys = tuple(list(protected_keys) + ["user-agent"])
|
||||||
|
|
||||||
|
headers = build_safe_headers(base_headers, extra_headers, protected_keys)
|
||||||
|
body = cls.build_request_body(request_data)
|
||||||
|
|
||||||
|
# 获取有效的模型名称
|
||||||
|
effective_model_name = model_name or request_data.get("model")
|
||||||
|
|
||||||
|
return await run_endpoint_check(
|
||||||
|
client=client,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
json_body=body,
|
||||||
|
api_format=cls.name,
|
||||||
|
# 用量计算参数(现在强制记录)
|
||||||
|
db=db,
|
||||||
|
user=user,
|
||||||
|
provider_name=provider_name,
|
||||||
|
provider_id=provider_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
model_name=effective_model_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CLI Adapter 配置方法 - 子类应覆盖这些方法而不是整个 check_endpoint
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_endpoint_url(cls, base_url: str, request_data: Dict[str, Any], model_name: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
构建CLI API端点URL - 子类应覆盖
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: API基础URL
|
||||||
|
request_data: 请求数据
|
||||||
|
model_name: 模型名称(某些API需要,如Gemini)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
完整的端点URL
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_endpoint_url")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
构建CLI API认证头 - 子类应覆盖
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API密钥
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
基础认证头部字典
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_base_headers")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_protected_header_keys(cls) -> tuple:
|
||||||
|
"""
|
||||||
|
返回CLI API的保护头部key - 子类应覆盖
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
保护头部key的元组
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement get_protected_header_keys")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
构建CLI API请求体 - 子类应覆盖
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_data: 请求数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
请求体字典
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_request_body")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_cli_user_agent(cls) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取CLI User-Agent - 子类可覆盖
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CLI User-Agent字符串,如果不需要则为None
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# CLI Adapter 注册表 - 用于根据 API format 获取 CLI Adapter 实例
|
# CLI Adapter 注册表 - 用于根据 API format 获取 CLI Adapter 实例
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ from src.api.handlers.base.base_handler import (
|
|||||||
from src.api.handlers.base.parsers import get_parser_for_format
|
from src.api.handlers.base.parsers import get_parser_for_format
|
||||||
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
|
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
|
||||||
from src.api.handlers.base.stream_context import StreamContext
|
from src.api.handlers.base.stream_context import StreamContext
|
||||||
from src.api.handlers.base.utils import build_sse_headers
|
from src.api.handlers.base.utils import (
|
||||||
|
build_sse_headers,
|
||||||
|
check_html_response,
|
||||||
|
check_prefetched_response_error,
|
||||||
|
)
|
||||||
|
from src.core.error_utils import extract_error_message
|
||||||
|
|
||||||
# 直接从具体模块导入,避免循环依赖
|
# 直接从具体模块导入,避免循环依赖
|
||||||
from src.api.handlers.base.response_parser import (
|
from src.api.handlers.base.response_parser import (
|
||||||
@@ -57,8 +62,11 @@ from src.models.database import (
|
|||||||
ProviderEndpoint,
|
ProviderEndpoint,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from src.config.constants import StreamDefaults
|
||||||
|
from src.config.settings import config
|
||||||
from src.services.provider.transport import build_provider_url
|
from src.services.provider.transport import build_provider_url
|
||||||
from src.utils.sse_parser import SSEEventParser
|
from src.utils.sse_parser import SSEEventParser
|
||||||
|
from src.utils.timeout import read_first_chunk_with_ttfb_timeout
|
||||||
|
|
||||||
|
|
||||||
class CliMessageHandlerBase(BaseMessageHandler):
|
class CliMessageHandlerBase(BaseMessageHandler):
|
||||||
@@ -136,7 +144,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
查找逻辑:
|
查找逻辑:
|
||||||
1. 直接通过 GlobalModel.name 匹配
|
1. 直接通过 GlobalModel.name 匹配
|
||||||
2. 查找该 Provider 的 Model 实现
|
2. 查找该 Provider 的 Model 实现
|
||||||
3. 使用 provider_model_name / provider_model_aliases 选择最终名称
|
3. 使用 provider_model_name / provider_model_mappings 选择最终名称
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_model: 用户请求的模型名(必须是 GlobalModel.name)
|
source_model: 用户请求的模型名(必须是 GlobalModel.name)
|
||||||
@@ -153,9 +161,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
logger.debug(f"[CLI] _get_mapped_model: source={source_model}, provider={provider_id[:8]}..., mapping={mapping}")
|
logger.debug(f"[CLI] _get_mapped_model: source={source_model}, provider={provider_id[:8]}..., mapping={mapping}")
|
||||||
|
|
||||||
if mapping and mapping.model:
|
if mapping and mapping.model:
|
||||||
# 使用 select_provider_model_name 支持别名功能
|
# 使用 select_provider_model_name 支持模型映射功能
|
||||||
# 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一别名
|
# 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一映射
|
||||||
# 传入 api_format 用于过滤适用的别名作用域
|
# 传入 api_format 用于过滤适用的映射作用域
|
||||||
affinity_key = self.api_key.id if self.api_key else None
|
affinity_key = self.api_key.id if self.api_key else None
|
||||||
mapped_name = mapping.model.select_provider_model_name(
|
mapped_name = mapping.model.select_provider_model_name(
|
||||||
affinity_key, api_format=self.FORMAT_ID
|
affinity_key, api_format=self.FORMAT_ID
|
||||||
@@ -326,9 +334,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
stream_generator,
|
stream_generator,
|
||||||
provider_name,
|
provider_name,
|
||||||
attempt_id,
|
attempt_id,
|
||||||
_provider_id,
|
provider_id,
|
||||||
_endpoint_id,
|
endpoint_id,
|
||||||
_key_id,
|
key_id,
|
||||||
) = await self.orchestrator.execute_with_fallback(
|
) = await self.orchestrator.execute_with_fallback(
|
||||||
api_format=ctx.api_format,
|
api_format=ctx.api_format,
|
||||||
model_name=ctx.model,
|
model_name=ctx.model,
|
||||||
@@ -338,7 +346,17 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
is_stream=True,
|
is_stream=True,
|
||||||
capability_requirements=capability_requirements or None,
|
capability_requirements=capability_requirements or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 更新上下文(确保 provider 信息已设置,用于 streaming 状态更新)
|
||||||
ctx.attempt_id = attempt_id
|
ctx.attempt_id = attempt_id
|
||||||
|
if not ctx.provider_name:
|
||||||
|
ctx.provider_name = provider_name
|
||||||
|
if not ctx.provider_id:
|
||||||
|
ctx.provider_id = provider_id
|
||||||
|
if not ctx.endpoint_id:
|
||||||
|
ctx.endpoint_id = endpoint_id
|
||||||
|
if not ctx.key_id:
|
||||||
|
ctx.key_id = key_id
|
||||||
|
|
||||||
# 创建后台任务记录统计
|
# 创建后台任务记录统计
|
||||||
background_tasks = BackgroundTasks()
|
background_tasks = BackgroundTasks()
|
||||||
@@ -400,7 +418,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
ctx.provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
ctx.provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
||||||
ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置
|
ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置
|
||||||
|
|
||||||
# 获取模型映射(别名/映射 → 实际模型名)
|
# 获取模型映射(映射名称 → 实际模型名)
|
||||||
mapped_model = await self._get_mapped_model(
|
mapped_model = await self._get_mapped_model(
|
||||||
source_model=ctx.model,
|
source_model=ctx.model,
|
||||||
provider_id=str(provider.id),
|
provider_id=str(provider.id),
|
||||||
@@ -474,8 +492,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
|
|
||||||
stream_response.raise_for_status()
|
stream_response.raise_for_status()
|
||||||
|
|
||||||
# 使用字节流迭代器(避免 aiter_lines 的性能问题)
|
# 使用字节流迭代器(避免 aiter_lines 的性能问题, aiter_bytes 会自动解压 gzip/deflate)
|
||||||
byte_iterator = stream_response.aiter_raw()
|
byte_iterator = stream_response.aiter_bytes()
|
||||||
|
|
||||||
# 预读第一个数据块,检测嵌套错误(HTTP 200 但响应体包含错误)
|
# 预读第一个数据块,检测嵌套错误(HTTP 200 但响应体包含错误)
|
||||||
prefetched_chunks = await self._prefetch_and_check_embedded_error(
|
prefetched_chunks = await self._prefetch_and_check_embedded_error(
|
||||||
@@ -486,6 +504,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
error_text = await self._extract_error_text(e)
|
error_text = await self._extract_error_text(e)
|
||||||
logger.error(f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}")
|
logger.error(f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}")
|
||||||
await http_client.aclose()
|
await http_client.aclose()
|
||||||
|
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
|
||||||
|
e.upstream_response = error_text # type: ignore[attr-defined]
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except EmbeddedErrorException:
|
except EmbeddedErrorException:
|
||||||
@@ -521,20 +541,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
try:
|
try:
|
||||||
sse_parser = SSEEventParser()
|
sse_parser = SSEEventParser()
|
||||||
last_data_time = time.time()
|
last_data_time = time.time()
|
||||||
streaming_status_updated = False
|
|
||||||
buffer = b""
|
buffer = b""
|
||||||
|
output_state = {"first_yield": True, "streaming_updated": False}
|
||||||
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
|
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
|
||||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||||
|
|
||||||
# 检查是否需要格式转换
|
# 检查是否需要格式转换
|
||||||
needs_conversion = self._needs_format_conversion(ctx)
|
needs_conversion = self._needs_format_conversion(ctx)
|
||||||
|
|
||||||
async for chunk in stream_response.aiter_raw():
|
async for chunk in stream_response.aiter_bytes():
|
||||||
# 在第一次输出数据前更新状态为 streaming
|
|
||||||
if not streaming_status_updated:
|
|
||||||
self._update_usage_to_streaming_with_ctx(ctx)
|
|
||||||
streaming_status_updated = True
|
|
||||||
|
|
||||||
buffer += chunk
|
buffer += chunk
|
||||||
# 处理缓冲区中的完整行
|
# 处理缓冲区中的完整行
|
||||||
while b"\n" in buffer:
|
while b"\n" in buffer:
|
||||||
@@ -559,6 +574,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
event.get("event"),
|
event.get("event"),
|
||||||
event.get("data") or "",
|
event.get("data") or "",
|
||||||
)
|
)
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
yield b"\n"
|
yield b"\n"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -576,6 +592,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据",
|
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
|
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
|
||||||
return # 结束生成器
|
return # 结束生成器
|
||||||
|
|
||||||
@@ -583,8 +600,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
if needs_conversion:
|
if needs_conversion:
|
||||||
converted_line = self._convert_sse_line(ctx, line, events)
|
converted_line = self._convert_sse_line(ctx, line, events)
|
||||||
if converted_line:
|
if converted_line:
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
yield (converted_line + "\n").encode("utf-8")
|
yield (converted_line + "\n").encode("utf-8")
|
||||||
else:
|
else:
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
yield (line + "\n").encode("utf-8")
|
yield (line + "\n").encode("utf-8")
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
@@ -635,7 +654,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
|
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
|
||||||
except httpx.RemoteProtocolError as e:
|
except httpx.RemoteProtocolError:
|
||||||
if ctx.data_count > 0:
|
if ctx.data_count > 0:
|
||||||
error_event = {
|
error_event = {
|
||||||
"type": "error",
|
"type": "error",
|
||||||
@@ -672,6 +691,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
|
|
||||||
同时检测 HTML 响应(通常是 base_url 配置错误导致返回网页)。
|
同时检测 HTML 响应(通常是 base_url 配置错误导致返回网页)。
|
||||||
|
|
||||||
|
首次读取时会应用 TTFB(首字节超时)检测,超时则触发故障转移。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
byte_iterator: 字节流迭代器
|
byte_iterator: 字节流迭代器
|
||||||
provider: Provider 对象
|
provider: Provider 对象
|
||||||
@@ -684,9 +705,12 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
Raises:
|
Raises:
|
||||||
EmbeddedErrorException: 如果检测到嵌套错误
|
EmbeddedErrorException: 如果检测到嵌套错误
|
||||||
ProviderNotAvailableException: 如果检测到 HTML 响应(配置错误)
|
ProviderNotAvailableException: 如果检测到 HTML 响应(配置错误)
|
||||||
|
ProviderTimeoutException: 如果首字节超时(TTFB timeout)
|
||||||
"""
|
"""
|
||||||
prefetched_chunks: list = []
|
prefetched_chunks: list = []
|
||||||
max_prefetch_lines = 5 # 最多预读5行来检测错误
|
max_prefetch_lines = config.stream_prefetch_lines # 最多预读行数来检测错误
|
||||||
|
max_prefetch_bytes = StreamDefaults.MAX_PREFETCH_BYTES # 避免无换行响应导致 buffer 增长
|
||||||
|
total_prefetched_bytes = 0
|
||||||
buffer = b""
|
buffer = b""
|
||||||
line_count = 0
|
line_count = 0
|
||||||
should_stop = False
|
should_stop = False
|
||||||
@@ -704,11 +728,25 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
else:
|
else:
|
||||||
provider_parser = self.parser
|
provider_parser = self.parser
|
||||||
|
|
||||||
async for chunk in byte_iterator:
|
# 使用共享的 TTFB 超时函数读取首字节
|
||||||
|
ttfb_timeout = config.stream_first_byte_timeout
|
||||||
|
first_chunk, aiter = await read_first_chunk_with_ttfb_timeout(
|
||||||
|
byte_iterator,
|
||||||
|
timeout=ttfb_timeout,
|
||||||
|
request_id=self.request_id,
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
)
|
||||||
|
prefetched_chunks.append(first_chunk)
|
||||||
|
total_prefetched_bytes += len(first_chunk)
|
||||||
|
buffer += first_chunk
|
||||||
|
|
||||||
|
# 继续读取剩余的预读数据
|
||||||
|
async for chunk in aiter:
|
||||||
prefetched_chunks.append(chunk)
|
prefetched_chunks.append(chunk)
|
||||||
|
total_prefetched_bytes += len(chunk)
|
||||||
buffer += chunk
|
buffer += chunk
|
||||||
|
|
||||||
# 尝试按行解析缓冲区
|
# 尝试按行解析缓冲区(SSE 格式)
|
||||||
while b"\n" in buffer:
|
while b"\n" in buffer:
|
||||||
line_bytes, buffer = buffer.split(b"\n", 1)
|
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||||
try:
|
try:
|
||||||
@@ -725,15 +763,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
normalized_line = line.rstrip("\r")
|
normalized_line = line.rstrip("\r")
|
||||||
|
|
||||||
# 检测 HTML 响应(base_url 配置错误的常见症状)
|
# 检测 HTML 响应(base_url 配置错误的常见症状)
|
||||||
lower_line = normalized_line.lower()
|
if check_html_response(normalized_line):
|
||||||
if lower_line.startswith("<!doctype") or lower_line.startswith("<html"):
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f" [{self.request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
|
f" [{self.request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
|
||||||
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
|
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
|
||||||
f"base_url={endpoint.base_url}"
|
f"base_url={endpoint.base_url}"
|
||||||
)
|
)
|
||||||
raise ProviderNotAvailableException(
|
raise ProviderNotAvailableException(
|
||||||
f"提供商 '{provider.name}' 返回了 HTML 页面而非 API 响应,请检查 endpoint 的 base_url 配置是否正确"
|
f"提供商 '{provider.name}' 返回了 HTML 页面而非 API 响应,"
|
||||||
|
f"请检查 endpoint 的 base_url 配置是否正确"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not normalized_line or normalized_line.startswith(":"):
|
if not normalized_line or normalized_line.startswith(":"):
|
||||||
@@ -782,15 +820,45 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
should_stop = True
|
should_stop = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# 达到预读字节上限,停止继续预读(避免无换行响应导致内存增长)
|
||||||
|
if not should_stop and total_prefetched_bytes >= max_prefetch_bytes:
|
||||||
|
logger.debug(
|
||||||
|
f" [{self.request_id}] 预读达到字节上限,停止继续预读: "
|
||||||
|
f"Provider={provider.name}, bytes={total_prefetched_bytes}, "
|
||||||
|
f"max_bytes={max_prefetch_bytes}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
if should_stop or line_count >= max_prefetch_lines:
|
if should_stop or line_count >= max_prefetch_lines:
|
||||||
break
|
break
|
||||||
|
|
||||||
except EmbeddedErrorException:
|
# 预读结束后,检查是否为非 SSE 格式的 HTML/JSON 响应
|
||||||
# 重新抛出嵌套错误
|
# 处理某些代理返回的纯 JSON 错误(可能无换行/多行 JSON)以及 HTML 页面(base_url 配置错误)
|
||||||
|
if not should_stop and prefetched_chunks:
|
||||||
|
check_prefetched_response_error(
|
||||||
|
prefetched_chunks=prefetched_chunks,
|
||||||
|
parser=provider_parser,
|
||||||
|
request_id=self.request_id,
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
endpoint_id=endpoint.id,
|
||||||
|
base_url=endpoint.base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
except (EmbeddedErrorException, ProviderTimeoutException, ProviderNotAvailableException):
|
||||||
|
# 重新抛出可重试的 Provider 异常,触发故障转移
|
||||||
raise
|
raise
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
# 网络 I/O 异常:记录警告,可能需要重试
|
||||||
|
logger.warning(
|
||||||
|
f" [{self.request_id}] 预读流时发生网络异常: {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 其他异常(如网络错误)在预读阶段发生,记录日志但不中断
|
# 未预期的严重异常:记录错误并重新抛出,避免掩盖问题
|
||||||
logger.debug(f" [{self.request_id}] 预读流时发生异常: {e}")
|
logger.error(
|
||||||
|
f" [{self.request_id}] 预读流时发生严重异常: {type(e).__name__}: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
return prefetched_chunks
|
return prefetched_chunks
|
||||||
|
|
||||||
@@ -807,17 +875,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
sse_parser = SSEEventParser()
|
sse_parser = SSEEventParser()
|
||||||
last_data_time = time.time()
|
last_data_time = time.time()
|
||||||
buffer = b""
|
buffer = b""
|
||||||
first_yield = True # 标记是否是第一次 yield
|
output_state = {"first_yield": True, "streaming_updated": False}
|
||||||
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
|
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
|
||||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||||
|
|
||||||
# 检查是否需要格式转换
|
# 检查是否需要格式转换
|
||||||
needs_conversion = self._needs_format_conversion(ctx)
|
needs_conversion = self._needs_format_conversion(ctx)
|
||||||
|
|
||||||
# 在第一次输出数据前更新状态为 streaming
|
|
||||||
if prefetched_chunks:
|
|
||||||
self._update_usage_to_streaming_with_ctx(ctx)
|
|
||||||
|
|
||||||
# 先处理预读的字节块
|
# 先处理预读的字节块
|
||||||
for chunk in prefetched_chunks:
|
for chunk in prefetched_chunks:
|
||||||
buffer += chunk
|
buffer += chunk
|
||||||
@@ -844,10 +908,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
event.get("event"),
|
event.get("event"),
|
||||||
event.get("data") or "",
|
event.get("data") or "",
|
||||||
)
|
)
|
||||||
# 记录首字时间 (第一次 yield)
|
self._mark_first_output(ctx, output_state)
|
||||||
if first_yield:
|
|
||||||
ctx.record_first_byte_time(self.start_time)
|
|
||||||
first_yield = False
|
|
||||||
yield b"\n"
|
yield b"\n"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -857,16 +918,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
if needs_conversion:
|
if needs_conversion:
|
||||||
converted_line = self._convert_sse_line(ctx, line, events)
|
converted_line = self._convert_sse_line(ctx, line, events)
|
||||||
if converted_line:
|
if converted_line:
|
||||||
# 记录首字时间 (第一次 yield)
|
self._mark_first_output(ctx, output_state)
|
||||||
if first_yield:
|
|
||||||
ctx.record_first_byte_time(self.start_time)
|
|
||||||
first_yield = False
|
|
||||||
yield (converted_line + "\n").encode("utf-8")
|
yield (converted_line + "\n").encode("utf-8")
|
||||||
else:
|
else:
|
||||||
# 记录首字时间 (第一次 yield)
|
self._mark_first_output(ctx, output_state)
|
||||||
if first_yield:
|
|
||||||
ctx.record_first_byte_time(self.start_time)
|
|
||||||
first_yield = False
|
|
||||||
yield (line + "\n").encode("utf-8")
|
yield (line + "\n").encode("utf-8")
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
@@ -905,10 +960,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
event.get("event"),
|
event.get("event"),
|
||||||
event.get("data") or "",
|
event.get("data") or "",
|
||||||
)
|
)
|
||||||
# 记录首字时间 (第一次 yield) - 如果预读数据为空
|
self._mark_first_output(ctx, output_state)
|
||||||
if first_yield:
|
|
||||||
ctx.record_first_byte_time(self.start_time)
|
|
||||||
first_yield = False
|
|
||||||
yield b"\n"
|
yield b"\n"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -926,6 +978,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据",
|
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
|
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -933,16 +986,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
if needs_conversion:
|
if needs_conversion:
|
||||||
converted_line = self._convert_sse_line(ctx, line, events)
|
converted_line = self._convert_sse_line(ctx, line, events)
|
||||||
if converted_line:
|
if converted_line:
|
||||||
# 记录首字时间 (第一次 yield) - 如果预读数据为空
|
self._mark_first_output(ctx, output_state)
|
||||||
if first_yield:
|
|
||||||
ctx.record_first_byte_time(self.start_time)
|
|
||||||
first_yield = False
|
|
||||||
yield (converted_line + "\n").encode("utf-8")
|
yield (converted_line + "\n").encode("utf-8")
|
||||||
else:
|
else:
|
||||||
# 记录首字时间 (第一次 yield) - 如果预读数据为空
|
self._mark_first_output(ctx, output_state)
|
||||||
if first_yield:
|
|
||||||
ctx.record_first_byte_time(self.start_time)
|
|
||||||
first_yield = False
|
|
||||||
yield (line + "\n").encode("utf-8")
|
yield (line + "\n").encode("utf-8")
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
@@ -1326,7 +1373,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
model=ctx.model,
|
model=ctx.model,
|
||||||
response_time_ms=response_time_ms,
|
response_time_ms=response_time_ms,
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
error_message=str(error),
|
error_message=extract_error_message(error),
|
||||||
request_headers=original_headers,
|
request_headers=original_headers,
|
||||||
request_body=actual_request_body,
|
request_body=actual_request_body,
|
||||||
is_stream=True,
|
is_stream=True,
|
||||||
@@ -1382,7 +1429,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
provider_name = str(provider.name)
|
provider_name = str(provider.name)
|
||||||
provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
||||||
|
|
||||||
# 获取模型映射(别名/映射 → 实际模型名)
|
# 获取模型映射(映射名称 → 实际模型名)
|
||||||
mapped_model = await self._get_mapped_model(
|
mapped_model = await self._get_mapped_model(
|
||||||
source_model=model,
|
source_model=model,
|
||||||
provider_id=str(provider.id),
|
provider_id=str(provider.id),
|
||||||
@@ -1594,7 +1641,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
model=model,
|
model=model,
|
||||||
response_time_ms=response_time_ms,
|
response_time_ms=response_time_ms,
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
error_message=str(e),
|
error_message=extract_error_message(e),
|
||||||
request_headers=original_headers,
|
request_headers=original_headers,
|
||||||
request_body=actual_request_body,
|
request_body=actual_request_body,
|
||||||
is_stream=False,
|
is_stream=False,
|
||||||
@@ -1614,14 +1661,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
|
|
||||||
for encoding in ["utf-8", "gbk", "latin1"]:
|
for encoding in ["utf-8", "gbk", "latin1"]:
|
||||||
try:
|
try:
|
||||||
return error_bytes.decode(encoding)[:500]
|
return error_bytes.decode(encoding)
|
||||||
except (UnicodeDecodeError, LookupError):
|
except (UnicodeDecodeError, LookupError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return error_bytes.decode("utf-8", errors="replace")[:500]
|
return error_bytes.decode("utf-8", errors="replace")
|
||||||
else:
|
else:
|
||||||
return (
|
return (
|
||||||
e.response.text[:500]
|
e.response.text
|
||||||
if hasattr(e.response, "_content")
|
if hasattr(e.response, "_content")
|
||||||
else "Unable to read response"
|
else "Unable to read response"
|
||||||
)
|
)
|
||||||
@@ -1639,6 +1686,25 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
return False
|
return False
|
||||||
return ctx.provider_api_format.upper() != ctx.client_api_format.upper()
|
return ctx.provider_api_format.upper() != ctx.client_api_format.upper()
|
||||||
|
|
||||||
|
def _mark_first_output(self, ctx: StreamContext, state: Dict[str, bool]) -> None:
|
||||||
|
"""
|
||||||
|
标记首次输出:记录 TTFB 并更新 streaming 状态
|
||||||
|
|
||||||
|
在第一次 yield 数据前调用,确保:
|
||||||
|
1. 首字时间 (TTFB) 已记录到 ctx
|
||||||
|
2. Usage 状态已更新为 streaming(包含 provider/key/TTFB 信息)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: 流上下文
|
||||||
|
state: 包含 first_yield 和 streaming_updated 的状态字典
|
||||||
|
"""
|
||||||
|
if state["first_yield"]:
|
||||||
|
ctx.record_first_byte_time(self.start_time)
|
||||||
|
state["first_yield"] = False
|
||||||
|
if not state["streaming_updated"]:
|
||||||
|
self._update_usage_to_streaming_with_ctx(ctx)
|
||||||
|
state["streaming_updated"] = True
|
||||||
|
|
||||||
def _convert_sse_line(
|
def _convert_sse_line(
|
||||||
self,
|
self,
|
||||||
ctx: StreamContext,
|
ctx: StreamContext,
|
||||||
|
|||||||
1252
src/api/handlers/base/endpoint_checker.py
Normal file
1252
src/api/handlers/base/endpoint_checker.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user