mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-02 15:52:26 +08:00
Compare commits
128 Commits
v0.1.1
...
cddc22d2b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
af476ff21e | ||
|
|
3bbc1c6b66 | ||
|
|
c69a0a8506 | ||
|
|
1fae202bde | ||
|
|
b9a26c4550 | ||
|
|
e42bd35d48 | ||
|
|
f22a073fd9 | ||
|
|
5c7ad089d2 | ||
|
|
97425ac68f | ||
|
|
912f6643e2 | ||
|
|
6c0373fda6 | ||
|
|
070121717d | ||
|
|
85fafeacb8 | ||
|
|
daf8b870f0 | ||
|
|
880fb61c66 | ||
|
|
7e792dabfc | ||
|
|
cd06169b2f | ||
|
|
50ffd47546 | ||
|
|
5f0c1fb347 | ||
|
|
7b932d7afb | ||
|
|
c7b971cfe7 | ||
|
|
293bb592dc | ||
|
|
3e50c157be | ||
|
|
21587449c8 | ||
|
|
3d0ab353d3 | ||
|
|
b2a857c164 | ||
|
|
4d1d863916 | ||
|
|
b579420690 | ||
|
|
9d5c84f9d3 | ||
|
|
53e6a82480 | ||
|
|
bd11ebdbd5 | ||
|
|
1dac4cb156 | ||
|
|
50abb55c94 | ||
|
|
73d3c9d3e4 | ||
|
|
d24c3885ab | ||
|
|
d696c575e6 | ||
|
|
46ff5a1a50 | ||
|
|
edce43d45f | ||
|
|
33265b4b13 | ||
|
|
a94aeca2d3 | ||
|
|
c42ebdd0ee | ||
|
|
f1e3c2ab11 | ||
|
|
4e2ba0e57f | ||
|
|
a3df41d63d | ||
|
|
ad1c8c394c | ||
|
|
9b496abb73 | ||
|
|
f3a69a6160 | ||
|
|
adcdb73d29 | ||
|
|
cf67160821 | ||
|
|
718f56ba75 | ||
|
|
d87de10f62 | ||
|
|
c6b9582978 | ||
|
|
3d583b0a8d | ||
|
|
f849a54027 | ||
|
|
f2cd96c34c | ||
|
|
34d480910a | ||
|
|
f16fb28405 | ||
|
|
a0ffc2c406 | ||
|
|
a7bfab1475 | ||
|
|
84d4db0f8d | ||
|
|
903b182fdf | ||
|
|
d9bd0790fe | ||
|
|
c6fcc7982d | ||
|
|
aaa6a8f60d | ||
|
|
11774c69b6 | ||
|
|
8f0a0cbdb1 | ||
|
|
51b85915d2 | ||
|
|
b0d295c6c9 | ||
|
|
c94f011462 | ||
|
|
3296d026e3 | ||
|
|
2e01c7cf5a | ||
|
|
88e37594cf | ||
|
|
03ee6c16d9 | ||
|
|
743f23e640 | ||
|
|
7068aa9130 | ||
|
|
56fb6bf36c | ||
|
|
728f9bb126 | ||
|
|
5319c06f0e |
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
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
|
||||
# JWT密钥(使用 python generate_keys.py 生成)
|
||||
|
||||
39
.github/workflows/docker-publish.yml
vendored
39
.github/workflows/docker-publish.yml
vendored
@@ -15,6 +15,8 @@ env:
|
||||
REGISTRY: ghcr.io
|
||||
BASE_IMAGE_NAME: fawney19/aether-base
|
||||
APP_IMAGE_NAME: fawney19/aether
|
||||
# Files that affect base image - used for hash calculation
|
||||
BASE_FILES: "Dockerfile.base pyproject.toml frontend/package.json frontend/package-lock.json"
|
||||
|
||||
jobs:
|
||||
check-base-changes:
|
||||
@@ -23,8 +25,13 @@ jobs:
|
||||
base_changed: ${{ steps.check.outputs.base_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if base image needs rebuild
|
||||
id: check
|
||||
@@ -34,10 +41,26 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if base-related files changed
|
||||
if git diff --name-only HEAD~1 HEAD | grep -qE '^(Dockerfile\.base|pyproject\.toml|frontend/package.*\.json)$'; then
|
||||
# Calculate current hash of base-related files
|
||||
CURRENT_HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||
echo "Current base files hash: $CURRENT_HASH"
|
||||
|
||||
# Try to get hash label from remote image config
|
||||
# Pull the image config and extract labels
|
||||
REMOTE_HASH=""
|
||||
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest 2>/dev/null; then
|
||||
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
|
||||
# No remote image or no hash label, need to rebuild
|
||||
echo "No remote base image or hash label found, need rebuild"
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
elif [ "$CURRENT_HASH" != "$REMOTE_HASH" ]; then
|
||||
echo "Hash mismatch: remote=$REMOTE_HASH, current=$CURRENT_HASH"
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Hash matches, no rebuild needed"
|
||||
echo "base_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
@@ -61,6 +84,12 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Calculate base files hash
|
||||
id: hash
|
||||
run: |
|
||||
HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||
echo "hash=$HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract metadata for base image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -69,6 +98,8 @@ jobs:
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
labels: |
|
||||
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -117,7 +148,7 @@ jobs:
|
||||
|
||||
- name: Update Dockerfile.app to use registry base image
|
||||
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
|
||||
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 .
|
||||
FROM aether-base:latest
|
||||
# 用于 GitHub Actions CI(官方源)
|
||||
FROM aether-base:latest AS builder
|
||||
|
||||
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 alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
|
||||
# 构建前端(使用基础镜像中已安装的 node_modules)
|
||||
COPY frontend/ /tmp/frontend/
|
||||
RUN cd /tmp/frontend && npm run build && \
|
||||
cp -r dist/* /usr/share/nginx/html/ && \
|
||||
rm -rf /tmp/frontend
|
||||
# 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"]
|
||||
|
||||
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"]
|
||||
117
Dockerfile.base
117
Dockerfile.base
@@ -1,122 +1,25 @@
|
||||
# 基础镜像:包含所有依赖,只在依赖变化时需要重建
|
||||
# 构建镜像:编译环境 + 预编译的依赖
|
||||
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
|
||||
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
||||
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 系统依赖
|
||||
# 构建工具
|
||||
RUN apt-get update && apt-get install -y \
|
||||
nginx \
|
||||
supervisor \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
curl \
|
||||
gettext-base \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python 依赖(安装到系统,不用 -e 模式)
|
||||
# Python 依赖
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN mkdir -p src && touch src/__init__.py && \
|
||||
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/
|
||||
WORKDIR /tmp/frontend
|
||||
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;' \
|
||||
' 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"]
|
||||
# 前端依赖(只安装,不构建)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm ci
|
||||
|
||||
@@ -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
|
||||
|
||||
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 \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
curl \
|
||||
gettext-base \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -20,107 +17,12 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.li
|
||||
# pip 镜像源
|
||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# Python 依赖(安装到系统,不用 -e 模式)
|
||||
# Python 依赖
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN mkdir -p src && touch src/__init__.py && \
|
||||
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/
|
||||
WORKDIR /tmp/frontend
|
||||
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;' \
|
||||
' 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"]
|
||||
# 前端依赖(只安装,不构建,使用淘宝镜像源)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
|
||||
|
||||
15
LICENSE
15
LICENSE
@@ -5,12 +5,17 @@ Aether 非商业开源许可证
|
||||
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
||||
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
||||
|
||||
1. 仅限非商业用途
|
||||
本软件不得用于商业目的。商业目的包括但不限于:
|
||||
1. 仅限非盈利用途
|
||||
本软件不得用于盈利目的。盈利目的包括但不限于:
|
||||
- 出售本软件或任何衍生作品
|
||||
- 使用本软件提供付费服务
|
||||
- 将本软件用于商业产品或服务
|
||||
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
|
||||
- 将本软件用于以盈利为目的的商业产品或服务
|
||||
|
||||
以下用途被明确允许:
|
||||
- 个人学习和研究
|
||||
- 教育机构的教学和研究
|
||||
- 非盈利组织的内部使用
|
||||
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
|
||||
|
||||
2. 署名要求
|
||||
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
||||
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
|
||||
您不得以不同的条款将本软件再许可给他人。
|
||||
|
||||
5. 商业许可
|
||||
如需商业使用,请联系版权持有人以获取单独的商业许可。
|
||||
如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
|
||||
|
||||
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
||||
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
||||
|
||||
23
README.md
23
README.md
@@ -60,8 +60,11 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
# 3. 部署
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 更新
|
||||
docker-compose pull && docker-compose up -d
|
||||
# 4. 首次部署时, 初始化数据库
|
||||
./migrate.sh
|
||||
|
||||
# 5. 更新
|
||||
docker-compose pull && docker-compose up -d && ./migrate.sh
|
||||
```
|
||||
|
||||
### Docker Compose(本地构建镜像)
|
||||
@@ -140,7 +143,7 @@ cd frontend && npm install && npm run dev
|
||||
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
|
||||
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
|
||||
|
||||
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能调用支持 1H缓存的模型
|
||||
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
|
||||
|
||||
### Q: 如何配置负载均衡?
|
||||
|
||||
@@ -159,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)
|
||||
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create ENUM types
|
||||
op.execute("CREATE TYPE userrole AS ENUM ('admin', 'user')")
|
||||
# Create ENUM types (with IF NOT EXISTS for idempotency)
|
||||
op.execute("DO $$ BEGIN CREATE TYPE userrole AS ENUM ('admin', 'user'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
|
||||
op.execute(
|
||||
"CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier')"
|
||||
"DO $$ BEGIN CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier'); EXCEPTION WHEN duplicate_object THEN NULL; END $$"
|
||||
)
|
||||
|
||||
# ==================== users ====================
|
||||
@@ -35,7 +35,7 @@ def upgrade() -> None:
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.Enum("admin", "user", name="userrole", create_type=False),
|
||||
postgresql.ENUM("admin", "user", name="userrole", create_type=False),
|
||||
nullable=False,
|
||||
server_default="user",
|
||||
),
|
||||
@@ -67,7 +67,7 @@ def upgrade() -> None:
|
||||
sa.Column("website", sa.String(500), nullable=True),
|
||||
sa.Column(
|
||||
"billing_type",
|
||||
sa.Enum(
|
||||
postgresql.ENUM(
|
||||
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
|
||||
),
|
||||
nullable=False,
|
||||
@@ -394,6 +394,10 @@ def upgrade() -> None:
|
||||
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 ====================
|
||||
op.create_table(
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""remove_model_mappings_add_aliases
|
||||
|
||||
合并迁移:
|
||||
1. 添加 provider_model_aliases 字段到 models 表
|
||||
2. 迁移 model_mappings 数据到 provider_model_aliases
|
||||
3. 删除 model_mappings 表
|
||||
4. 添加索引优化别名解析性能
|
||||
|
||||
Revision ID: e9b3d63f0cbf
|
||||
Revises: 20251210_baseline
|
||||
Create Date: 2025-12-14 13:00:22.828183+00:00
|
||||
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e9b3d63f0cbf'
|
||||
down_revision = '20251210_baseline'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def table_exists(bind, table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = :table_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def index_exists(bind, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = :index_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 provider_model_aliases 字段,迁移数据,删除 model_mappings 表"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. 添加 provider_model_aliases 字段(如果不存在)
|
||||
if not column_exists(bind, "models", "provider_model_aliases"):
|
||||
op.add_column(
|
||||
'models',
|
||||
sa.Column('provider_model_aliases', sa.JSON(), nullable=True)
|
||||
)
|
||||
|
||||
# 2. 迁移 model_mappings 数据(如果表存在)
|
||||
session = Session(bind=bind)
|
||||
|
||||
model_mappings_table = sa.table(
|
||||
"model_mappings",
|
||||
sa.column("source_model", sa.String),
|
||||
sa.column("target_global_model_id", sa.String),
|
||||
sa.column("provider_id", sa.String),
|
||||
sa.column("mapping_type", sa.String),
|
||||
sa.column("is_active", sa.Boolean),
|
||||
)
|
||||
|
||||
models_table = sa.table(
|
||||
"models",
|
||||
sa.column("id", sa.String),
|
||||
sa.column("provider_id", sa.String),
|
||||
sa.column("global_model_id", sa.String),
|
||||
sa.column("provider_model_aliases", sa.JSON),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
def normalize_alias_list(value) -> list[dict]:
|
||||
"""将 DB 返回的 JSON 值规范化为 list[{'name': str, 'priority': int}]"""
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value) if value else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
normalized: list[dict] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
raw_name = item.get("name")
|
||||
if not isinstance(raw_name, str):
|
||||
continue
|
||||
name = raw_name.strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
raw_priority = item.get("priority", 1)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except Exception:
|
||||
priority = 1
|
||||
if priority < 1:
|
||||
priority = 1
|
||||
|
||||
normalized.append({"name": name, "priority": priority})
|
||||
|
||||
return normalized
|
||||
|
||||
# 查询所有活跃的 provider 级别 alias(只迁移 is_active=True 且 mapping_type='alias' 的)
|
||||
# 全局别名/映射不迁移(新架构不再支持 source_model -> GlobalModel.name 的解析)
|
||||
# 仅当 model_mappings 表存在时执行迁移
|
||||
if table_exists(bind, "model_mappings"):
|
||||
mappings = session.execute(
|
||||
sa.select(
|
||||
model_mappings_table.c.source_model,
|
||||
model_mappings_table.c.target_global_model_id,
|
||||
model_mappings_table.c.provider_id,
|
||||
)
|
||||
.where(
|
||||
model_mappings_table.c.is_active.is_(True),
|
||||
model_mappings_table.c.provider_id.isnot(None),
|
||||
model_mappings_table.c.mapping_type == "alias",
|
||||
)
|
||||
.order_by(model_mappings_table.c.provider_id, model_mappings_table.c.source_model)
|
||||
).all()
|
||||
|
||||
# 按 (provider_id, target_global_model_id) 分组,收集别名
|
||||
alias_groups: dict = {}
|
||||
for source_model, target_global_model_id, provider_id in mappings:
|
||||
if not isinstance(source_model, str):
|
||||
continue
|
||||
source_model = source_model.strip()
|
||||
if not source_model:
|
||||
continue
|
||||
if not isinstance(provider_id, str) or not provider_id:
|
||||
continue
|
||||
if not isinstance(target_global_model_id, str) or not target_global_model_id:
|
||||
continue
|
||||
|
||||
key = (provider_id, target_global_model_id)
|
||||
if key not in alias_groups:
|
||||
alias_groups[key] = []
|
||||
priority = len(alias_groups[key]) + 1
|
||||
alias_groups[key].append({"name": source_model, "priority": priority})
|
||||
|
||||
# 更新对应的 models 记录
|
||||
for (provider_id, global_model_id), aliases in alias_groups.items():
|
||||
model_row = session.execute(
|
||||
sa.select(models_table.c.id, models_table.c.provider_model_aliases)
|
||||
.where(
|
||||
models_table.c.provider_id == provider_id,
|
||||
models_table.c.global_model_id == global_model_id,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
if model_row:
|
||||
model_id = model_row[0]
|
||||
existing_aliases = normalize_alias_list(model_row[1])
|
||||
|
||||
existing_names = {a["name"] for a in existing_aliases}
|
||||
merged_aliases = list(existing_aliases)
|
||||
for alias in aliases:
|
||||
name = alias.get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
name = name.strip()
|
||||
if not name or name in existing_names:
|
||||
continue
|
||||
|
||||
merged_aliases.append(
|
||||
{
|
||||
"name": name,
|
||||
"priority": len(merged_aliases) + 1,
|
||||
}
|
||||
)
|
||||
existing_names.add(name)
|
||||
|
||||
session.execute(
|
||||
models_table.update()
|
||||
.where(models_table.c.id == model_id)
|
||||
.values(
|
||||
provider_model_aliases=merged_aliases if merged_aliases else None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
|
||||
# 3. 删除 model_mappings 表
|
||||
op.drop_table('model_mappings')
|
||||
|
||||
# 4. 添加索引优化别名解析性能
|
||||
# provider_model_name 索引(支持精确匹配,如果不存在)
|
||||
if not index_exists(bind, "idx_model_provider_model_name"):
|
||||
op.create_index(
|
||||
"idx_model_provider_model_name",
|
||||
"models",
|
||||
["provider_model_name"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("is_active = true"),
|
||||
)
|
||||
|
||||
# provider_model_aliases GIN 索引(支持 JSONB 查询,仅 PostgreSQL)
|
||||
if bind.dialect.name == "postgresql":
|
||||
# 将 json 列转为 jsonb(jsonb 性能更好且支持 GIN 索引)
|
||||
# 使用 IF NOT EXISTS 风格的检查来避免重复转换
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'models'
|
||||
AND column_name = 'provider_model_aliases'
|
||||
AND data_type = 'json'
|
||||
) THEN
|
||||
ALTER TABLE models
|
||||
ALTER COLUMN provider_model_aliases TYPE jsonb
|
||||
USING provider_model_aliases::jsonb;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
# 创建 GIN 索引
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_model_provider_model_aliases_gin
|
||||
ON models USING gin(provider_model_aliases jsonb_path_ops)
|
||||
WHERE is_active = true
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""恢复 model_mappings 表,移除 provider_model_aliases 字段和索引"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. 删除索引
|
||||
op.drop_index("idx_model_provider_model_name", table_name="models")
|
||||
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute("DROP INDEX IF EXISTS idx_model_provider_model_aliases_gin")
|
||||
# 将 jsonb 列还原为 json
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE models
|
||||
ALTER COLUMN provider_model_aliases TYPE json
|
||||
USING provider_model_aliases::json
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. 恢复 model_mappings 表
|
||||
op.create_table(
|
||||
'model_mappings',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('source_model', sa.String(200), nullable=False),
|
||||
sa.Column(
|
||||
'target_global_model_id',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('global_models.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('provider_id', sa.String(36), sa.ForeignKey('providers.id'), nullable=True),
|
||||
sa.Column('mapping_type', sa.String(20), nullable=False, server_default='alias'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint('source_model', 'provider_id', name='uq_model_mapping_source_provider'),
|
||||
)
|
||||
op.create_index('ix_model_mappings_source_model', 'model_mappings', ['source_model'])
|
||||
op.create_index('ix_model_mappings_target_global_model_id', 'model_mappings', ['target_global_model_id'])
|
||||
op.create_index('ix_model_mappings_provider_id', 'model_mappings', ['provider_id'])
|
||||
op.create_index('ix_model_mappings_mapping_type', 'model_mappings', ['mapping_type'])
|
||||
|
||||
# 3. 移除 provider_model_aliases 字段
|
||||
op.drop_column('models', 'provider_model_aliases')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add first_byte_time_ms to usage table
|
||||
|
||||
Revision ID: 180e63a9c83a
|
||||
Revises: e9b3d63f0cbf
|
||||
Create Date: 2025-12-15 17:07:44.631032+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '180e63a9c83a'
|
||||
down_revision = 'e9b3d63f0cbf'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 添加首字时间字段到 usage 表(如果不存在)
|
||||
if not column_exists(bind, "usage", "first_byte_time_ms"):
|
||||
op.add_column('usage', sa.Column('first_byte_time_ms', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
# 删除首字时间字段
|
||||
op.drop_column('usage', 'first_byte_time_ms')
|
||||
@@ -0,0 +1,110 @@
|
||||
"""refactor global_model to use config json field
|
||||
|
||||
Revision ID: 1cc6942cf06f
|
||||
Revises: 180e63a9c83a
|
||||
Create Date: 2025-12-16 03:11:32.480976+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1cc6942cf06f'
|
||||
down_revision = '180e63a9c83a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本
|
||||
|
||||
1. 添加 config 列
|
||||
2. 把旧数据迁移到 config
|
||||
3. 删除旧列
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 检查是否已经迁移过(config 列存在且旧列不存在)
|
||||
has_config = column_exists(bind, "global_models", "config")
|
||||
has_old_columns = column_exists(bind, "global_models", "default_supports_streaming")
|
||||
|
||||
if has_config and not has_old_columns:
|
||||
# 已完成迁移,跳过
|
||||
return
|
||||
|
||||
# 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询)
|
||||
if not has_config:
|
||||
op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True))
|
||||
|
||||
# 2. 迁移数据:把旧字段合并到 config JSON(仅当旧列存在时)
|
||||
if has_old_columns:
|
||||
op.execute("""
|
||||
UPDATE global_models
|
||||
SET config = jsonb_strip_nulls(jsonb_build_object(
|
||||
'streaming', COALESCE(default_supports_streaming, true),
|
||||
'vision', CASE WHEN COALESCE(default_supports_vision, false) THEN true ELSE NULL END,
|
||||
'function_calling', CASE WHEN COALESCE(default_supports_function_calling, false) THEN true ELSE NULL END,
|
||||
'extended_thinking', CASE WHEN COALESCE(default_supports_extended_thinking, false) THEN true ELSE NULL END,
|
||||
'image_generation', CASE WHEN COALESCE(default_supports_image_generation, false) THEN true ELSE NULL END,
|
||||
'description', description,
|
||||
'icon_url', icon_url,
|
||||
'official_url', official_url
|
||||
))
|
||||
""")
|
||||
|
||||
# 3. 删除旧列
|
||||
op.drop_column('global_models', 'default_supports_streaming')
|
||||
op.drop_column('global_models', 'default_supports_vision')
|
||||
op.drop_column('global_models', 'default_supports_function_calling')
|
||||
op.drop_column('global_models', 'default_supports_extended_thinking')
|
||||
op.drop_column('global_models', 'default_supports_image_generation')
|
||||
op.drop_column('global_models', 'description')
|
||||
op.drop_column('global_models', 'icon_url')
|
||||
op.drop_column('global_models', 'official_url')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
# 1. 添加旧列
|
||||
op.add_column('global_models', sa.Column('icon_url', sa.VARCHAR(length=500), nullable=True))
|
||||
op.add_column('global_models', sa.Column('official_url', sa.VARCHAR(length=500), nullable=True))
|
||||
op.add_column('global_models', sa.Column('description', sa.TEXT(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_streaming', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_vision', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_function_calling', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_extended_thinking', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_image_generation', sa.BOOLEAN(), nullable=True))
|
||||
|
||||
# 2. 从 config 恢复数据
|
||||
op.execute("""
|
||||
UPDATE global_models
|
||||
SET
|
||||
default_supports_streaming = COALESCE((config->>'streaming')::boolean, true),
|
||||
default_supports_vision = COALESCE((config->>'vision')::boolean, false),
|
||||
default_supports_function_calling = COALESCE((config->>'function_calling')::boolean, false),
|
||||
default_supports_extended_thinking = COALESCE((config->>'extended_thinking')::boolean, false),
|
||||
default_supports_image_generation = COALESCE((config->>'image_generation')::boolean, false),
|
||||
description = config->>'description',
|
||||
icon_url = config->>'icon_url',
|
||||
official_url = config->>'official_url'
|
||||
""")
|
||||
|
||||
# 3. 删除 config 列
|
||||
op.drop_column('global_models', 'config')
|
||||
@@ -0,0 +1,57 @@
|
||||
"""add proxy field to provider_endpoints
|
||||
|
||||
Revision ID: f30f9936f6a2
|
||||
Revises: 1cc6942cf06f
|
||||
Create Date: 2025-12-18 06:31:58.451112+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f30f9936f6a2'
|
||||
down_revision = '1cc6942cf06f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def get_column_type(table_name: str, column_name: str) -> str:
|
||||
"""获取列的类型"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col['name'] == column_name:
|
||||
return str(col['type']).upper()
|
||||
return ''
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 proxy 字段到 provider_endpoints 表"""
|
||||
if not column_exists('provider_endpoints', 'proxy'):
|
||||
# 字段不存在,直接添加 JSONB 类型
|
||||
op.add_column('provider_endpoints', sa.Column('proxy', JSONB(), nullable=True))
|
||||
else:
|
||||
# 字段已存在,检查是否需要转换类型
|
||||
col_type = get_column_type('provider_endpoints', 'proxy')
|
||||
if 'JSONB' not in col_type:
|
||||
# 如果是 JSON 类型,转换为 JSONB
|
||||
op.execute(
|
||||
'ALTER TABLE provider_endpoints '
|
||||
'ALTER COLUMN proxy TYPE JSONB USING proxy::jsonb'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除 proxy 字段"""
|
||||
if column_exists('provider_endpoints', 'proxy'):
|
||||
op.drop_column('provider_endpoints', 'proxy')
|
||||
@@ -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"
|
||||
))
|
||||
40
deploy.sh
40
deploy.sh
@@ -21,15 +21,18 @@ HASH_FILE=".deps-hash"
|
||||
CODE_HASH_FILE=".code-hash"
|
||||
MIGRATION_HASH_FILE=".migration-hash"
|
||||
|
||||
# 计算依赖文件的哈希值
|
||||
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
|
||||
calc_deps_hash() {
|
||||
cat pyproject.toml frontend/package.json frontend/package-lock.json 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() {
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -162,29 +165,46 @@ git pull
|
||||
|
||||
# 标记是否需要重启
|
||||
NEED_RESTART=false
|
||||
BASE_REBUILT=false
|
||||
|
||||
# 检查基础镜像是否存在,或依赖是否变化
|
||||
if ! docker image inspect aether-base:latest >/dev/null 2>&1; then
|
||||
echo ">>> Base image not found, building..."
|
||||
build_base
|
||||
BASE_REBUILT=true
|
||||
NEED_RESTART=true
|
||||
elif check_deps_changed; then
|
||||
echo ">>> Dependencies changed, rebuilding base image..."
|
||||
build_base
|
||||
BASE_REBUILT=true
|
||||
NEED_RESTART=true
|
||||
else
|
||||
echo ">>> Dependencies unchanged."
|
||||
fi
|
||||
|
||||
# 检查代码是否变化
|
||||
# 检查代码或迁移是否变化,或者 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
|
||||
echo ">>> App image not found, building..."
|
||||
build_app
|
||||
NEED_RESTART=true
|
||||
elif [ "$BASE_REBUILT" = true ]; then
|
||||
echo ">>> Base image rebuilt, rebuilding app image..."
|
||||
build_app
|
||||
NEED_RESTART=true
|
||||
elif check_code_changed; then
|
||||
echo ">>> Code changed, rebuilding app image..."
|
||||
build_app
|
||||
NEED_RESTART=true
|
||||
elif [ "$MIGRATION_CHANGED" = true ]; then
|
||||
echo ">>> Migration files changed, rebuilding app image..."
|
||||
build_app
|
||||
NEED_RESTART=true
|
||||
else
|
||||
echo ">>> Code unchanged."
|
||||
fi
|
||||
@@ -197,9 +217,9 @@ else
|
||||
echo ">>> No changes detected, skipping restart."
|
||||
fi
|
||||
|
||||
# 检查迁移变化
|
||||
if check_migration_changed; then
|
||||
echo ">>> Migration files changed, running database migration..."
|
||||
# 检查迁移变化(如果前面已经检测到变化并重建了镜像,这里直接运行迁移)
|
||||
if [ "$MIGRATION_CHANGED" = true ]; then
|
||||
echo ">>> Running database migration..."
|
||||
sleep 3
|
||||
run_migration
|
||||
else
|
||||
|
||||
3
dev.sh
3
dev.sh
@@ -8,7 +8,8 @@ source .env
|
||||
set +a
|
||||
|
||||
# 构建 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(热重载模式)
|
||||
echo "🚀 启动本地开发服务器..."
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.app
|
||||
dockerfile: Dockerfile.app.local
|
||||
image: aether-app:latest
|
||||
container_name: aether-app
|
||||
environment:
|
||||
|
||||
@@ -12,8 +12,6 @@ services:
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
@@ -27,8 +25,6 @@ services:
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 5s
|
||||
|
||||
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",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -305,6 +306,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -316,9 +318,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -333,9 +335,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -350,9 +352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -367,9 +369,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -384,9 +386,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -401,9 +403,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -418,9 +420,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -435,9 +437,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -452,9 +454,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -469,9 +471,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -486,9 +488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -503,9 +505,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
|
||||
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
|
||||
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -520,9 +522,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
|
||||
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
|
||||
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -537,9 +539,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -554,9 +556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
|
||||
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
|
||||
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -571,9 +573,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
|
||||
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
|
||||
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -588,9 +590,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -605,9 +607,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -622,9 +624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -639,9 +641,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -656,9 +658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -673,9 +675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -690,9 +692,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -707,9 +709,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -724,9 +726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -741,9 +743,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1598,6 +1600,7 @@
|
||||
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
@@ -1676,6 +1679,7 @@
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
@@ -2004,6 +2008,7 @@
|
||||
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.10",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -2301,6 +2306,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2602,6 +2608,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.2",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -2718,6 +2725,7 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -2940,6 +2948,7 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@@ -2999,18 +3008,6 @@
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -3134,9 +3131,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
||||
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -3147,32 +3144,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.9",
|
||||
"@esbuild/android-arm": "0.25.9",
|
||||
"@esbuild/android-arm64": "0.25.9",
|
||||
"@esbuild/android-x64": "0.25.9",
|
||||
"@esbuild/darwin-arm64": "0.25.9",
|
||||
"@esbuild/darwin-x64": "0.25.9",
|
||||
"@esbuild/freebsd-arm64": "0.25.9",
|
||||
"@esbuild/freebsd-x64": "0.25.9",
|
||||
"@esbuild/linux-arm": "0.25.9",
|
||||
"@esbuild/linux-arm64": "0.25.9",
|
||||
"@esbuild/linux-ia32": "0.25.9",
|
||||
"@esbuild/linux-loong64": "0.25.9",
|
||||
"@esbuild/linux-mips64el": "0.25.9",
|
||||
"@esbuild/linux-ppc64": "0.25.9",
|
||||
"@esbuild/linux-riscv64": "0.25.9",
|
||||
"@esbuild/linux-s390x": "0.25.9",
|
||||
"@esbuild/linux-x64": "0.25.9",
|
||||
"@esbuild/netbsd-arm64": "0.25.9",
|
||||
"@esbuild/netbsd-x64": "0.25.9",
|
||||
"@esbuild/openbsd-arm64": "0.25.9",
|
||||
"@esbuild/openbsd-x64": "0.25.9",
|
||||
"@esbuild/openharmony-arm64": "0.25.9",
|
||||
"@esbuild/sunos-x64": "0.25.9",
|
||||
"@esbuild/win32-arm64": "0.25.9",
|
||||
"@esbuild/win32-ia32": "0.25.9",
|
||||
"@esbuild/win32-x64": "0.25.9"
|
||||
"@esbuild/aix-ppc64": "0.27.2",
|
||||
"@esbuild/android-arm": "0.27.2",
|
||||
"@esbuild/android-arm64": "0.27.2",
|
||||
"@esbuild/android-x64": "0.27.2",
|
||||
"@esbuild/darwin-arm64": "0.27.2",
|
||||
"@esbuild/darwin-x64": "0.27.2",
|
||||
"@esbuild/freebsd-arm64": "0.27.2",
|
||||
"@esbuild/freebsd-x64": "0.27.2",
|
||||
"@esbuild/linux-arm": "0.27.2",
|
||||
"@esbuild/linux-arm64": "0.27.2",
|
||||
"@esbuild/linux-ia32": "0.27.2",
|
||||
"@esbuild/linux-loong64": "0.27.2",
|
||||
"@esbuild/linux-mips64el": "0.27.2",
|
||||
"@esbuild/linux-ppc64": "0.27.2",
|
||||
"@esbuild/linux-riscv64": "0.27.2",
|
||||
"@esbuild/linux-s390x": "0.27.2",
|
||||
"@esbuild/linux-x64": "0.27.2",
|
||||
"@esbuild/netbsd-arm64": "0.27.2",
|
||||
"@esbuild/netbsd-x64": "0.27.2",
|
||||
"@esbuild/openbsd-arm64": "0.27.2",
|
||||
"@esbuild/openbsd-x64": "0.27.2",
|
||||
"@esbuild/openharmony-arm64": "0.27.2",
|
||||
"@esbuild/sunos-x64": "0.27.2",
|
||||
"@esbuild/win32-arm64": "0.27.2",
|
||||
"@esbuild/win32-ia32": "0.27.2",
|
||||
"@esbuild/win32-x64": "0.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
@@ -3204,6 +3201,7 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3747,9 +3745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -4084,18 +4082,6 @@
|
||||
"@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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@@ -4115,6 +4101,7 @@
|
||||
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.23",
|
||||
"@asamuzakjp/dom-selector": "^6.7.4",
|
||||
@@ -4194,257 +4181,6 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -4930,6 +4666,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4997,6 +4734,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -6027,6 +5765,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6115,13 +5854,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
|
||||
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -6195,6 +5935,7 @@
|
||||
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.10",
|
||||
"@vitest/mocker": "4.0.10",
|
||||
@@ -6279,6 +6020,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
|
||||
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.21",
|
||||
"@vue/compiler-sfc": "3.5.21",
|
||||
@@ -6311,7 +6053,6 @@
|
||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"eslint-scope": "^8.2.0",
|
||||
@@ -6336,7 +6077,6 @@
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,210 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 配置导出数据结构
|
||||
export interface ConfigExportData {
|
||||
version: string
|
||||
exported_at: string
|
||||
global_models: GlobalModelExport[]
|
||||
providers: ProviderExport[]
|
||||
}
|
||||
|
||||
// 用户导出数据结构
|
||||
export interface UsersExportData {
|
||||
version: string
|
||||
exported_at: string
|
||||
users: UserExport[]
|
||||
}
|
||||
|
||||
export interface UserExport {
|
||||
email: string
|
||||
username: string
|
||||
password_hash: string
|
||||
role: string
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
model_capability_settings?: any
|
||||
quota_usd?: number | null
|
||||
used_usd?: number
|
||||
total_usd?: number
|
||||
is_active: boolean
|
||||
api_keys: UserApiKeyExport[]
|
||||
}
|
||||
|
||||
export interface UserApiKeyExport {
|
||||
key_hash: string
|
||||
key_encrypted?: string | null
|
||||
name?: string | null
|
||||
is_standalone: boolean
|
||||
balance_used_usd?: number
|
||||
current_balance_usd?: number | null
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number
|
||||
concurrent_limit?: number | null
|
||||
force_capabilities?: any
|
||||
is_active: boolean
|
||||
auto_delete_on_expiry?: boolean
|
||||
total_requests?: number
|
||||
total_cost_usd?: number
|
||||
}
|
||||
|
||||
export interface GlobalModelExport {
|
||||
name: string
|
||||
display_name: string
|
||||
default_price_per_request?: number | null
|
||||
default_tiered_pricing: any
|
||||
supported_capabilities?: string[] | null
|
||||
config?: any
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ProviderExport {
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string | null
|
||||
website?: string | null
|
||||
billing_type?: string | null
|
||||
monthly_quota_usd?: number | null
|
||||
quota_reset_day?: number
|
||||
rpm_limit?: number | null
|
||||
provider_priority?: number
|
||||
is_active: boolean
|
||||
rate_limit?: number | null
|
||||
concurrent_limit?: number | null
|
||||
config?: any
|
||||
endpoints: EndpointExport[]
|
||||
models: ModelExport[]
|
||||
}
|
||||
|
||||
export interface EndpointExport {
|
||||
api_format: string
|
||||
base_url: string
|
||||
headers?: any
|
||||
timeout?: number
|
||||
max_retries?: number
|
||||
max_concurrent?: number | null
|
||||
rate_limit?: number | null
|
||||
is_active: boolean
|
||||
custom_path?: string | null
|
||||
config?: any
|
||||
keys: KeyExport[]
|
||||
}
|
||||
|
||||
export interface KeyExport {
|
||||
api_key: string
|
||||
name?: string | null
|
||||
note?: string | null
|
||||
rate_multiplier?: number
|
||||
internal_priority?: number
|
||||
global_priority?: number | null
|
||||
max_concurrent?: number | null
|
||||
rate_limit?: number | null
|
||||
daily_limit?: number | null
|
||||
monthly_limit?: number | null
|
||||
allowed_models?: string[] | null
|
||||
capabilities?: any
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ModelExport {
|
||||
global_model_name: string | null
|
||||
provider_model_name: string
|
||||
provider_model_mappings?: any
|
||||
price_per_request?: number | null
|
||||
tiered_pricing?: any
|
||||
supports_vision?: boolean | null
|
||||
supports_function_calling?: boolean | null
|
||||
supports_streaming?: boolean | null
|
||||
supports_extended_thinking?: boolean | null
|
||||
supports_image_generation?: boolean | null
|
||||
is_active: boolean
|
||||
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 模型查询响应
|
||||
export interface ProviderModelsQueryResponse {
|
||||
success: boolean
|
||||
data: {
|
||||
models: Array<{
|
||||
id: string
|
||||
object?: string
|
||||
created?: number
|
||||
owned_by?: string
|
||||
display_name?: string
|
||||
api_format?: string
|
||||
}>
|
||||
error?: string
|
||||
}
|
||||
provider: {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigImportRequest extends ConfigExportData {
|
||||
merge_mode: 'skip' | 'overwrite' | 'error'
|
||||
}
|
||||
|
||||
export interface UsersImportRequest extends UsersExportData {
|
||||
merge_mode: 'skip' | 'overwrite' | 'error'
|
||||
}
|
||||
|
||||
export interface UsersImportResponse {
|
||||
message: string
|
||||
stats: {
|
||||
users: { created: number; updated: number; skipped: number }
|
||||
api_keys: { created: number; skipped: number }
|
||||
errors: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigImportResponse {
|
||||
message: string
|
||||
stats: {
|
||||
global_models: { created: number; updated: number; skipped: number }
|
||||
providers: { created: number; updated: number; skipped: number }
|
||||
endpoints: { created: number; updated: number; skipped: number }
|
||||
keys: { created: number; updated: number; skipped: number }
|
||||
models: { created: number; updated: number; skipped: number }
|
||||
errors: string[]
|
||||
}
|
||||
}
|
||||
|
||||
// API密钥管理相关接口定义
|
||||
export interface AdminApiKey {
|
||||
id: string // UUID
|
||||
@@ -173,5 +378,100 @@ export const adminApi = {
|
||||
'/api/admin/system/api-formats'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导出配置
|
||||
async exportConfig(): Promise<ConfigExportData> {
|
||||
const response = await apiClient.get<ConfigExportData>('/api/admin/system/config/export')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导入配置
|
||||
async importConfig(data: ConfigImportRequest): Promise<ConfigImportResponse> {
|
||||
const response = await apiClient.post<ConfigImportResponse>(
|
||||
'/api/admin/system/config/import',
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导出用户数据
|
||||
async exportUsers(): Promise<UsersExportData> {
|
||||
const response = await apiClient.get<UsersExportData>('/api/admin/system/users/export')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导入用户数据
|
||||
async importUsers(data: UsersImportRequest): Promise<UsersImportResponse> {
|
||||
const response = await apiClient.post<UsersImportResponse>(
|
||||
'/api/admin/system/users/import',
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 查询 Provider 可用模型(从上游 API 获取)
|
||||
async queryProviderModels(providerId: string, apiKeyId?: string): Promise<ProviderModelsQueryResponse> {
|
||||
const response = await apiClient.post<ProviderModelsQueryResponse>(
|
||||
'/api/admin/provider-query/models',
|
||||
{ provider_id: providerId, api_key_id: apiKeyId }
|
||||
)
|
||||
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 // 允许扩展其他统计数据
|
||||
}
|
||||
|
||||
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 {
|
||||
id: string // UUID
|
||||
username: string
|
||||
@@ -87,5 +137,41 @@ export const authApi = {
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface UserAffinity {
|
||||
key_name: string | null
|
||||
key_prefix: string | null // Provider Key 脱敏显示(前4...后4)
|
||||
rate_multiplier: number
|
||||
global_model_id: string | null // 原始的 global_model_id(用于删除)
|
||||
model_name: string | null // 模型名称(如 claude-haiku-4-5-20250514)
|
||||
model_display_name: string | null // 模型显示名称(如 Claude Haiku 4.5)
|
||||
api_format: string | null // API 格式 (claude/openai)
|
||||
@@ -119,6 +120,18 @@ export const cacheApi = {
|
||||
await api.delete(`/api/admin/monitoring/cache/users/${userIdentifier}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除单条缓存亲和性
|
||||
*
|
||||
* @param affinityKey API Key ID
|
||||
* @param endpointId Endpoint ID
|
||||
* @param modelId GlobalModel ID
|
||||
* @param apiFormat API 格式 (claude/openai)
|
||||
*/
|
||||
async clearSingleAffinity(affinityKey: string, endpointId: string, modelId: string, apiFormat: string): Promise<void> {
|
||||
await api.delete(`/api/admin/monitoring/cache/affinity/${affinityKey}/${endpointId}/${modelId}/${apiFormat}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
@@ -271,3 +284,93 @@ export const cacheAnalysisApi = {
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模型映射缓存管理 API ====================
|
||||
|
||||
// 映射条目
|
||||
export interface ModelMappingItem {
|
||||
mapping_name: string
|
||||
global_model_name: string | null
|
||||
global_model_display_name: string | null
|
||||
providers: string[]
|
||||
ttl: number | null
|
||||
}
|
||||
|
||||
// 未映射的条目(NOT_FOUND、invalid、error)
|
||||
export interface UnmappedEntry {
|
||||
mapping_name: string
|
||||
status: 'not_found' | 'invalid' | 'error'
|
||||
ttl: number | null
|
||||
}
|
||||
|
||||
// Provider 模型映射缓存(Redis 缓存)
|
||||
export interface ProviderModelMapping {
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
global_model_id: string
|
||||
global_model_name: string
|
||||
global_model_display_name: string | null
|
||||
provider_model_name: string
|
||||
aliases: string[] | null
|
||||
ttl: number | null
|
||||
hit_count: number
|
||||
}
|
||||
|
||||
export interface ModelMappingCacheStats {
|
||||
available: boolean
|
||||
message?: string
|
||||
ttl_seconds?: number
|
||||
total_keys?: number
|
||||
breakdown?: {
|
||||
model_by_id: number
|
||||
model_by_provider_global: number
|
||||
global_model_by_id: number
|
||||
global_model_by_name: number
|
||||
global_model_resolve: number
|
||||
}
|
||||
mappings?: ModelMappingItem[]
|
||||
provider_model_mappings?: ProviderModelMapping[] | null
|
||||
unmapped?: UnmappedEntry[] | null
|
||||
}
|
||||
|
||||
export interface ClearModelMappingCacheResponse {
|
||||
status: string
|
||||
message: string
|
||||
deleted_count?: number
|
||||
model_name?: string
|
||||
deleted_keys?: string[]
|
||||
}
|
||||
|
||||
export const modelMappingCacheApi = {
|
||||
/**
|
||||
* 获取模型映射缓存统计
|
||||
*/
|
||||
async getStats(): Promise<ModelMappingCacheStats> {
|
||||
const response = await api.get('/api/admin/monitoring/cache/model-mapping/stats')
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有模型映射缓存
|
||||
*/
|
||||
async clearAll(): Promise<ClearModelMappingCacheResponse> {
|
||||
const response = await api.delete('/api/admin/monitoring/cache/model-mapping')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除指定模型名称的映射缓存
|
||||
*/
|
||||
async clearByName(modelName: string): Promise<ClearModelMappingCacheResponse> {
|
||||
const response = await api.delete(`/api/admin/monitoring/cache/model-mapping/${encodeURIComponent(modelName)}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除指定 Provider 和 GlobalModel 的映射缓存
|
||||
*/
|
||||
async clearProviderModel(providerId: string, globalModelId: string): Promise<ClearModelMappingCacheResponse> {
|
||||
const response = await api.delete(`/api/admin/monitoring/cache/model-mapping/provider/${providerId}/${globalModelId}`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface DashboardStatsResponse {
|
||||
cache_stats?: CacheStats
|
||||
users?: UserStats
|
||||
token_breakdown?: TokenBreakdown
|
||||
// 普通用户专用字段
|
||||
monthly_cost?: number
|
||||
}
|
||||
|
||||
export interface RecentRequestsResponse {
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* 模型别名管理 API
|
||||
*/
|
||||
|
||||
import client from '../client'
|
||||
import type { ModelMapping, ModelMappingCreate, ModelMappingUpdate } from './types'
|
||||
|
||||
export interface ModelAlias {
|
||||
id: string
|
||||
alias: string
|
||||
global_model_id: string
|
||||
global_model_name: string | null
|
||||
global_model_display_name: string | null
|
||||
provider_id: string | null
|
||||
provider_name: string | null
|
||||
scope: 'global' | 'provider'
|
||||
mapping_type: 'alias' | 'mapping'
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateModelAliasRequest {
|
||||
alias: string
|
||||
global_model_id: string
|
||||
provider_id?: string | null
|
||||
mapping_type?: 'alias' | 'mapping'
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateModelAliasRequest {
|
||||
alias?: string
|
||||
global_model_id?: string
|
||||
provider_id?: string | null
|
||||
mapping_type?: 'alias' | 'mapping'
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
function transformMapping(mapping: ModelMapping): ModelAlias {
|
||||
return {
|
||||
id: mapping.id,
|
||||
alias: mapping.source_model,
|
||||
global_model_id: mapping.target_global_model_id,
|
||||
global_model_name: mapping.target_global_model_name,
|
||||
global_model_display_name: mapping.target_global_model_display_name,
|
||||
provider_id: mapping.provider_id ?? null,
|
||||
provider_name: mapping.provider_name ?? null,
|
||||
scope: mapping.scope,
|
||||
mapping_type: mapping.mapping_type || 'alias',
|
||||
is_active: mapping.is_active,
|
||||
created_at: mapping.created_at,
|
||||
updated_at: mapping.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取别名列表
|
||||
*/
|
||||
export async function getAliases(params?: {
|
||||
provider_id?: string
|
||||
global_model_id?: string
|
||||
is_active?: boolean
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<ModelAlias[]> {
|
||||
const response = await client.get('/api/admin/models/mappings', {
|
||||
params: {
|
||||
provider_id: params?.provider_id,
|
||||
target_global_model_id: params?.global_model_id,
|
||||
is_active: params?.is_active,
|
||||
skip: params?.skip,
|
||||
limit: params?.limit
|
||||
}
|
||||
})
|
||||
return (response.data as ModelMapping[]).map(transformMapping)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个别名
|
||||
*/
|
||||
export async function getAlias(id: string): Promise<ModelAlias> {
|
||||
const response = await client.get(`/api/admin/models/mappings/${id}`)
|
||||
return transformMapping(response.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建别名
|
||||
*/
|
||||
export async function createAlias(data: CreateModelAliasRequest): Promise<ModelAlias> {
|
||||
const payload: ModelMappingCreate = {
|
||||
source_model: data.alias,
|
||||
target_global_model_id: data.global_model_id,
|
||||
provider_id: data.provider_id ?? null,
|
||||
mapping_type: data.mapping_type ?? 'alias',
|
||||
is_active: data.is_active ?? true
|
||||
}
|
||||
const response = await client.post('/api/admin/models/mappings', payload)
|
||||
return transformMapping(response.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新别名
|
||||
*/
|
||||
export async function updateAlias(id: string, data: UpdateModelAliasRequest): Promise<ModelAlias> {
|
||||
const payload: ModelMappingUpdate = {
|
||||
source_model: data.alias,
|
||||
target_global_model_id: data.global_model_id,
|
||||
provider_id: data.provider_id ?? null,
|
||||
mapping_type: data.mapping_type,
|
||||
is_active: data.is_active
|
||||
}
|
||||
const response = await client.patch(`/api/admin/models/mappings/${id}`, payload)
|
||||
return transformMapping(response.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除别名
|
||||
*/
|
||||
export async function deleteAlias(id: string): Promise<void> {
|
||||
await client.delete(`/api/admin/models/mappings/${id}`)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from '../client'
|
||||
import type { ProviderEndpoint } from './types'
|
||||
import type { ProviderEndpoint, ProxyConfig } from './types'
|
||||
|
||||
/**
|
||||
* 获取指定 Provider 的所有 Endpoints
|
||||
@@ -38,6 +38,7 @@ export async function createEndpoint(
|
||||
rate_limit?: number
|
||||
is_active?: boolean
|
||||
config?: Record<string, any>
|
||||
proxy?: ProxyConfig | null
|
||||
}
|
||||
): Promise<ProviderEndpoint> {
|
||||
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
|
||||
@@ -63,6 +64,7 @@ export async function updateEndpoint(
|
||||
rate_limit: number
|
||||
is_active: boolean
|
||||
config: Record<string, any>
|
||||
proxy: ProxyConfig | null
|
||||
}>
|
||||
): Promise<ProviderEndpoint> {
|
||||
const response = await client.put(`/api/admin/endpoints/${endpointId}`, data)
|
||||
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
GlobalModelUpdate,
|
||||
GlobalModelResponse,
|
||||
GlobalModelWithStats,
|
||||
GlobalModelListResponse
|
||||
GlobalModelListResponse,
|
||||
ModelCatalogProviderDetail,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -83,3 +84,16 @@ export async function batchAssignToProviders(
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,5 @@ export * from './endpoints'
|
||||
export * from './keys'
|
||||
export * from './health'
|
||||
export * from './models'
|
||||
export * from './aliases'
|
||||
export * from './adaptive'
|
||||
export * from './global-models'
|
||||
|
||||
@@ -110,6 +110,14 @@ export async function updateEndpointKey(
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -5,9 +5,8 @@ import type {
|
||||
ModelUpdate,
|
||||
ModelCatalogResponse,
|
||||
ProviderAvailableSourceModelsResponse,
|
||||
UpdateModelMappingRequest,
|
||||
UpdateModelMappingResponse,
|
||||
DeleteModelMappingResponse
|
||||
UpstreamModel,
|
||||
ImportFromUpstreamResponse,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -99,27 +98,6 @@ export async function getProviderAvailableSourceModels(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新目录中的模型映射
|
||||
*/
|
||||
export async function updateCatalogMapping(
|
||||
mappingId: string,
|
||||
data: UpdateModelMappingRequest
|
||||
): Promise<UpdateModelMappingResponse> {
|
||||
const response = await client.put(`/api/admin/models/catalog/mappings/${mappingId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目录中的模型映射
|
||||
*/
|
||||
export async function deleteCatalogMapping(
|
||||
mappingId: string
|
||||
): Promise<DeleteModelMappingResponse> {
|
||||
const response = await client.delete(`/api/admin/models/catalog/mappings/${mappingId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量为 Provider 关联 GlobalModels
|
||||
*/
|
||||
@@ -143,3 +121,40 @@ export async function batchAssignModelsToProvider(
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试模型连接性
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
// API 格式常量
|
||||
export const API_FORMATS = {
|
||||
CLAUDE: 'CLAUDE',
|
||||
CLAUDE_CLI: 'CLAUDE_CLI',
|
||||
OPENAI: 'OPENAI',
|
||||
OPENAI_CLI: 'OPENAI_CLI',
|
||||
GEMINI: 'GEMINI',
|
||||
GEMINI_CLI: 'GEMINI_CLI',
|
||||
} as const
|
||||
|
||||
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
|
||||
|
||||
// API 格式显示名称映射(按品牌分组:API 在前,CLI 在后)
|
||||
export const API_FORMAT_LABELS: Record<string, string> = {
|
||||
[API_FORMATS.CLAUDE]: 'Claude',
|
||||
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
|
||||
[API_FORMATS.OPENAI]: 'OpenAI',
|
||||
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
|
||||
[API_FORMATS.GEMINI]: 'Gemini',
|
||||
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理配置类型
|
||||
*/
|
||||
export interface ProxyConfig {
|
||||
url: string
|
||||
username?: string
|
||||
password?: string
|
||||
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||
}
|
||||
|
||||
export interface ProviderEndpoint {
|
||||
id: string
|
||||
provider_id: string
|
||||
@@ -19,6 +51,7 @@ export interface ProviderEndpoint {
|
||||
last_failure_at?: string
|
||||
is_active: boolean
|
||||
config?: Record<string, any>
|
||||
proxy?: ProxyConfig | null
|
||||
total_keys: number
|
||||
active_keys: number
|
||||
created_at: string
|
||||
@@ -77,6 +110,24 @@ export interface EndpointAPIKey {
|
||||
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 {
|
||||
api_format: string
|
||||
health_score: number
|
||||
@@ -211,11 +262,21 @@ export interface ConcurrencyStatus {
|
||||
key_max_concurrent?: number
|
||||
}
|
||||
|
||||
export interface ProviderModelMapping {
|
||||
name: string
|
||||
priority: number // 优先级(数字越小优先级越高)
|
||||
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
|
||||
}
|
||||
|
||||
// 保留别名以保持向后兼容
|
||||
export type ProviderModelAlias = ProviderModelMapping
|
||||
|
||||
export interface Model {
|
||||
id: string
|
||||
provider_id: string
|
||||
global_model_id?: string // 关联的 GlobalModel ID
|
||||
provider_model_name: string // Provider 侧的模型名称(原 name)
|
||||
provider_model_name: string // Provider 侧的主模型名称
|
||||
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
|
||||
price_per_request?: number | null // 按次计费价格
|
||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||
@@ -244,7 +305,8 @@ export interface Model {
|
||||
}
|
||||
|
||||
export interface ModelCreate {
|
||||
provider_model_name: string // Provider 侧的模型名称(原 name)
|
||||
provider_model_name: string // Provider 侧的主模型名称
|
||||
provider_model_mappings?: ProviderModelMapping[] // 模型名称映射列表(带优先级)
|
||||
global_model_id: string // 关联的 GlobalModel ID(必填)
|
||||
// 计费配置(可选,为空时使用 GlobalModel 默认值)
|
||||
price_per_request?: number // 按次计费价格
|
||||
@@ -261,6 +323,7 @@ export interface ModelCreate {
|
||||
|
||||
export interface ModelUpdate {
|
||||
provider_model_name?: string
|
||||
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||
global_model_id?: string
|
||||
price_per_request?: number | null // 按次计费价格(null 表示清空/使用默认值)
|
||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||
@@ -273,21 +336,6 @@ export interface ModelUpdate {
|
||||
is_available?: boolean
|
||||
}
|
||||
|
||||
export interface ModelMapping {
|
||||
id: string
|
||||
source_model: string // 别名/源模型名
|
||||
target_global_model_id: string // 目标 GlobalModel ID
|
||||
target_global_model_name: string | null
|
||||
target_global_model_display_name: string | null
|
||||
provider_id: string | null
|
||||
provider_name: string | null
|
||||
scope: 'global' | 'provider'
|
||||
mapping_type: 'alias' | 'mapping'
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ModelCapabilities {
|
||||
supports_vision: boolean
|
||||
supports_function_calling: boolean
|
||||
@@ -335,7 +383,6 @@ export interface ModelCatalogItem {
|
||||
global_model_name: string // GlobalModel.name(原 source_model)
|
||||
display_name: string // GlobalModel.display_name
|
||||
description?: string | null // GlobalModel.description
|
||||
aliases: string[] // 所有指向该 GlobalModel 的别名列表
|
||||
providers: ModelCatalogProviderDetail[] // 支持该模型的 Provider 列表
|
||||
price_range: ModelPriceRange // 价格区间
|
||||
total_providers: number
|
||||
@@ -351,8 +398,6 @@ export interface ProviderAvailableSourceModel {
|
||||
global_model_name: string // GlobalModel.name(原 source_model)
|
||||
display_name: string // GlobalModel.display_name
|
||||
provider_model_name: string // Model.provider_model_name(Provider 侧的模型名)
|
||||
has_alias: boolean // 是否有别名指向该 GlobalModel
|
||||
aliases: string[] // 别名列表
|
||||
model_id?: string | null // Model.id
|
||||
price: ProviderModelPriceInfo
|
||||
capabilities: ModelCapabilities
|
||||
@@ -371,65 +416,6 @@ export interface BatchAssignProviderConfig {
|
||||
model_id?: string
|
||||
}
|
||||
|
||||
export interface BatchAssignModelMappingRequest {
|
||||
global_model_id: string // 要分配的 GlobalModel ID(原 source_model)
|
||||
providers: BatchAssignProviderConfig[]
|
||||
}
|
||||
|
||||
export interface BatchAssignProviderResult {
|
||||
provider_id: string
|
||||
mapping_id?: string | null
|
||||
created_model: boolean
|
||||
model_id?: string | null
|
||||
updated: boolean
|
||||
}
|
||||
|
||||
export interface BatchAssignError {
|
||||
provider_id: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface BatchAssignModelMappingResponse {
|
||||
success: boolean
|
||||
created_mappings: BatchAssignProviderResult[]
|
||||
errors: BatchAssignError[]
|
||||
}
|
||||
|
||||
export interface ModelMappingCreate {
|
||||
source_model: string // 源模型名或别名
|
||||
target_global_model_id: string // 目标 GlobalModel ID
|
||||
provider_id?: string | null
|
||||
mapping_type?: 'alias' | 'mapping'
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface ModelMappingUpdate {
|
||||
source_model?: string // 源模型名或别名
|
||||
target_global_model_id?: string // 目标 GlobalModel ID
|
||||
provider_id?: string | null
|
||||
mapping_type?: 'alias' | 'mapping'
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateModelMappingRequest {
|
||||
source_model?: string
|
||||
target_global_model_id?: string
|
||||
provider_id?: string | null
|
||||
mapping_type?: 'alias' | 'mapping'
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateModelMappingResponse {
|
||||
success: boolean
|
||||
mapping_id: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DeleteModelMappingResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface AdaptiveStatsResponse {
|
||||
adaptive_mode: boolean
|
||||
current_limit: number | null
|
||||
@@ -476,67 +462,45 @@ export interface TieredPricingConfig {
|
||||
export interface GlobalModelCreate {
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string
|
||||
official_url?: string
|
||||
icon_url?: string
|
||||
// 按次计费配置(可选,与阶梯计费叠加)
|
||||
default_price_per_request?: number
|
||||
// 阶梯计费配置(必填,固定价格用单阶梯表示)
|
||||
default_tiered_pricing: TieredPricingConfig
|
||||
// 默认能力配置
|
||||
default_supports_vision?: boolean
|
||||
default_supports_function_calling?: boolean
|
||||
default_supports_streaming?: boolean
|
||||
default_supports_extended_thinking?: boolean
|
||||
default_supports_image_generation?: boolean
|
||||
// Key 能力配置 - 模型支持的能力列表
|
||||
supported_capabilities?: string[]
|
||||
// 模型配置(JSON格式)- 包含能力、规格、元信息等
|
||||
config?: Record<string, any>
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface GlobalModelUpdate {
|
||||
display_name?: string
|
||||
description?: string
|
||||
official_url?: string
|
||||
icon_url?: string
|
||||
is_active?: boolean
|
||||
// 按次计费配置
|
||||
default_price_per_request?: number | null // null 表示清空
|
||||
// 阶梯计费配置
|
||||
default_tiered_pricing?: TieredPricingConfig
|
||||
// 默认能力配置
|
||||
default_supports_vision?: boolean
|
||||
default_supports_function_calling?: boolean
|
||||
default_supports_streaming?: boolean
|
||||
default_supports_extended_thinking?: boolean
|
||||
default_supports_image_generation?: boolean
|
||||
// Key 能力配置 - 模型支持的能力列表
|
||||
supported_capabilities?: string[] | null
|
||||
// 模型配置(JSON格式)- 包含能力、规格、元信息等
|
||||
config?: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface GlobalModelResponse {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string
|
||||
official_url?: string
|
||||
icon_url?: string
|
||||
is_active: boolean
|
||||
// 按次计费配置
|
||||
default_price_per_request?: number
|
||||
// 阶梯计费配置(必填)
|
||||
default_tiered_pricing: TieredPricingConfig
|
||||
// 默认能力配置
|
||||
default_supports_vision?: boolean
|
||||
default_supports_function_calling?: boolean
|
||||
default_supports_streaming?: boolean
|
||||
default_supports_extended_thinking?: boolean
|
||||
default_supports_image_generation?: boolean
|
||||
// Key 能力配置 - 模型支持的能力列表
|
||||
supported_capabilities?: string[] | null
|
||||
// 模型配置(JSON格式)
|
||||
config?: Record<string, any> | null
|
||||
// 统计数据
|
||||
provider_count?: number
|
||||
alias_count?: number
|
||||
usage_count?: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
@@ -552,3 +516,42 @@ export interface GlobalModelListResponse {
|
||||
models: GlobalModelResponse[]
|
||||
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,
|
||||
deleteGlobalModel,
|
||||
batchAssignToProviders,
|
||||
getGlobalModelProviders,
|
||||
} from './endpoints/global-models'
|
||||
|
||||
288
frontend/src/api/models-dev.ts
Normal file
288
frontend/src/api/models-dev.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Models.dev API 服务
|
||||
* 通过后端代理获取 models.dev 数据(解决跨域问题)
|
||||
*/
|
||||
|
||||
import api from './client'
|
||||
|
||||
// 缓存配置
|
||||
const CACHE_KEY = 'models_dev_cache'
|
||||
const CACHE_DURATION = 15 * 60 * 1000 // 15 分钟
|
||||
|
||||
// Models.dev API 数据结构
|
||||
export interface ModelsDevCost {
|
||||
input?: number
|
||||
output?: number
|
||||
reasoning?: number
|
||||
cache_read?: number
|
||||
}
|
||||
|
||||
export interface ModelsDevLimit {
|
||||
context?: number
|
||||
output?: number
|
||||
}
|
||||
|
||||
export interface ModelsDevModel {
|
||||
id: string
|
||||
name: string
|
||||
family?: string
|
||||
reasoning?: boolean
|
||||
tool_call?: boolean
|
||||
structured_output?: boolean
|
||||
temperature?: boolean
|
||||
attachment?: boolean
|
||||
knowledge?: string
|
||||
release_date?: string
|
||||
last_updated?: string
|
||||
input?: string[] // 输入模态: text, image, audio, video, pdf
|
||||
output?: string[] // 输出模态: text, image, audio
|
||||
open_weights?: boolean
|
||||
cost?: ModelsDevCost
|
||||
limit?: ModelsDevLimit
|
||||
deprecated?: boolean
|
||||
}
|
||||
|
||||
export interface ModelsDevProvider {
|
||||
id: string
|
||||
env?: string[]
|
||||
npm?: string
|
||||
api?: string
|
||||
name: string
|
||||
doc?: string
|
||||
models: Record<string, ModelsDevModel>
|
||||
official?: boolean // 是否为官方提供商
|
||||
}
|
||||
|
||||
export type ModelsDevData = Record<string, ModelsDevProvider>
|
||||
|
||||
// 扁平化的模型列表项(用于搜索和选择)
|
||||
export interface ModelsDevModelItem {
|
||||
providerId: string
|
||||
providerName: string
|
||||
modelId: string
|
||||
modelName: string
|
||||
family?: string
|
||||
inputPrice?: number
|
||||
outputPrice?: number
|
||||
contextLimit?: number
|
||||
outputLimit?: number
|
||||
supportsVision?: boolean
|
||||
supportsToolCall?: boolean
|
||||
supportsReasoning?: boolean
|
||||
supportsStructuredOutput?: boolean
|
||||
supportsTemperature?: boolean
|
||||
supportsAttachment?: boolean
|
||||
openWeights?: boolean
|
||||
deprecated?: boolean
|
||||
official?: boolean // 是否来自官方提供商
|
||||
// 用于 display_metadata 的额外字段
|
||||
knowledgeCutoff?: string
|
||||
releaseDate?: string
|
||||
inputModalities?: string[]
|
||||
outputModalities?: string[]
|
||||
}
|
||||
|
||||
interface CacheData {
|
||||
timestamp: number
|
||||
data: ModelsDevData
|
||||
}
|
||||
|
||||
// 内存缓存
|
||||
let memoryCache: CacheData | null = null
|
||||
|
||||
function hasOfficialFlag(data: ModelsDevData): boolean {
|
||||
return Object.values(data).some(provider => typeof provider?.official === 'boolean')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 models.dev 数据(带缓存)
|
||||
*/
|
||||
export async function getModelsDevData(): Promise<ModelsDevData> {
|
||||
// 1. 检查内存缓存
|
||||
if (memoryCache && Date.now() - memoryCache.timestamp < CACHE_DURATION) {
|
||||
// 兼容旧缓存:没有 official 字段时丢弃,强制刷新一次
|
||||
if (hasOfficialFlag(memoryCache.data)) {
|
||||
return memoryCache.data
|
||||
}
|
||||
memoryCache = null
|
||||
}
|
||||
|
||||
// 2. 检查 localStorage 缓存
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY)
|
||||
if (cached) {
|
||||
const cacheData: CacheData = JSON.parse(cached)
|
||||
if (Date.now() - cacheData.timestamp < CACHE_DURATION) {
|
||||
// 兼容旧缓存:没有 official 字段时丢弃,强制刷新一次
|
||||
if (hasOfficialFlag(cacheData.data)) {
|
||||
memoryCache = cacheData
|
||||
return cacheData.data
|
||||
}
|
||||
localStorage.removeItem(CACHE_KEY)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 缓存解析失败,忽略
|
||||
}
|
||||
|
||||
// 3. 从后端代理获取新数据
|
||||
const response = await api.get<ModelsDevData>('/api/admin/models/external')
|
||||
const data = response.data
|
||||
|
||||
// 4. 更新缓存
|
||||
const cacheData: CacheData = {
|
||||
timestamp: Date.now(),
|
||||
data,
|
||||
}
|
||||
memoryCache = cacheData
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData))
|
||||
} catch {
|
||||
// localStorage 写入失败,忽略
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 模型列表缓存(避免重复转换)
|
||||
let modelsListCache: ModelsDevModelItem[] | null = null
|
||||
let modelsListCacheTimestamp: number | null = null
|
||||
|
||||
/**
|
||||
* 获取扁平化的模型列表
|
||||
* 数据只加载一次,通过参数过滤官方/全部
|
||||
*/
|
||||
export async function getModelsDevList(officialOnly: boolean = true): Promise<ModelsDevModelItem[]> {
|
||||
const data = await getModelsDevData()
|
||||
const currentTimestamp = memoryCache?.timestamp ?? 0
|
||||
|
||||
// 如果缓存为空或数据已刷新,构建一次
|
||||
if (!modelsListCache || modelsListCacheTimestamp !== currentTimestamp) {
|
||||
const items: ModelsDevModelItem[] = []
|
||||
|
||||
for (const [providerId, provider] of Object.entries(data)) {
|
||||
if (!provider.models) continue
|
||||
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
items.push({
|
||||
providerId,
|
||||
providerName: provider.name,
|
||||
modelId,
|
||||
modelName: model.name || modelId,
|
||||
family: model.family,
|
||||
inputPrice: model.cost?.input,
|
||||
outputPrice: model.cost?.output,
|
||||
contextLimit: model.limit?.context,
|
||||
outputLimit: model.limit?.output,
|
||||
supportsVision: model.input?.includes('image'),
|
||||
supportsToolCall: model.tool_call,
|
||||
supportsReasoning: model.reasoning,
|
||||
supportsStructuredOutput: model.structured_output,
|
||||
supportsTemperature: model.temperature,
|
||||
supportsAttachment: model.attachment,
|
||||
openWeights: model.open_weights,
|
||||
deprecated: model.deprecated,
|
||||
official: provider.official,
|
||||
// display_metadata 相关字段
|
||||
knowledgeCutoff: model.knowledge,
|
||||
releaseDate: model.release_date,
|
||||
inputModalities: model.input,
|
||||
outputModalities: model.output,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 按 provider 名称和模型名称排序
|
||||
items.sort((a, b) => {
|
||||
const providerCompare = a.providerName.localeCompare(b.providerName)
|
||||
if (providerCompare !== 0) return providerCompare
|
||||
return a.modelName.localeCompare(b.modelName)
|
||||
})
|
||||
|
||||
modelsListCache = items
|
||||
modelsListCacheTimestamp = currentTimestamp
|
||||
}
|
||||
|
||||
// 根据参数过滤
|
||||
if (officialOnly) {
|
||||
return modelsListCache.filter(m => m.official)
|
||||
}
|
||||
return modelsListCache
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索模型
|
||||
* 搜索时包含所有提供商(包括第三方)
|
||||
*/
|
||||
export async function searchModelsDevModels(
|
||||
query: string,
|
||||
options?: {
|
||||
limit?: number
|
||||
excludeDeprecated?: boolean
|
||||
}
|
||||
): Promise<ModelsDevModelItem[]> {
|
||||
// 搜索时包含全部提供商
|
||||
const allModels = await getModelsDevList(false)
|
||||
const { limit = 50, excludeDeprecated = true } = options || {}
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
|
||||
const filtered = allModels.filter((model) => {
|
||||
if (excludeDeprecated && model.deprecated) return false
|
||||
|
||||
// 搜索模型 ID、名称、provider 名称、family
|
||||
return (
|
||||
model.modelId.toLowerCase().includes(queryLower) ||
|
||||
model.modelName.toLowerCase().includes(queryLower) ||
|
||||
model.providerName.toLowerCase().includes(queryLower) ||
|
||||
model.family?.toLowerCase().includes(queryLower)
|
||||
)
|
||||
})
|
||||
|
||||
// 排序:精确匹配优先
|
||||
filtered.sort((a, b) => {
|
||||
const aExact =
|
||||
a.modelId.toLowerCase() === queryLower ||
|
||||
a.modelName.toLowerCase() === queryLower
|
||||
const bExact =
|
||||
b.modelId.toLowerCase() === queryLower ||
|
||||
b.modelName.toLowerCase() === queryLower
|
||||
if (aExact && !bExact) return -1
|
||||
if (!aExact && bExact) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
return filtered.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定模型详情
|
||||
*/
|
||||
export async function getModelsDevModel(
|
||||
providerId: string,
|
||||
modelId: string
|
||||
): Promise<ModelsDevModel | null> {
|
||||
const data = await getModelsDevData()
|
||||
return data[providerId]?.models?.[modelId] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 provider logo URL
|
||||
*/
|
||||
export function getProviderLogoUrl(providerId: string): string {
|
||||
return `https://models.dev/logos/${providerId}.svg`
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
export function clearModelsDevCache(): void {
|
||||
memoryCache = null
|
||||
modelsListCache = null
|
||||
modelsListCacheTimestamp = null
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY)
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,14 @@ export interface PublicGlobalModel {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string | null
|
||||
description: string | null
|
||||
icon_url: string | null
|
||||
is_active: boolean
|
||||
// 阶梯计费配置
|
||||
default_tiered_pricing: TieredPricingConfig
|
||||
default_price_per_request: number | null // 按次计费价格
|
||||
// 能力
|
||||
default_supports_vision: boolean
|
||||
default_supports_function_calling: boolean
|
||||
default_supports_streaming: boolean
|
||||
default_supports_extended_thinking: boolean
|
||||
default_supports_image_generation: boolean
|
||||
// Key 能力支持
|
||||
supported_capabilities: string[] | null
|
||||
// 模型配置(JSON)
|
||||
config: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface PublicGlobalModelListResponse {
|
||||
|
||||
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>
|
||||
@@ -299,7 +299,7 @@ function formatDuration(ms: number): string {
|
||||
const hours = Math.floor(ms / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))
|
||||
if (hours > 0) {
|
||||
return `${hours}h${minutes > 0 ? minutes + 'm' : ''}`
|
||||
return `${hours}h${minutes > 0 ? `${minutes}m` : ''}`
|
||||
}
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
@@ -34,11 +34,10 @@ const buttonClass = computed(() => {
|
||||
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
|
||||
|
||||
const variantClasses = {
|
||||
default:
|
||||
'bg-primary text-white shadow-[0_20px_35px_rgba(204,120,92,0.35)] hover:bg-primary/90 hover:shadow-[0_25px_45px_rgba(204,120,92,0.45)]',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85 shadow-sm',
|
||||
default: 'bg-primary text-white hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85',
|
||||
outline:
|
||||
'border border-border/60 bg-card/60 text-foreground hover:border-primary/60 hover:text-primary hover:bg-primary/10 shadow-sm backdrop-blur transition-all',
|
||||
'border border-border/60 bg-card/60 text-foreground hover:border-primary/60 hover:text-primary hover:bg-primary/10 backdrop-blur transition-all',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-inner hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 overflow-y-auto"
|
||||
class="fixed inset-0 overflow-y-auto pointer-events-none"
|
||||
:style="{ zIndex: containerZIndex }"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
@@ -16,13 +16,13 @@
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto"
|
||||
:style="{ zIndex: backdropZIndex }"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0 pointer-events-none">
|
||||
<!-- 对话框内容 -->
|
||||
<Transition
|
||||
enter-active-class="duration-300 ease-out"
|
||||
@@ -34,7 +34,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border"
|
||||
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border pointer-events-auto"
|
||||
:style="{ zIndex: contentZIndex }"
|
||||
:class="maxWidthClass"
|
||||
@click.stop
|
||||
@@ -71,8 +71,8 @@
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- 内容区域:统一添加 padding -->
|
||||
<div class="px-6 py-3">
|
||||
<!-- 内容区域:可选添加 padding -->
|
||||
<div :class="noPadding ? '' : 'px-6 py-3'">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots, type Component } from 'vue'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
|
||||
// Props 定义
|
||||
const props = defineProps<{
|
||||
@@ -104,6 +105,7 @@ const props = defineProps<{
|
||||
icon?: Component // Lucide icon component
|
||||
iconClass?: string // Custom icon color class
|
||||
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
||||
noPadding?: boolean // Disable default content padding
|
||||
}>()
|
||||
|
||||
// Emits 定义
|
||||
@@ -157,4 +159,16 @@ const maxWidthClass = computed(() => {
|
||||
const containerZIndex = computed(() => props.zIndex || 60)
|
||||
const backdropZIndex = computed(() => props.zIndex || 60)
|
||||
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (isOpen.value) {
|
||||
handleClose()
|
||||
return true // 阻止其他监听器(如父级抽屉的 ESC 监听器)
|
||||
}
|
||||
return false
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
:class="inputClass"
|
||||
:value="modelValue"
|
||||
:autocomplete="autocompleteAttr"
|
||||
:data-lpignore="disableAutofill ? 'true' : undefined"
|
||||
:data-1p-ignore="disableAutofill ? 'true' : undefined"
|
||||
:data-form-type="disableAutofill ? 'other' : undefined"
|
||||
v-bind="$attrs"
|
||||
@input="handleInput"
|
||||
>
|
||||
@@ -16,6 +19,7 @@ interface Props {
|
||||
modelValue?: string | number
|
||||
class?: string
|
||||
autocomplete?: string
|
||||
disableAutofill?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -23,7 +27,12 @@ const emit = defineEmits<{
|
||||
'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(() =>
|
||||
cn(
|
||||
|
||||
@@ -45,7 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const contentClass = computed(() =>
|
||||
cn(
|
||||
'z-[100] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
|
||||
'z-[200] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
props.class
|
||||
|
||||
@@ -4,11 +4,11 @@ import { log } from '@/utils/logger'
|
||||
export function useClipboard() {
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
async function copyToClipboard(text: string, showToast = true): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制到剪贴板')
|
||||
if (showToast) success('已复制到剪贴板')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -25,17 +25,17 @@ export function useClipboard() {
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
success('已复制到剪贴板')
|
||||
if (showToast) success('已复制到剪贴板')
|
||||
return true
|
||||
}
|
||||
showError('复制失败,请手动复制')
|
||||
if (showToast) showError('复制失败,请手动复制')
|
||||
return false
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('复制失败:', err)
|
||||
showError('复制失败,请手动选择文本进行复制')
|
||||
if (showToast) showError('复制失败,请手动选择文本进行复制')
|
||||
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({
|
||||
message,
|
||||
title: title || '危险操作',
|
||||
confirmText: '删除',
|
||||
confirmText: confirmText || '删除',
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="100"
|
||||
placeholder="留空不限制"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 1, max: 10000 })"
|
||||
/>
|
||||
@@ -376,7 +376,7 @@ const form = ref<StandaloneKeyFormData>({
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
rate_limit: 100,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
@@ -389,7 +389,7 @@ function resetForm() {
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
rate_limit: 100,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
@@ -408,7 +408,7 @@ function loadKeyData() {
|
||||
initial_balance_usd: props.apiKey.initial_balance_usd,
|
||||
expire_days: props.apiKey.expire_days,
|
||||
never_expire: props.apiKey.never_expire,
|
||||
rate_limit: props.apiKey.rate_limit || 100,
|
||||
rate_limit: props.apiKey.rate_limit,
|
||||
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
||||
allowed_providers: props.apiKey.allowed_providers || [],
|
||||
allowed_api_formats: props.apiKey.allowed_api_formats || [],
|
||||
|
||||
@@ -98,12 +98,27 @@
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<p
|
||||
v-if="!isDemo"
|
||||
v-if="!isDemo && !allowRegistration"
|
||||
class="text-xs text-slate-400 dark:text-muted-foreground/80"
|
||||
>
|
||||
如需开通账户,请联系管理员配置访问权限
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<template #footer>
|
||||
@@ -124,10 +139,18 @@
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Register Dialog -->
|
||||
<RegisterDialog
|
||||
v-model:open="showRegisterDialog"
|
||||
:require-email-verification="requireEmailVerification"
|
||||
@success="handleRegisterSuccess"
|
||||
@switch-to-login="handleSwitchToLogin"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -136,6 +159,8 @@ import Label from '@/components/ui/label.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
|
||||
import RegisterDialog from './RegisterDialog.vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -151,6 +176,9 @@ const { success: showSuccess, warning: showWarning, error: showError } = useToas
|
||||
|
||||
const isOpen = ref(props.modelValue)
|
||||
const isDemo = computed(() => isDemoMode())
|
||||
const showRegisterDialog = ref(false)
|
||||
const requireEmailVerification = ref(false)
|
||||
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
isOpen.value = val
|
||||
@@ -201,4 +229,33 @@ async function handleLogin() {
|
||||
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>
|
||||
|
||||
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>
|
||||
@@ -1,384 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
:icon="dialogIcon"
|
||||
size="md"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- 模式选择(仅创建时显示) -->
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>创建类型 *</Label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="p-3 rounded-lg border-2 text-left transition-all"
|
||||
:class="[
|
||||
form.mapping_type === 'alias'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
]"
|
||||
@click="form.mapping_type = 'alias'"
|
||||
>
|
||||
<div class="font-medium text-sm">
|
||||
别名
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
名称简写,按目标模型计费
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-3 rounded-lg border-2 text-left transition-all"
|
||||
:class="[
|
||||
form.mapping_type === 'mapping'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
]"
|
||||
@click="form.mapping_type = 'mapping'"
|
||||
>
|
||||
<div class="font-medium text-sm">
|
||||
映射
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
模型降级,按源模型计费
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模式说明 -->
|
||||
<div class="rounded-lg border border-border bg-muted/50 p-3 text-sm">
|
||||
<p class="text-foreground font-medium mb-1">
|
||||
{{ form.mapping_type === 'alias' ? '别名模式' : '映射模式' }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ form.mapping_type === 'alias'
|
||||
? '用户请求此别名时,会路由到目标模型,并按目标模型价格计费。'
|
||||
: '将源模型的请求转发到目标模型处理,按源模型价格计费。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Provider 选择/作用范围 -->
|
||||
<div
|
||||
v-if="showProviderSelect"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>作用范围</Label>
|
||||
<!-- 固定 Provider 时显示只读 -->
|
||||
<div
|
||||
v-if="fixedProvider"
|
||||
class="px-3 py-2 border rounded-md bg-muted/50 text-sm"
|
||||
>
|
||||
仅 {{ fixedProvider.display_name || fixedProvider.name }}
|
||||
</div>
|
||||
<!-- 否则显示可选择的下拉 -->
|
||||
<Select
|
||||
v-else
|
||||
v-model:open="providerSelectOpen"
|
||||
:model-value="form.provider_id || 'global'"
|
||||
@update:model-value="handleProviderChange"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="选择作用范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">
|
||||
全局(所有 Provider)
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="p in providers"
|
||||
:key="p.id"
|
||||
:value="p.id"
|
||||
>
|
||||
仅 {{ p.display_name || p.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 别名模式:别名名称 -->
|
||||
<div
|
||||
v-if="form.mapping_type === 'alias'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label for="alias-name">别名名称 *</Label>
|
||||
<Input
|
||||
id="alias-name"
|
||||
v-model="form.alias"
|
||||
placeholder="如:sonnet, opus"
|
||||
:disabled="isEditMode"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditMode ? '创建后不可修改' : '用户将使用此名称请求模型' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 映射模式:选择源模型 -->
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>源模型 (用户请求的模型) *</Label>
|
||||
<Select
|
||||
v-model:open="sourceModelSelectOpen"
|
||||
:model-value="form.alias"
|
||||
:disabled="isEditMode"
|
||||
@update:model-value="form.alias = $event"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="w-full"
|
||||
:class="{ 'opacity-50': isEditMode }"
|
||||
>
|
||||
<SelectValue placeholder="请选择源模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in availableSourceModels"
|
||||
:key="model.id"
|
||||
:value="model.name"
|
||||
>
|
||||
{{ model.display_name }} ({{ model.name }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditMode ? '创建后不可修改' : '选择要被映射的源模型,计费将按此模型价格' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 目标模型选择 -->
|
||||
<div class="space-y-2">
|
||||
<Label>
|
||||
{{ form.mapping_type === 'alias' ? '目标模型 *' : '目标模型 (实际处理请求) *' }}
|
||||
</Label>
|
||||
<!-- 固定目标模型时显示只读信息 -->
|
||||
<div
|
||||
v-if="fixedTargetModel"
|
||||
class="px-3 py-2 border rounded-md bg-muted/50"
|
||||
>
|
||||
<span class="font-medium">{{ fixedTargetModel.display_name }}</span>
|
||||
<span class="text-muted-foreground ml-1">({{ fixedTargetModel.name }})</span>
|
||||
</div>
|
||||
<!-- 否则显示下拉选择 -->
|
||||
<Select
|
||||
v-else
|
||||
v-model:open="targetModelSelectOpen"
|
||||
:model-value="form.global_model_id"
|
||||
@update:model-value="form.global_model_id = $event"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="请选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in availableTargetModels"
|
||||
:key="model.id"
|
||||
:value="model.id"
|
||||
>
|
||||
{{ model.display_name }} ({{ model.name }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Loader2
|
||||
v-if="submitting"
|
||||
class="w-4 h-4 mr-2 animate-spin"
|
||||
/>
|
||||
{{ isEditMode ? '保存' : '创建' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Loader2, Tag, SquarePen } from 'lucide-vue-next'
|
||||
import { Dialog, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import type { ModelAlias, CreateModelAliasRequest, UpdateModelAliasRequest } from '@/api/endpoints/aliases'
|
||||
import type { GlobalModelResponse } from '@/api/global-models'
|
||||
|
||||
export interface ProviderOption {
|
||||
id: string
|
||||
name: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
interface AliasFormData {
|
||||
alias: string
|
||||
global_model_id: string
|
||||
provider_id: string | null
|
||||
mapping_type: 'alias' | 'mapping'
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
editingAlias?: ModelAlias | null
|
||||
globalModels: GlobalModelResponse[]
|
||||
providers?: ProviderOption[]
|
||||
fixedTargetModel?: GlobalModelResponse | null // 用于从模型详情抽屉打开时固定目标模型
|
||||
fixedProvider?: ProviderOption | null // 用于 Provider 特定别名固定 Provider
|
||||
showProviderSelect?: boolean // 是否显示 Provider 选择(默认 true)
|
||||
}>(), {
|
||||
editingAlias: null,
|
||||
providers: () => [],
|
||||
fixedTargetModel: null,
|
||||
fixedProvider: null,
|
||||
showProviderSelect: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'submit': [data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean]
|
||||
}>()
|
||||
|
||||
const { error: showError } = useToast()
|
||||
|
||||
// 状态
|
||||
const submitting = ref(false)
|
||||
const providerSelectOpen = ref(false)
|
||||
const sourceModelSelectOpen = ref(false)
|
||||
const targetModelSelectOpen = ref(false)
|
||||
const form = ref<AliasFormData>({
|
||||
alias: '',
|
||||
global_model_id: '',
|
||||
provider_id: null,
|
||||
mapping_type: 'alias',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
// 处理 Provider 选择变化
|
||||
function handleProviderChange(value: string) {
|
||||
form.value.provider_id = value === 'global' ? null : value
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
alias: '',
|
||||
global_model_id: props.fixedTargetModel?.id || '',
|
||||
provider_id: props.fixedProvider?.id || null,
|
||||
mapping_type: 'alias',
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
// 加载别名数据(编辑模式)
|
||||
function loadAliasData() {
|
||||
if (!props.editingAlias) return
|
||||
form.value = {
|
||||
alias: props.editingAlias.alias,
|
||||
global_model_id: props.editingAlias.global_model_id,
|
||||
provider_id: props.editingAlias.provider_id,
|
||||
mapping_type: props.editingAlias.mapping_type || 'alias',
|
||||
is_active: props.editingAlias.is_active,
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.open,
|
||||
entity: () => props.editingAlias,
|
||||
isLoading: submitting,
|
||||
onClose: () => emit('update:open', false),
|
||||
loadData: loadAliasData,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
if (isEditMode.value) {
|
||||
return form.value.mapping_type === 'mapping' ? '编辑映射' : '编辑别名'
|
||||
}
|
||||
if (props.fixedProvider) {
|
||||
return `创建 ${props.fixedProvider.display_name || props.fixedProvider.name} 特定别名/映射`
|
||||
}
|
||||
return '创建别名/映射'
|
||||
})
|
||||
|
||||
// 对话框描述
|
||||
const dialogDescription = computed(() => {
|
||||
if (isEditMode.value) {
|
||||
return form.value.mapping_type === 'mapping' ? '修改模型映射配置' : '修改别名设置'
|
||||
}
|
||||
return '为模型创建别名或映射规则'
|
||||
})
|
||||
|
||||
// 对话框图标
|
||||
const dialogIcon = computed(() => isEditMode.value ? SquarePen : Tag)
|
||||
|
||||
// 映射模式下可选的源模型(排除已选择的目标模型)
|
||||
const availableSourceModels = computed(() => {
|
||||
return props.globalModels.filter(m => m.id !== form.value.global_model_id)
|
||||
})
|
||||
|
||||
// 可选的目标模型(映射模式下排除已选择的源模型)
|
||||
const availableTargetModels = computed(() => {
|
||||
if (form.value.mapping_type === 'mapping' && form.value.alias) {
|
||||
// 找到源模型对应的 GlobalModel
|
||||
const sourceModel = props.globalModels.find(m => m.name === form.value.alias)
|
||||
if (sourceModel) {
|
||||
return props.globalModels.filter(m => m.id !== sourceModel.id)
|
||||
}
|
||||
}
|
||||
return props.globalModels
|
||||
})
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!form.value.alias) {
|
||||
showError(form.value.mapping_type === 'alias' ? '请输入别名名称' : '请选择源模型', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
const targetModelId = props.fixedTargetModel?.id || form.value.global_model_id
|
||||
if (!targetModelId) {
|
||||
showError('请选择目标模型', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: CreateModelAliasRequest | UpdateModelAliasRequest = {
|
||||
alias: form.value.alias,
|
||||
global_model_id: targetModelId,
|
||||
provider_id: props.fixedProvider?.id || form.value.provider_id,
|
||||
mapping_type: form.value.mapping_type,
|
||||
is_active: form.value.is_active,
|
||||
}
|
||||
|
||||
emit('submit', data, !!props.editingAlias)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,174 +2,304 @@
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
:title="isEditMode ? '编辑模型' : '创建统一模型'"
|
||||
:description="isEditMode ? '修改模型配置和价格信息' : '添加一个新的全局模型定义'"
|
||||
:description="isEditMode ? '修改模型配置和价格信息' : ''"
|
||||
:icon="isEditMode ? SquarePen : Layers"
|
||||
size="xl"
|
||||
size="3xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<form
|
||||
class="space-y-5 max-h-[70vh] overflow-y-auto pr-1"
|
||||
@submit.prevent="handleSubmit"
|
||||
<div
|
||||
class="flex gap-4"
|
||||
:class="isEditMode ? '' : 'h-[500px]'"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<section class="space-y-3">
|
||||
<h4 class="font-medium text-sm">
|
||||
基本信息
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="model-name"
|
||||
class="text-xs"
|
||||
>模型名称 *</Label>
|
||||
<Input
|
||||
id="model-name"
|
||||
v-model="form.name"
|
||||
placeholder="claude-3-5-sonnet-20241022"
|
||||
:disabled="isEditMode"
|
||||
required
|
||||
/>
|
||||
<p
|
||||
v-if="!isEditMode"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
创建后不可修改
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="model-display-name"
|
||||
class="text-xs"
|
||||
>显示名称 *</Label>
|
||||
<Input
|
||||
id="model-display-name"
|
||||
v-model="form.display_name"
|
||||
placeholder="Claude 3.5 Sonnet"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="model-description"
|
||||
class="text-xs"
|
||||
>描述</Label>
|
||||
<Input
|
||||
id="model-description"
|
||||
v-model="form.description"
|
||||
placeholder="简短描述此模型的特点"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 能力配置 -->
|
||||
<section class="space-y-2">
|
||||
<h4 class="font-medium text-sm">
|
||||
默认能力
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
v-model="form.default_supports_streaming"
|
||||
type="checkbox"
|
||||
class="rounded"
|
||||
>
|
||||
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>流式输出</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
v-model="form.default_supports_vision"
|
||||
type="checkbox"
|
||||
class="rounded"
|
||||
>
|
||||
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>视觉理解</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
v-model="form.default_supports_function_calling"
|
||||
type="checkbox"
|
||||
class="rounded"
|
||||
>
|
||||
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>工具调用</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
v-model="form.default_supports_extended_thinking"
|
||||
type="checkbox"
|
||||
class="rounded"
|
||||
>
|
||||
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>深度思考</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
v-model="form.default_supports_image_generation"
|
||||
type="checkbox"
|
||||
class="rounded"
|
||||
>
|
||||
<Image class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>图像生成</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Key 能力配置 -->
|
||||
<section
|
||||
v-if="availableCapabilities.length > 0"
|
||||
class="space-y-2"
|
||||
<!-- 左侧:模型选择(仅创建模式) -->
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="w-[260px] shrink-0 flex flex-col h-full"
|
||||
>
|
||||
<h4 class="font-medium text-sm">
|
||||
Key 能力支持
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="cap in availableCapabilities"
|
||||
:key="cap.name"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.supported_capabilities?.includes(cap.name)"
|
||||
class="rounded"
|
||||
@change="toggleCapability(cap.name)"
|
||||
>
|
||||
<span>{{ cap.display_name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<section class="space-y-3">
|
||||
<h4 class="font-medium text-sm">
|
||||
价格配置
|
||||
</h4>
|
||||
<TieredPricingEditor
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
:show-cache1h="form.supported_capabilities?.includes('cache_1h')"
|
||||
/>
|
||||
|
||||
<!-- 按次计费 -->
|
||||
<div class="flex items-center gap-3 pt-2 border-t">
|
||||
<Label class="text-xs whitespace-nowrap">按次计费 ($/次)</Label>
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative mb-3">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
:model-value="form.default_price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="w-32"
|
||||
placeholder="留空不启用"
|
||||
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索模型、提供商..."
|
||||
class="pl-8 h-8 text-sm"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">每次请求固定费用,可与 Token 计费叠加</span>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<!-- 模型列表(两级结构) -->
|
||||
<div class="flex-1 overflow-y-auto border rounded-lg min-h-0 scrollbar-thin">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- 提供商分组 -->
|
||||
<div
|
||||
v-for="group in groupedModels"
|
||||
:key="group.providerId"
|
||||
class="border-b last:border-b-0"
|
||||
>
|
||||
<!-- 提供商标题行 -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-2.5 py-2 cursor-pointer hover:bg-muted text-sm"
|
||||
@click="toggleProvider(group.providerId)"
|
||||
>
|
||||
<ChevronRight
|
||||
class="w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0"
|
||||
:class="expandedProvider === group.providerId ? 'rotate-90' : ''"
|
||||
/>
|
||||
<img
|
||||
:src="getProviderLogoUrl(group.providerId)"
|
||||
:alt="group.providerName"
|
||||
class="w-4 h-4 rounded shrink-0 dark:invert dark:brightness-90"
|
||||
@error="handleLogoError"
|
||||
>
|
||||
<span class="truncate font-medium text-xs flex-1">{{ group.providerName }}</span>
|
||||
<span class="text-[10px] text-muted-foreground shrink-0">{{ group.models.length }}</span>
|
||||
</div>
|
||||
<!-- 模型列表 -->
|
||||
<div
|
||||
v-if="expandedProvider === group.providerId"
|
||||
class="bg-muted/30"
|
||||
>
|
||||
<div
|
||||
v-for="model in group.models"
|
||||
:key="model.modelId"
|
||||
class="flex flex-col gap-0.5 pl-7 pr-2.5 py-1.5 cursor-pointer text-xs border-t"
|
||||
:class="selectedModel?.modelId === model.modelId && selectedModel?.providerId === model.providerId
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'"
|
||||
@click="selectModel(model)"
|
||||
>
|
||||
<span class="truncate font-medium">{{ model.modelName }}</span>
|
||||
<span
|
||||
class="truncate text-[10px]"
|
||||
:class="selectedModel?.modelId === model.modelId && selectedModel?.providerId === model.providerId
|
||||
? 'text-primary-foreground/70'
|
||||
: 'text-muted-foreground'"
|
||||
>{{ model.modelId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="groupedModels.length === 0"
|
||||
class="text-center py-8 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ searchQuery ? '未找到模型' : '加载中...' }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:表单 -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto h-full scrollbar-thin"
|
||||
:class="isEditMode ? 'max-h-[70vh]' : ''"
|
||||
>
|
||||
<form
|
||||
class="space-y-5"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<section class="space-y-3">
|
||||
<h4 class="font-medium text-sm">
|
||||
基本信息
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="model-name"
|
||||
class="text-xs"
|
||||
>模型名称 *</Label>
|
||||
<Input
|
||||
id="model-name"
|
||||
v-model="form.name"
|
||||
placeholder="claude-3-5-sonnet-20241022"
|
||||
:disabled="isEditMode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="model-display-name"
|
||||
class="text-xs"
|
||||
>显示名称 *</Label>
|
||||
<Input
|
||||
id="model-display-name"
|
||||
v-model="form.display_name"
|
||||
placeholder="Claude 3.5 Sonnet"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="model-description"
|
||||
class="text-xs"
|
||||
>描述</Label>
|
||||
<Input
|
||||
id="model-description"
|
||||
:model-value="form.config?.description || ''"
|
||||
placeholder="简短描述此模型的特点"
|
||||
@update:model-value="(v) => setConfigField('description', v || undefined)"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="model-family"
|
||||
class="text-xs"
|
||||
>模型系列</Label>
|
||||
<Input
|
||||
id="model-family"
|
||||
:model-value="form.config?.family || ''"
|
||||
placeholder="如 GPT-4、Claude 3"
|
||||
@update:model-value="(v) => setConfigField('family', v || undefined)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="model-context-limit"
|
||||
class="text-xs"
|
||||
>上下文限制</Label>
|
||||
<Input
|
||||
id="model-context-limit"
|
||||
type="number"
|
||||
:model-value="form.config?.context_limit ?? ''"
|
||||
placeholder="如 128000"
|
||||
@update:model-value="(v) => setConfigField('context_limit', v ? Number(v) : undefined)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="model-output-limit"
|
||||
class="text-xs"
|
||||
>输出限制</Label>
|
||||
<Input
|
||||
id="model-output-limit"
|
||||
type="number"
|
||||
:model-value="form.config?.output_limit ?? ''"
|
||||
placeholder="如 8192"
|
||||
@update:model-value="(v) => setConfigField('output_limit', v ? Number(v) : undefined)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 能力配置 -->
|
||||
<section class="space-y-2">
|
||||
<h4 class="font-medium text-sm">
|
||||
默认能力
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.config?.streaming !== false"
|
||||
class="rounded"
|
||||
@change="setConfigField('streaming', ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>流式</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.config?.vision === true"
|
||||
class="rounded"
|
||||
@change="setConfigField('vision', ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>视觉</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.config?.function_calling === true"
|
||||
class="rounded"
|
||||
@change="setConfigField('function_calling', ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>工具</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.config?.extended_thinking === true"
|
||||
class="rounded"
|
||||
@change="setConfigField('extended_thinking', ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>思考</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.config?.image_generation === true"
|
||||
class="rounded"
|
||||
@change="setConfigField('image_generation', ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<Image class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>生图</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Key 能力配置 -->
|
||||
<section
|
||||
v-if="availableCapabilities.length > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<h4 class="font-medium text-sm">
|
||||
Key 能力支持
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="cap in availableCapabilities"
|
||||
:key="cap.name"
|
||||
class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.supported_capabilities?.includes(cap.name)"
|
||||
class="rounded"
|
||||
@change="toggleCapability(cap.name)"
|
||||
>
|
||||
<span>{{ cap.display_name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<section class="space-y-3">
|
||||
<h4 class="font-medium text-sm">
|
||||
价格配置
|
||||
</h4>
|
||||
<TieredPricingEditor
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
:show-cache1h="form.supported_capabilities?.includes('cache_1h')"
|
||||
/>
|
||||
<div class="flex items-center gap-3 pt-2 border-t">
|
||||
<Label class="text-xs whitespace-nowrap">按次计费</Label>
|
||||
<Input
|
||||
:model-value="form.default_price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="w-24"
|
||||
placeholder="$/次"
|
||||
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">可与 Token 计费叠加</span>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
@@ -180,7 +310,7 @@
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || !form.name || !form.display_name"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Loader2
|
||||
@@ -189,19 +319,35 @@
|
||||
/>
|
||||
{{ isEditMode ? '保存' : '创建' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedModel && !isEditMode"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="clearSelection"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen } from 'lucide-vue-next'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen,
|
||||
Search, ChevronRight
|
||||
} from 'lucide-vue-next'
|
||||
import { Dialog, Button, Input, Label } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { log } from '@/utils/logger'
|
||||
import TieredPricingEditor from './TieredPricingEditor.vue'
|
||||
import {
|
||||
getModelsDevList,
|
||||
getProviderLogoUrl,
|
||||
type ModelsDevModelItem,
|
||||
} from '@/api/models-dev'
|
||||
import {
|
||||
createGlobalModel,
|
||||
updateGlobalModel,
|
||||
@@ -226,42 +372,147 @@ const { success, error: showError } = useToast()
|
||||
const submitting = ref(false)
|
||||
const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | null>(null)
|
||||
|
||||
// 阶梯计费配置(统一使用,固定价格就是单阶梯)
|
||||
// 模型列表相关
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const allModelsCache = ref<ModelsDevModelItem[]>([]) // 全部模型(缓存)
|
||||
const selectedModel = ref<ModelsDevModelItem | null>(null)
|
||||
const expandedProvider = ref<string | null>(null)
|
||||
|
||||
// 当前显示的模型列表:有搜索词时用全部,否则只用官方
|
||||
const allModels = computed(() => {
|
||||
if (searchQuery.value) {
|
||||
return allModelsCache.value
|
||||
}
|
||||
return allModelsCache.value.filter(m => m.official)
|
||||
})
|
||||
|
||||
// 按提供商分组的模型
|
||||
interface ProviderGroup {
|
||||
providerId: string
|
||||
providerName: string
|
||||
models: ModelsDevModelItem[]
|
||||
}
|
||||
|
||||
const groupedModels = computed(() => {
|
||||
let models = allModels.value.filter(m => !m.deprecated)
|
||||
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchQuery.value) {
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
models = models.filter(model => {
|
||||
const searchableText = `${model.providerId} ${model.providerName} ${model.modelId} ${model.modelName} ${model.family || ''}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
// 按提供商分组
|
||||
const groups = new Map<string, ProviderGroup>()
|
||||
for (const model of models) {
|
||||
if (!groups.has(model.providerId)) {
|
||||
groups.set(model.providerId, {
|
||||
providerId: model.providerId,
|
||||
providerName: model.providerName,
|
||||
models: []
|
||||
})
|
||||
}
|
||||
groups.get(model.providerId)!.models.push(model)
|
||||
}
|
||||
|
||||
// 转换为数组并排序
|
||||
const result = Array.from(groups.values())
|
||||
|
||||
// 如果有搜索词,把提供商名称/ID匹配的排在前面
|
||||
if (searchQuery.value) {
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
result.sort((a, b) => {
|
||||
const aText = `${a.providerId} ${a.providerName}`.toLowerCase()
|
||||
const bText = `${b.providerId} ${b.providerName}`.toLowerCase()
|
||||
const aProviderMatch = keywords.some(k => aText.includes(k))
|
||||
const bProviderMatch = keywords.some(k => bText.includes(k))
|
||||
if (aProviderMatch && !bProviderMatch) return -1
|
||||
if (!aProviderMatch && bProviderMatch) return 1
|
||||
return a.providerName.localeCompare(b.providerName)
|
||||
})
|
||||
} else {
|
||||
result.sort((a, b) => a.providerName.localeCompare(b.providerName))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 搜索时如果只有一个提供商,自动展开
|
||||
watch(groupedModels, (groups) => {
|
||||
if (searchQuery.value && groups.length === 1) {
|
||||
expandedProvider.value = groups[0].providerId
|
||||
}
|
||||
})
|
||||
|
||||
// 切换提供商展开状态
|
||||
function toggleProvider(providerId: string) {
|
||||
expandedProvider.value = expandedProvider.value === providerId ? null : providerId
|
||||
}
|
||||
|
||||
// 阶梯计费配置
|
||||
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
|
||||
interface FormData {
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string
|
||||
default_price_per_request?: number
|
||||
default_supports_streaming?: boolean
|
||||
default_supports_image_generation?: boolean
|
||||
default_supports_vision?: boolean
|
||||
default_supports_function_calling?: boolean
|
||||
default_supports_extended_thinking?: boolean
|
||||
supported_capabilities?: string[]
|
||||
config?: Record<string, any>
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
const defaultForm = (): FormData => ({
|
||||
name: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
default_price_per_request: undefined,
|
||||
default_supports_streaming: true,
|
||||
default_supports_image_generation: false,
|
||||
default_supports_vision: false,
|
||||
default_supports_function_calling: false,
|
||||
default_supports_extended_thinking: false,
|
||||
supported_capabilities: [],
|
||||
config: { streaming: true },
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const form = ref<FormData>(defaultForm())
|
||||
|
||||
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
|
||||
|
||||
// 设置 config 字段
|
||||
function setConfigField(key: string, value: any) {
|
||||
if (!form.value.config) {
|
||||
form.value.config = {}
|
||||
}
|
||||
if (value === undefined || value === '' || (value === false && !KEEP_FALSE_CONFIG_KEYS.has(key))) {
|
||||
delete form.value.config[key]
|
||||
} else {
|
||||
form.value.config[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Key 能力选项
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
|
||||
// 加载模型列表
|
||||
async function loadModels() {
|
||||
if (allModelsCache.value.length > 0) return
|
||||
loading.value = true
|
||||
try {
|
||||
// 只加载一次全部模型,过滤在 computed 中完成
|
||||
allModelsCache.value = await getModelsDevList(false)
|
||||
} catch (err) {
|
||||
log.error('Failed to load models:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开对话框时加载数据
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen && !props.model) {
|
||||
loadModels()
|
||||
}
|
||||
})
|
||||
|
||||
// 加载可用能力列表
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
@@ -284,38 +535,92 @@ function toggleCapability(capName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载能力列表
|
||||
onMounted(() => {
|
||||
loadCapabilities()
|
||||
})
|
||||
|
||||
// 选择模型并填充表单
|
||||
function selectModel(model: ModelsDevModelItem) {
|
||||
selectedModel.value = model
|
||||
expandedProvider.value = model.providerId
|
||||
form.value.name = model.modelId
|
||||
form.value.display_name = model.modelName
|
||||
|
||||
// 构建 config
|
||||
const config: Record<string, any> = {
|
||||
streaming: true,
|
||||
}
|
||||
if (model.supportsVision) config.vision = true
|
||||
if (model.supportsToolCall) config.function_calling = true
|
||||
if (model.supportsReasoning) config.extended_thinking = true
|
||||
if (model.supportsStructuredOutput) config.structured_output = true
|
||||
if (model.supportsTemperature !== false) config.temperature = model.supportsTemperature
|
||||
if (model.supportsAttachment) config.attachment = true
|
||||
if (model.openWeights) config.open_weights = true
|
||||
if (model.contextLimit) config.context_limit = model.contextLimit
|
||||
if (model.outputLimit) config.output_limit = model.outputLimit
|
||||
if (model.knowledgeCutoff) config.knowledge_cutoff = model.knowledgeCutoff
|
||||
if (model.family) config.family = model.family
|
||||
if (model.releaseDate) config.release_date = model.releaseDate
|
||||
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
|
||||
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
|
||||
form.value.config = config
|
||||
|
||||
if (model.inputPrice !== undefined || model.outputPrice !== undefined) {
|
||||
tieredPricing.value = {
|
||||
tiers: [{
|
||||
up_to: null,
|
||||
input_price_per_1m: model.inputPrice || 0,
|
||||
output_price_per_1m: model.outputPrice || 0,
|
||||
}]
|
||||
}
|
||||
} else {
|
||||
tieredPricing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 清除选择(手动填写)
|
||||
function clearSelection() {
|
||||
selectedModel.value = null
|
||||
form.value = defaultForm()
|
||||
tieredPricing.value = null
|
||||
}
|
||||
|
||||
// Logo 加载失败处理
|
||||
function handleLogoError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = defaultForm()
|
||||
tieredPricing.value = null
|
||||
searchQuery.value = ''
|
||||
selectedModel.value = null
|
||||
expandedProvider.value = null
|
||||
}
|
||||
|
||||
// 加载模型数据(编辑模式)
|
||||
function loadModelData() {
|
||||
if (!props.model) return
|
||||
// 先重置创建模式的残留状态
|
||||
selectedModel.value = null
|
||||
searchQuery.value = ''
|
||||
expandedProvider.value = null
|
||||
|
||||
form.value = {
|
||||
name: props.model.name,
|
||||
display_name: props.model.display_name,
|
||||
description: props.model.description,
|
||||
default_price_per_request: props.model.default_price_per_request,
|
||||
default_supports_streaming: props.model.default_supports_streaming,
|
||||
default_supports_image_generation: props.model.default_supports_image_generation,
|
||||
default_supports_vision: props.model.default_supports_vision,
|
||||
default_supports_function_calling: props.model.default_supports_function_calling,
|
||||
default_supports_extended_thinking: props.model.default_supports_extended_thinking,
|
||||
supported_capabilities: [...(props.model.supported_capabilities || [])],
|
||||
config: props.model.config ? { ...props.model.config } : { streaming: true },
|
||||
is_active: props.model.is_active,
|
||||
}
|
||||
|
||||
// 加载阶梯计费配置(深拷贝)
|
||||
if (props.model.default_tiered_pricing) {
|
||||
tieredPricing.value = JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
|
||||
}
|
||||
// 确保 tieredPricing 也被正确设置或重置
|
||||
tieredPricing.value = props.model.default_tiered_pricing
|
||||
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
|
||||
: null
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
@@ -339,24 +644,22 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取包含自动计算缓存价格的最终数据
|
||||
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
|
||||
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
|
||||
|
||||
// 清理空的 config
|
||||
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
|
||||
? form.value.config
|
||||
: undefined
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEditMode.value && props.model) {
|
||||
const updateData: GlobalModelUpdate = {
|
||||
display_name: form.value.display_name,
|
||||
description: form.value.description,
|
||||
// 使用 null 而不是 undefined 来显式清空字段
|
||||
config: cleanConfig || null,
|
||||
default_price_per_request: form.value.default_price_per_request ?? null,
|
||||
default_tiered_pricing: finalTieredPricing,
|
||||
default_supports_streaming: form.value.default_supports_streaming,
|
||||
default_supports_image_generation: form.value.default_supports_image_generation,
|
||||
default_supports_vision: form.value.default_supports_vision,
|
||||
default_supports_function_calling: form.value.default_supports_function_calling,
|
||||
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
|
||||
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : null,
|
||||
is_active: form.value.is_active,
|
||||
}
|
||||
@@ -366,14 +669,9 @@ async function handleSubmit() {
|
||||
const createData: GlobalModelCreate = {
|
||||
name: form.value.name!,
|
||||
display_name: form.value.display_name!,
|
||||
description: form.value.description,
|
||||
default_price_per_request: form.value.default_price_per_request || undefined,
|
||||
config: cleanConfig,
|
||||
default_price_per_request: form.value.default_price_per_request ?? undefined,
|
||||
default_tiered_pricing: finalTieredPricing,
|
||||
default_supports_streaming: form.value.default_supports_streaming,
|
||||
default_supports_image_generation: form.value.default_supports_image_generation,
|
||||
default_supports_vision: form.value.default_supports_vision,
|
||||
default_supports_function_calling: form.value.default_supports_function_calling,
|
||||
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
|
||||
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : undefined,
|
||||
is_active: form.value.is_active,
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
<template v-if="model.description">
|
||||
<template v-if="model.config?.description">
|
||||
<span class="shrink-0">·</span>
|
||||
<span
|
||||
class="text-xs truncate"
|
||||
:title="model.description"
|
||||
>{{ model.description }}</span>
|
||||
:title="model.config?.description"
|
||||
>{{ model.config?.description }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,19 +104,6 @@
|
||||
<span class="hidden sm:inline">关联提供商</span>
|
||||
<span class="sm:hidden">提供商</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md transition-all duration-200"
|
||||
:class="[
|
||||
detailTab === 'aliases'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="detailTab = 'aliases'"
|
||||
>
|
||||
<span class="hidden sm:inline">别名/映射</span>
|
||||
<span class="sm:hidden">别名</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 内容 -->
|
||||
@@ -156,10 +143,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.streaming !== false ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.streaming !== false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
@@ -173,10 +160,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.image_generation === true ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.image_generation === true ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
@@ -190,10 +177,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_vision ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.vision === true ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.vision === true ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
@@ -207,10 +194,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.function_calling === true ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.function_calling === true ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
@@ -224,10 +211,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.extended_thinking === true ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.extended_thinking === true ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -409,11 +396,11 @@
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border bg-muted/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-xs text-muted-foreground">别名数量</Label>
|
||||
<Tag class="w-4 h-4 text-muted-foreground" />
|
||||
<Label class="text-xs text-muted-foreground">调用次数</Label>
|
||||
<BarChart3 class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-1">
|
||||
{{ model.alias_count || 0 }}
|
||||
{{ model.usage_count || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,105 +455,153 @@
|
||||
<template v-else-if="providers.length > 0">
|
||||
<!-- 桌面端表格 -->
|
||||
<Table class="hidden sm:table">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-10 font-semibold">
|
||||
Provider
|
||||
</TableHead>
|
||||
<TableHead class="w-[120px] h-10 font-semibold">
|
||||
能力
|
||||
</TableHead>
|
||||
<TableHead class="w-[180px] h-10 font-semibold">
|
||||
价格 ($/M)
|
||||
</TableHead>
|
||||
<TableHead class="w-[80px] h-10 font-semibold text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-10 font-semibold">
|
||||
Provider
|
||||
</TableHead>
|
||||
<TableHead class="w-[120px] h-10 font-semibold">
|
||||
能力
|
||||
</TableHead>
|
||||
<TableHead class="w-[180px] h-10 font-semibold">
|
||||
价格 ($/M)
|
||||
</TableHead>
|
||||
<TableHead class="w-[80px] h-10 font-semibold text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell class="py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||
:title="provider.is_active ? '活跃' : '停用'"
|
||||
/>
|
||||
<span class="font-medium truncate">{{ provider.display_name }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<div class="flex gap-0.5">
|
||||
<Zap
|
||||
v-if="provider.supports_streaming"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
title="流式输出"
|
||||
/>
|
||||
<Eye
|
||||
v-if="provider.supports_vision"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
title="视觉理解"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="provider.supports_function_calling"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
title="工具调用"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<div class="text-xs font-mono space-y-0.5">
|
||||
<!-- Token 计费:输入/输出 -->
|
||||
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
|
||||
<span class="text-muted-foreground">输入/输出:</span>
|
||||
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
|
||||
<!-- 阶梯标记 -->
|
||||
<span
|
||||
v-if="(provider.tier_count || 1) > 1"
|
||||
class="ml-1 text-muted-foreground"
|
||||
title="阶梯计费"
|
||||
>[阶梯]</span>
|
||||
</div>
|
||||
<!-- 缓存价格 -->
|
||||
<div
|
||||
v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<span>缓存:</span>
|
||||
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<!-- 1h 缓存价格 -->
|
||||
<div
|
||||
v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<span>1h 缓存:</span>
|
||||
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
<div v-if="(provider.price_per_request || 0) > 0">
|
||||
<span class="text-muted-foreground">按次:</span>
|
||||
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/次</span>
|
||||
</div>
|
||||
<!-- 无定价 -->
|
||||
<span
|
||||
v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)"
|
||||
class="text-muted-foreground"
|
||||
>-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑此关联"
|
||||
@click="$emit('editProvider', provider)"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:title="provider.is_active ? '停用此关联' : '启用此关联'"
|
||||
@click="$emit('toggleProviderStatus', provider)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除此关联"
|
||||
@click="$emit('deleteProvider', provider)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="sm:hidden divide-y divide-border/40">
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
class="p-4 space-y-3"
|
||||
>
|
||||
<TableCell class="py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||
:title="provider.is_active ? '活跃' : '停用'"
|
||||
/>
|
||||
<span class="font-medium truncate">{{ provider.display_name }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<div class="flex gap-0.5">
|
||||
<Zap
|
||||
v-if="provider.supports_streaming"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
title="流式输出"
|
||||
/>
|
||||
<Eye
|
||||
v-if="provider.supports_vision"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
title="视觉理解"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="provider.supports_function_calling"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
title="工具调用"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<div class="text-xs font-mono space-y-0.5">
|
||||
<!-- Token 计费:输入/输出 -->
|
||||
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
|
||||
<span class="text-muted-foreground">输入/输出:</span>
|
||||
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
|
||||
<!-- 阶梯标记 -->
|
||||
<span
|
||||
v-if="(provider.tier_count || 1) > 1"
|
||||
class="ml-1 text-muted-foreground"
|
||||
title="阶梯计费"
|
||||
>[阶梯]</span>
|
||||
</div>
|
||||
<!-- 缓存价格 -->
|
||||
<div
|
||||
v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<span>缓存:</span>
|
||||
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<!-- 1h 缓存价格 -->
|
||||
<div
|
||||
v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<span>1h 缓存:</span>
|
||||
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
<div v-if="(provider.price_per_request || 0) > 0">
|
||||
<span class="text-muted-foreground">按次:</span>
|
||||
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/次</span>
|
||||
</div>
|
||||
<!-- 无定价 -->
|
||||
<span
|
||||
v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)"
|
||||
class="text-muted-foreground"
|
||||
>-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑此关联"
|
||||
@click="$emit('editProvider', provider)"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
@@ -575,7 +610,6 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:title="provider.is_active ? '停用此关联' : '启用此关联'"
|
||||
@click="$emit('toggleProviderStatus', provider)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
@@ -584,82 +618,35 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除此关联"
|
||||
@click="$emit('deleteProvider', provider)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="sm:hidden divide-y divide-border/40">
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||
/>
|
||||
<span class="font-medium truncate">{{ provider.display_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('editProvider', provider)"
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<div class="flex gap-1">
|
||||
<Zap
|
||||
v-if="provider.supports_streaming"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
<Eye
|
||||
v-if="provider.supports_vision"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="provider.supports_function_calling"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0"
|
||||
class="text-muted-foreground font-mono"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('toggleProviderStatus', provider)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('deleteProvider', provider)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<div class="flex gap-1">
|
||||
<Zap
|
||||
v-if="provider.supports_streaming"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
<Eye
|
||||
v-if="provider.supports_vision"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="provider.supports_function_calling"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0"
|
||||
class="text-muted-foreground font-mono"
|
||||
>
|
||||
${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -684,236 +671,6 @@
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: 别名 -->
|
||||
<div v-show="detailTab === 'aliases'">
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题栏 -->
|
||||
<div class="px-4 py-3 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold">
|
||||
别名与映射
|
||||
</h4>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="添加别名/映射"
|
||||
@click="$emit('addAlias')"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="刷新"
|
||||
@click="$emit('refreshAliases')"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-3.5 h-3.5"
|
||||
:class="loadingAliases ? 'animate-spin' : ''"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格内容 -->
|
||||
<div
|
||||
v-if="loadingAliases"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="aliases.length > 0">
|
||||
<!-- 桌面端表格 -->
|
||||
<Table class="hidden sm:table">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-10 font-semibold">
|
||||
别名
|
||||
</TableHead>
|
||||
<TableHead class="w-[80px] h-10 font-semibold">
|
||||
类型
|
||||
</TableHead>
|
||||
<TableHead class="w-[100px] h-10 font-semibold">
|
||||
作用域
|
||||
</TableHead>
|
||||
<TableHead class="w-[100px] h-10 font-semibold text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="alias in aliases"
|
||||
:key="alias.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell class="py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="alias.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||
:title="alias.is_active ? '活跃' : '停用'"
|
||||
/>
|
||||
<code class="text-sm font-medium bg-muted px-1.5 py-0.5 rounded">{{ alias.alias }}</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<Badge
|
||||
v-if="alias.provider_id"
|
||||
variant="outline"
|
||||
class="text-xs truncate max-w-[90px]"
|
||||
:title="alias.provider_name || 'Provider'"
|
||||
>
|
||||
{{ alias.provider_name || 'Provider' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="default"
|
||||
class="text-xs"
|
||||
>
|
||||
全局
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑"
|
||||
@click="$emit('editAlias', alias)"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:title="alias.is_active ? '停用' : '启用'"
|
||||
@click="$emit('toggleAliasStatus', alias)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
@click="$emit('deleteAlias', alias)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="sm:hidden divide-y divide-border/40">
|
||||
<div
|
||||
v-for="alias in aliases"
|
||||
:key="alias.id"
|
||||
class="p-4 space-y-2"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="alias.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||
/>
|
||||
<code class="text-sm font-medium bg-muted px-1.5 py-0.5 rounded truncate">{{ alias.alias }}</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('editAlias', alias)"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('toggleAliasStatus', alias)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('deleteAlias', alias)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="alias.provider_id"
|
||||
variant="outline"
|
||||
class="text-xs truncate max-w-[120px]"
|
||||
>
|
||||
{{ alias.provider_name || 'Provider' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="default"
|
||||
class="text-xs"
|
||||
>
|
||||
全局
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="text-center py-12"
|
||||
>
|
||||
<!-- 空状态 -->
|
||||
<Tag class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
暂无别名或映射
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="mt-4"
|
||||
@click="$emit('addAlias')"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
添加别名/映射
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -931,7 +688,6 @@ import {
|
||||
Zap,
|
||||
Image,
|
||||
Building2,
|
||||
Tag,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
@@ -939,9 +695,12 @@ import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Layers
|
||||
Layers,
|
||||
BarChart3
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -955,13 +714,11 @@ import TableCell from '@/components/ui/table-cell.vue'
|
||||
|
||||
// 使用外部类型定义
|
||||
import type { GlobalModelResponse } from '@/api/global-models'
|
||||
import type { ModelAlias } from '@/api/endpoints/aliases'
|
||||
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
|
||||
import type { CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loadingProviders: false,
|
||||
loadingAliases: false,
|
||||
hasBlockingDialogOpen: false,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
@@ -973,21 +730,15 @@ const emit = defineEmits<{
|
||||
'deleteProvider': [provider: any]
|
||||
'toggleProviderStatus': [provider: any]
|
||||
'refreshProviders': []
|
||||
'addAlias': []
|
||||
'editAlias': [alias: ModelAlias]
|
||||
'toggleAliasStatus': [alias: ModelAlias]
|
||||
'deleteAlias': [alias: ModelAlias]
|
||||
'refreshAliases': []
|
||||
}>()
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
interface Props {
|
||||
model: GlobalModelResponse | null
|
||||
open: boolean
|
||||
providers: any[]
|
||||
aliases: ModelAlias[]
|
||||
loadingProviders?: boolean
|
||||
loadingAliases?: boolean
|
||||
hasBlockingDialogOpen?: boolean
|
||||
capabilities?: CapabilityDefinition[]
|
||||
}
|
||||
@@ -1014,16 +765,6 @@ function handleClose() {
|
||||
}
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
@@ -1085,6 +826,16 @@ watch(() => props.open, (newOpen) => {
|
||||
detailTab.value = 'basic'
|
||||
}
|
||||
})
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.open) {
|
||||
handleClose()
|
||||
}
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { default as GlobalModelFormDialog } from './GlobalModelFormDialog.vue'
|
||||
export { default as AliasDialog } from './AliasDialog.vue'
|
||||
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
|
||||
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'
|
||||
|
||||
@@ -31,29 +31,46 @@
|
||||
|
||||
<!-- 左右对比布局 -->
|
||||
<div class="flex gap-2 items-stretch">
|
||||
<!-- 左侧:可添加的模型 -->
|
||||
<!-- 左侧:可添加的模型(分组折叠) -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">
|
||||
可添加
|
||||
</p>
|
||||
<Button
|
||||
v-if="availableModels.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleSelectAllLeft"
|
||||
>
|
||||
{{ isAllLeftSelected ? '取消全选' : '全选' }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium shrink-0">
|
||||
可添加
|
||||
</p>
|
||||
<div class="flex-1 relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索模型..."
|
||||
class="pl-7 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
<button
|
||||
v-if="upstreamModelsLoaded"
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="刷新上游模型"
|
||||
:disabled="fetchingUpstreamModels"
|
||||
@click="fetchUpstreamModels(true)"
|
||||
>
|
||||
{{ availableModels.length }} 个
|
||||
</Badge>
|
||||
<RefreshCw
|
||||
class="w-3.5 h-3.5"
|
||||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="从提供商获取模型"
|
||||
@click="fetchUpstreamModels"
|
||||
>
|
||||
<Zap class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div
|
||||
@@ -63,7 +80,7 @@
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
</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"
|
||||
>
|
||||
<Layers class="w-10 h-10 mb-2 opacity-30" />
|
||||
@@ -73,37 +90,142 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="p-2 space-y-1"
|
||||
class="p-2 space-y-2"
|
||||
>
|
||||
<!-- 全局模型折叠组 -->
|
||||
<div
|
||||
v-for="model in availableModels"
|
||||
:key="model.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors"
|
||||
:class="selectedLeftIds.includes(model.id)
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'hover:bg-muted/50 cursor-pointer'"
|
||||
@click="toggleLeftSelection(model.id)"
|
||||
v-if="availableGlobalModels.length > 0 || !upstreamModelsLoaded"
|
||||
class="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedLeftIds.includes(model.id)"
|
||||
@update:checked="toggleLeftSelection(model.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">
|
||||
{{ model.display_name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
<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>
|
||||
<Badge
|
||||
:variant="model.is_active ? 'outline' : 'secondary'"
|
||||
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
|
||||
class="text-xs shrink-0"
|
||||
<div
|
||||
v-show="!collapsedGroups.has('global')"
|
||||
class="p-2 space-y-1 border-t"
|
||||
>
|
||||
{{ model.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
<div
|
||||
v-if="availableGlobalModels.length === 0"
|
||||
class="py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
所有全局模型均已关联
|
||||
</div>
|
||||
<div
|
||||
v-for="model in availableGlobalModels"
|
||||
v-else
|
||||
:key="model.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||
:class="selectedGlobalModelIds.includes(model.id)
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'hover:bg-muted/50'"
|
||||
@click="toggleGlobalModelSelection(model.id)"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedGlobalModelIds.includes(model.id)"
|
||||
@update:checked="toggleGlobalModelSelection(model.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">
|
||||
{{ model.display_name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.is_active ? 'outline' : 'secondary'"
|
||||
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ model.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</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>
|
||||
@@ -115,8 +237,8 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-9 h-8"
|
||||
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'border-primary' : ''"
|
||||
:disabled="selectedLeftIds.length === 0 || submittingAdd"
|
||||
:class="totalSelectedCount > 0 && !submittingAdd ? 'border-primary' : ''"
|
||||
:disabled="totalSelectedCount === 0 || submittingAdd"
|
||||
title="添加选中"
|
||||
@click="batchAddSelected"
|
||||
>
|
||||
@@ -127,7 +249,7 @@
|
||||
<ChevronRight
|
||||
v-else
|
||||
class="w-6 h-6 stroke-[3]"
|
||||
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'text-primary' : ''"
|
||||
:class="totalSelectedCount > 0 && !submittingAdd ? 'text-primary' : ''"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -154,26 +276,18 @@
|
||||
<!-- 右侧:已添加的模型 -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">
|
||||
已添加
|
||||
</p>
|
||||
<Button
|
||||
v-if="existingModels.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleSelectAllRight"
|
||||
>
|
||||
{{ isAllRightSelected ? '取消全选' : '全选' }}
|
||||
</Button>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
<p class="text-sm font-medium">
|
||||
已添加
|
||||
</p>
|
||||
<Button
|
||||
v-if="existingModels.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleSelectAllRight"
|
||||
>
|
||||
{{ existingModels.length }} 个
|
||||
</Badge>
|
||||
{{ isAllRightSelected ? '取消' : '全选' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div
|
||||
@@ -238,11 +352,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
@@ -253,8 +368,11 @@ import {
|
||||
getProviderModels,
|
||||
batchAssignModelsToProvider,
|
||||
deleteModel,
|
||||
importModelsFromUpstream,
|
||||
API_FORMAT_LABELS,
|
||||
type Model
|
||||
} from '@/api/endpoints'
|
||||
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -268,23 +386,35 @@ const emit = defineEmits<{
|
||||
'changed': []
|
||||
}>()
|
||||
|
||||
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const { error: showError, success } = useToast()
|
||||
|
||||
// 状态
|
||||
const loadingGlobalModels = ref(false)
|
||||
const submittingAdd = ref(false)
|
||||
const submittingRemove = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
|
||||
// 数据
|
||||
const allGlobalModels = ref<GlobalModelResponse[]>([])
|
||||
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 availableModels = computed(() => {
|
||||
// 折叠状态
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 搜索状态
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 计算可添加的全局模型(排除已关联的)
|
||||
const availableGlobalModelsBase = computed(() => {
|
||||
const existingGlobalModelIds = new Set(
|
||||
existingModels.value
|
||||
.filter(m => m.global_model_id)
|
||||
@@ -293,31 +423,129 @@ const availableModels = computed(() => {
|
||||
return allGlobalModels.value.filter(m => !existingGlobalModelIds.has(m.id))
|
||||
})
|
||||
|
||||
// 全选状态
|
||||
const isAllLeftSelected = computed(() =>
|
||||
availableModels.value.length > 0 &&
|
||||
selectedLeftIds.value.length === availableModels.value.length
|
||||
)
|
||||
// 搜索过滤后的全局模型
|
||||
const availableGlobalModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return availableGlobalModelsBase.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return availableGlobalModelsBase.value.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.display_name.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// 计算可添加的上游模型(排除已关联的,包括主模型名和映射名称)
|
||||
const availableUpstreamModelsBase = computed(() => {
|
||||
const 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(() =>
|
||||
existingModels.value.length > 0 &&
|
||||
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) => {
|
||||
if (isOpen && props.providerId) {
|
||||
await loadData()
|
||||
} else {
|
||||
// 重置状态
|
||||
selectedLeftIds.value = []
|
||||
selectedGlobalModelIds.value = []
|
||||
selectedUpstreamModelIds.value = []
|
||||
selectedRightIds.value = []
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
collapsedGroups.value = new Set()
|
||||
searchQuery.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
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) {
|
||||
const index = selectedLeftIds.value.indexOf(id)
|
||||
if (index === -1) {
|
||||
selectedLeftIds.value.push(id)
|
||||
// 从提供商获取模型
|
||||
async function fetchUpstreamModels(forceRefresh = false) {
|
||||
if (forceRefresh) {
|
||||
clearCache(props.providerId)
|
||||
}
|
||||
|
||||
try {
|
||||
fetchingUpstreamModels.value = true
|
||||
const result = await fetchCachedModels(props.providerId, forceRefresh)
|
||||
if (result) {
|
||||
if (result.error) {
|
||||
showError(result.error, '错误')
|
||||
} else {
|
||||
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 {
|
||||
selectedLeftIds.value.splice(index, 1)
|
||||
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() {
|
||||
if (isAllRightSelected.value) {
|
||||
@@ -382,22 +679,41 @@ function toggleSelectAllRight() {
|
||||
|
||||
// 批量添加选中的模型
|
||||
async function batchAddSelected() {
|
||||
if (selectedLeftIds.value.length === 0) return
|
||||
if (totalSelectedCount.value === 0) return
|
||||
|
||||
try {
|
||||
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) {
|
||||
allErrors.push(...result.errors.map(e => e.error))
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
const errorMessages = result.errors.map(e => e.error).join(', ')
|
||||
showError(`部分模型添加失败: ${errorMessages}`, '警告')
|
||||
// 处理上游模型(调用 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))
|
||||
}
|
||||
}
|
||||
|
||||
selectedLeftIds.value = []
|
||||
if (totalSuccess > 0) {
|
||||
success(`成功添加 ${totalSuccess} 个模型`)
|
||||
}
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
showError(`部分模型添加失败: ${allErrors.slice(0, 3).join(', ')}${allErrors.length > 3 ? '...' : ''}`, '警告')
|
||||
}
|
||||
|
||||
selectedGlobalModelIds.value = []
|
||||
selectedUpstreamModelIds.value = []
|
||||
await loadExistingModels()
|
||||
emit('changed')
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
>
|
||||
<form
|
||||
class="space-y-6"
|
||||
@submit.prevent="handleSubmit"
|
||||
@submit.prevent="handleSubmit()"
|
||||
>
|
||||
<!-- API 配置 -->
|
||||
<div class="space-y-4">
|
||||
@@ -132,6 +132,79 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理配置 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">
|
||||
代理配置
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch v-model="proxyEnabled" />
|
||||
<span class="text-sm text-muted-foreground">启用代理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="proxyEnabled"
|
||||
class="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="proxy_url">代理 URL *</Label>
|
||||
<Input
|
||||
id="proxy_url"
|
||||
v-model="form.proxy_url"
|
||||
placeholder="http://host:port 或 socks5://host:port"
|
||||
required
|
||||
:class="proxyUrlError ? 'border-red-500' : ''"
|
||||
/>
|
||||
<p
|
||||
v-if="proxyUrlError"
|
||||
class="text-xs text-red-500"
|
||||
>
|
||||
{{ proxyUrlError }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
支持 HTTP、HTTPS、SOCKS5 代理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="proxy_user">用户名(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_user_${formId}`"
|
||||
v-model="form.proxy_username"
|
||||
:name="`proxy_user_${formId}`"
|
||||
placeholder="代理认证用户名"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label :for="`proxy_pass_${formId}`">密码(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_pass_${formId}`"
|
||||
v-model="form.proxy_password"
|
||||
:name="`proxy_pass_${formId}`"
|
||||
type="text"
|
||||
:placeholder="passwordPlaceholder"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
:style="{ '-webkit-text-security': 'disc', 'text-security': 'disc' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -145,12 +218,24 @@
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="loading || !form.base_url || (!isEditMode && !form.api_format)"
|
||||
@click="handleSubmit"
|
||||
@click="handleSubmit()"
|
||||
>
|
||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : '创建') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 确认清空凭据对话框 -->
|
||||
<AlertDialog
|
||||
v-model="showClearCredentialsDialog"
|
||||
title="清空代理凭据"
|
||||
description="代理 URL 为空,但用户名和密码仍有值。是否清空这些凭据并继续保存?"
|
||||
type="warning"
|
||||
confirm-text="清空并保存"
|
||||
cancel-text="返回编辑"
|
||||
@confirm="confirmClearCredentials"
|
||||
@cancel="showClearCredentialsDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -165,7 +250,9 @@ import {
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Switch,
|
||||
} from '@/components/ui'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import { Link, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
@@ -194,6 +281,11 @@ const emit = defineEmits<{
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const selectOpen = ref(false)
|
||||
const proxyEnabled = ref(false)
|
||||
const showClearCredentialsDialog = ref(false) // 确认清空凭据对话框
|
||||
|
||||
// 生成随机 ID 防止浏览器自动填充
|
||||
const formId = Math.random().toString(36).substring(2, 10)
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
@@ -207,7 +299,11 @@ const form = ref({
|
||||
max_retries: 3,
|
||||
max_concurrent: undefined as number | undefined,
|
||||
rate_limit: undefined as number | undefined,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
// 代理配置
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
})
|
||||
|
||||
// API 格式列表
|
||||
@@ -237,6 +333,53 @@ const defaultPathPlaceholder = computed(() => {
|
||||
return `留空使用默认路径:${defaultPath.value}`
|
||||
})
|
||||
|
||||
// 检查是否有已保存的密码(后端返回 *** 表示有密码)
|
||||
const hasExistingPassword = computed(() => {
|
||||
if (!props.endpoint?.proxy) return false
|
||||
const proxy = props.endpoint.proxy as { password?: string }
|
||||
return proxy?.password === MASKED_PASSWORD
|
||||
})
|
||||
|
||||
// 密码输入框的 placeholder
|
||||
const passwordPlaceholder = computed(() => {
|
||||
if (hasExistingPassword.value) {
|
||||
return '已保存密码,留空保持不变'
|
||||
}
|
||||
return '代理认证密码'
|
||||
})
|
||||
|
||||
// 代理 URL 验证
|
||||
const proxyUrlError = computed(() => {
|
||||
// 只有启用代理且填写了 URL 时才验证
|
||||
if (!proxyEnabled.value || !form.value.proxy_url) {
|
||||
return ''
|
||||
}
|
||||
const url = form.value.proxy_url.trim()
|
||||
|
||||
// 检查禁止的特殊字符
|
||||
if (/[\n\r]/.test(url)) {
|
||||
return '代理 URL 包含非法字符'
|
||||
}
|
||||
|
||||
// 验证协议(不支持 SOCKS4)
|
||||
if (!/^(http|https|socks5):\/\//i.test(url)) {
|
||||
return '代理 URL 必须以 http://, https:// 或 socks5:// 开头'
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (!parsed.host) {
|
||||
return '代理 URL 必须包含有效的 host'
|
||||
}
|
||||
// 禁止 URL 中内嵌认证信息
|
||||
if (parsed.username || parsed.password) {
|
||||
return '请勿在 URL 中包含用户名和密码,请使用独立的认证字段'
|
||||
}
|
||||
} catch {
|
||||
return '代理 URL 格式无效'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 组件挂载时加载API格式
|
||||
onMounted(() => {
|
||||
loadApiFormats()
|
||||
@@ -252,14 +395,23 @@ function resetForm() {
|
||||
max_retries: 3,
|
||||
max_concurrent: undefined,
|
||||
rate_limit: undefined,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
}
|
||||
proxyEnabled.value = false
|
||||
}
|
||||
|
||||
// 原始密码占位符(后端返回的脱敏标记)
|
||||
const MASKED_PASSWORD = '***'
|
||||
|
||||
// 加载端点数据(编辑模式)
|
||||
function loadEndpointData() {
|
||||
if (!props.endpoint) return
|
||||
|
||||
const proxy = props.endpoint.proxy as { url?: string; username?: string; password?: string; enabled?: boolean } | null
|
||||
|
||||
form.value = {
|
||||
api_format: props.endpoint.api_format,
|
||||
base_url: props.endpoint.base_url,
|
||||
@@ -268,8 +420,15 @@ function loadEndpointData() {
|
||||
max_retries: props.endpoint.max_retries,
|
||||
max_concurrent: props.endpoint.max_concurrent || undefined,
|
||||
rate_limit: props.endpoint.rate_limit || undefined,
|
||||
is_active: props.endpoint.is_active
|
||||
is_active: props.endpoint.is_active,
|
||||
proxy_url: proxy?.url || '',
|
||||
proxy_username: proxy?.username || '',
|
||||
// 如果密码是脱敏标记,显示为空(让用户知道有密码但看不到)
|
||||
proxy_password: proxy?.password === MASKED_PASSWORD ? '' : (proxy?.password || ''),
|
||||
}
|
||||
|
||||
// 根据 enabled 字段或 url 存在判断是否启用代理
|
||||
proxyEnabled.value = proxy?.enabled ?? !!proxy?.url
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
@@ -282,12 +441,47 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 构建代理配置
|
||||
// - 有 URL 时始终保存配置,通过 enabled 字段控制是否启用
|
||||
// - 无 URL 时返回 null
|
||||
function buildProxyConfig(): { url: string; username?: string; password?: string; enabled: boolean } | null {
|
||||
if (!form.value.proxy_url) {
|
||||
// 没填 URL,无代理配置
|
||||
return null
|
||||
}
|
||||
return {
|
||||
url: form.value.proxy_url,
|
||||
username: form.value.proxy_username || undefined,
|
||||
password: form.value.proxy_password || undefined,
|
||||
enabled: proxyEnabled.value, // 开关状态决定是否启用
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (skipCredentialCheck = false) => {
|
||||
if (!props.provider && !props.endpoint) return
|
||||
|
||||
// 只在开关开启且填写了 URL 时验证
|
||||
if (proxyEnabled.value && form.value.proxy_url && proxyUrlError.value) {
|
||||
showError(proxyUrlError.value, '代理配置错误')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查:开关开启但没有 URL,却有用户名或密码
|
||||
const hasOrphanedCredentials = proxyEnabled.value
|
||||
&& !form.value.proxy_url
|
||||
&& (form.value.proxy_username || form.value.proxy_password)
|
||||
|
||||
if (hasOrphanedCredentials && !skipCredentialCheck) {
|
||||
// 弹出确认对话框
|
||||
showClearCredentialsDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const proxyConfig = buildProxyConfig()
|
||||
|
||||
if (isEditMode.value && props.endpoint) {
|
||||
// 更新端点
|
||||
await updateEndpoint(props.endpoint.id, {
|
||||
@@ -297,7 +491,8 @@ const handleSubmit = async () => {
|
||||
max_retries: form.value.max_retries,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
is_active: form.value.is_active
|
||||
is_active: form.value.is_active,
|
||||
proxy: proxyConfig,
|
||||
})
|
||||
|
||||
success('端点已更新', '保存成功')
|
||||
@@ -313,7 +508,8 @@ const handleSubmit = async () => {
|
||||
max_retries: form.value.max_retries,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
is_active: form.value.is_active
|
||||
is_active: form.value.is_active,
|
||||
proxy: proxyConfig,
|
||||
})
|
||||
|
||||
success('端点创建成功', '成功')
|
||||
@@ -329,4 +525,12 @@ const handleSubmit = async () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 确认清空凭据并继续保存
|
||||
const confirmClearCredentials = () => {
|
||||
form.value.proxy_username = ''
|
||||
form.value.proxy_password = ''
|
||||
showClearCredentialsDialog.value = false
|
||||
handleSubmit(true) // 跳过凭据检查,直接提交
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -116,6 +116,25 @@
|
||||
{{ model.global_model_name }}
|
||||
</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>
|
||||
@@ -148,16 +167,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
||||
import {
|
||||
updateEndpointKey,
|
||||
getProviderAvailableSourceModels,
|
||||
testModel,
|
||||
type EndpointAPIKey,
|
||||
type ProviderAvailableSourceModel
|
||||
} from '@/api/endpoints'
|
||||
@@ -181,6 +201,7 @@ const loadingModels = ref(false)
|
||||
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
||||
const selectedModels = ref<string[]>([])
|
||||
const initialModels = ref<string[]>([])
|
||||
const testingModelName = ref<string | null>(null)
|
||||
|
||||
// 监听对话框打开
|
||||
watch(() => props.open, (open) => {
|
||||
@@ -268,6 +289,32 @@ function clearModels() {
|
||||
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 {
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort()
|
||||
|
||||
@@ -260,6 +260,7 @@ import {
|
||||
updateEndpointKey,
|
||||
getAllCapabilities,
|
||||
type EndpointAPIKey,
|
||||
type EndpointAPIKeyUpdate,
|
||||
type ProviderEndpoint,
|
||||
type CapabilityDefinition
|
||||
} from '@/api/endpoints'
|
||||
@@ -386,10 +387,11 @@ function loadKeyData() {
|
||||
api_key: '',
|
||||
rate_multiplier: props.editingKey.rate_multiplier || 1.0,
|
||||
internal_priority: props.editingKey.internal_priority ?? 50,
|
||||
max_concurrent: props.editingKey.max_concurrent || undefined,
|
||||
rate_limit: props.editingKey.rate_limit || undefined,
|
||||
daily_limit: props.editingKey.daily_limit || undefined,
|
||||
monthly_limit: props.editingKey.monthly_limit || undefined,
|
||||
// 保留原始的 null/undefined 状态,null 表示自适应模式
|
||||
max_concurrent: props.editingKey.max_concurrent ?? undefined,
|
||||
rate_limit: props.editingKey.rate_limit ?? undefined,
|
||||
daily_limit: props.editingKey.daily_limit ?? undefined,
|
||||
monthly_limit: props.editingKey.monthly_limit ?? undefined,
|
||||
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
|
||||
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
|
||||
note: props.editingKey.note || '',
|
||||
@@ -439,12 +441,17 @@ async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.editingKey) {
|
||||
// 更新
|
||||
const updateData: any = {
|
||||
// 更新模式
|
||||
// 注意:max_concurrent 需要显式发送 null 来切换到自适应模式
|
||||
// undefined 会在 JSON 中被忽略,所以用 null 表示"清空/自适应"
|
||||
const updateData: EndpointAPIKeyUpdate = {
|
||||
name: form.value.name,
|
||||
rate_multiplier: form.value.rate_multiplier,
|
||||
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,
|
||||
daily_limit: form.value.daily_limit,
|
||||
monthly_limit: form.value.monthly_limit,
|
||||
|
||||
337
frontend/src/features/providers/components/ModelAliasDialog.vue
Normal file
337
frontend/src/features/providers/components/ModelAliasDialog.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
title="管理模型名称映射"
|
||||
description="配置 Provider 对此模型使用的名称变体,系统会按优先级顺序选择"
|
||||
:icon="Tag"
|
||||
size="lg"
|
||||
@update:model-value="handleClose"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- 模型信息 -->
|
||||
<div class="rounded-lg border bg-muted/30 p-3">
|
||||
<p class="font-medium">
|
||||
{{ model?.global_model_display_name || model?.provider_model_name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground font-mono">
|
||||
主名称: {{ model?.provider_model_name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 映射列表 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-sm font-medium">名称映射</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="addAlias"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div
|
||||
v-if="aliases.length > 0"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded-md"
|
||||
>
|
||||
<Info class="w-3.5 h-3.5 shrink-0" />
|
||||
<span>拖拽调整顺序,点击序号可编辑(相同数字为同级,负载均衡)</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="aliases.length > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="(alias, index) in aliases"
|
||||
:key="index"
|
||||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
|
||||
:class="[
|
||||
draggedIndex === index
|
||||
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
||||
: dragOverIndex === index
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart(index, $event)"
|
||||
@dragend="handleDragEnd"
|
||||
@dragover.prevent="handleDragOver(index)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop(index)"
|
||||
>
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted text-muted-foreground/40 group-hover:text-muted-foreground transition-colors shrink-0">
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<!-- 可编辑优先级 -->
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-if="editingPriorityIndex === index"
|
||||
type="number"
|
||||
min="1"
|
||||
:value="alias.priority"
|
||||
class="w-8 h-6 rounded-md bg-background border border-primary text-xs font-medium 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-md bg-muted/50 flex items-center justify-center text-xs font-medium text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
title="点击编辑优先级,相同数字为同级(负载均衡)"
|
||||
@click.stop="startEditPriority(index)"
|
||||
>
|
||||
{{ alias.priority }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 映射输入框 -->
|
||||
<Input
|
||||
v-model="alias.name"
|
||||
placeholder="映射名称,如 Claude-Sonnet-4.5"
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="shrink-0 text-destructive hover:text-destructive h-8 w-8"
|
||||
@click="removeAlias(index)"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="text-center py-6 text-muted-foreground border rounded-lg border-dashed"
|
||||
>
|
||||
<Tag class="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">
|
||||
未配置映射
|
||||
</p>
|
||||
<p class="text-xs mt-1">
|
||||
将只使用主模型名称
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleClose(false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Loader2
|
||||
v-if="submitting"
|
||||
class="w-4 h-4 mr-2 animate-spin"
|
||||
/>
|
||||
保存
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Tag, Plus, X, Loader2, GripVertical, Info } from 'lucide-vue-next'
|
||||
import { Dialog, Button, Input, Label } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import type { Model, ProviderModelAlias } from '@/api/endpoints'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
providerId: string
|
||||
model: Model | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
|
||||
const submitting = ref(false)
|
||||
const aliases = ref<ProviderModelAlias[]>([])
|
||||
|
||||
// 拖拽状态
|
||||
const draggedIndex = ref<number | null>(null)
|
||||
const dragOverIndex = ref<number | null>(null)
|
||||
|
||||
// 优先级编辑状态
|
||||
const editingPriorityIndex = ref<number | null>(null)
|
||||
|
||||
// 监听 open 变化
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen && props.model) {
|
||||
// 加载现有映射配置
|
||||
if (props.model.provider_model_mappings && Array.isArray(props.model.provider_model_mappings)) {
|
||||
aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_mappings))
|
||||
} else {
|
||||
aliases.value = []
|
||||
}
|
||||
// 重置状态
|
||||
editingPriorityIndex.value = null
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// 添加映射
|
||||
function addAlias() {
|
||||
// 新映射优先级为当前最大优先级 + 1,或者默认为 1
|
||||
const maxPriority = aliases.value.length > 0
|
||||
? Math.max(...aliases.value.map(a => a.priority))
|
||||
: 0
|
||||
aliases.value.push({ name: '', priority: maxPriority + 1 })
|
||||
}
|
||||
|
||||
// 移除映射
|
||||
function removeAlias(index: number) {
|
||||
aliases.value.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 = [...aliases.value]
|
||||
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
|
||||
|
||||
// 找到被拖动项在原数组中的索引对应的原始优先级
|
||||
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
|
||||
|
||||
items.forEach((alias, newIdx) => {
|
||||
// 找到这个映射在原数组中的索引
|
||||
const originalIdx = aliases.value.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++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
aliases.value = 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
|
||||
aliases.value[index].priority = Math.max(1, newPriority)
|
||||
editingPriorityIndex.value = null
|
||||
}
|
||||
|
||||
function cancelEditPriority() {
|
||||
editingPriorityIndex.value = null
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
function handleClose(value: boolean) {
|
||||
if (!submitting.value) {
|
||||
emit('update:open', value)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交保存
|
||||
async function handleSubmit() {
|
||||
if (submitting.value || !props.model) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// 过滤掉空的映射
|
||||
const validAliases = aliases.value.filter(a => a.name.trim())
|
||||
|
||||
await updateModel(props.providerId, props.model.id, {
|
||||
provider_model_mappings: validAliases.length > 0 ? validAliases : null
|
||||
})
|
||||
|
||||
showSuccess('映射配置已保存')
|
||||
emit('update:open', false)
|
||||
emit('saved')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '保存失败', '错误')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -312,8 +312,41 @@
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-4 border-l border-border">
|
||||
<span class="text-xs text-muted-foreground">调度:</span>
|
||||
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
||||
:class="[
|
||||
schedulingMode === 'fixed_order'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
title="严格按优先级顺序,不考虑缓存"
|
||||
@click="schedulingMode = 'fixed_order'"
|
||||
>
|
||||
固定顺序
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
||||
:class="[
|
||||
schedulingMode === 'cache_affinity'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
title="优先使用已缓存的Provider,利用Prompt Cache"
|
||||
@click="schedulingMode = 'cache_affinity'"
|
||||
>
|
||||
缓存亲和
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -410,6 +443,9 @@ const saving = ref(false)
|
||||
// Key 优先级编辑状态
|
||||
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
|
||||
|
||||
// 调度模式状态
|
||||
const schedulingMode = ref<'fixed_order' | 'cache_affinity'>('cache_affinity')
|
||||
|
||||
// 可用的 API 格式
|
||||
const availableFormats = computed(() => {
|
||||
return Object.keys(keysByFormat.value).sort()
|
||||
@@ -433,11 +469,18 @@ watch(internalOpen, async (open) => {
|
||||
// 加载当前的优先级模式配置
|
||||
async function loadCurrentPriorityMode() {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig('provider_priority_mode')
|
||||
const currentMode = response.value || 'provider'
|
||||
const [priorityResponse, schedulingResponse] = await Promise.all([
|
||||
adminApi.getSystemConfig('provider_priority_mode'),
|
||||
adminApi.getSystemConfig('scheduling_mode')
|
||||
])
|
||||
const currentMode = priorityResponse.value || 'provider'
|
||||
activeMainTab.value = currentMode === 'global_key' ? 'key' : 'provider'
|
||||
|
||||
const currentSchedulingMode = schedulingResponse.value || 'cache_affinity'
|
||||
schedulingMode.value = currentSchedulingMode === 'fixed_order' ? 'fixed_order' : 'cache_affinity'
|
||||
} catch {
|
||||
activeMainTab.value = 'provider'
|
||||
schedulingMode.value = 'cache_affinity'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,11 +654,19 @@ async function save() {
|
||||
|
||||
const newMode = activeMainTab.value === 'key' ? 'global_key' : 'provider'
|
||||
|
||||
await adminApi.updateSystemConfig(
|
||||
'provider_priority_mode',
|
||||
newMode,
|
||||
'Provider/Key 优先级策略:provider(提供商优先模式) 或 global_key(全局Key优先模式)'
|
||||
)
|
||||
// 保存优先级模式和调度模式
|
||||
await Promise.all([
|
||||
adminApi.updateSystemConfig(
|
||||
'provider_priority_mode',
|
||||
newMode,
|
||||
'Provider/Key 优先级策略:provider(提供商优先模式) 或 global_key(全局Key优先模式)'
|
||||
),
|
||||
adminApi.updateSystemConfig(
|
||||
'scheduling_mode',
|
||||
schedulingMode.value,
|
||||
'调度模式:fixed_order(固定顺序模式) 或 cache_affinity(缓存亲和模式)'
|
||||
)
|
||||
])
|
||||
|
||||
const providerUpdates = sortedProviders.value.map((provider, index) =>
|
||||
updateProvider(provider.id, { provider_priority: index + 1 })
|
||||
|
||||
@@ -337,8 +337,40 @@
|
||||
{{ key.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{{ key.api_key_masked }}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[10px] font-mono text-muted-foreground truncate max-w-[180px]">
|
||||
{{ revealedKeys.has(key.id) ? revealedKeys.get(key.id) : key.api_key_masked }}
|
||||
</span>
|
||||
<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 class="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
@@ -483,9 +515,9 @@
|
||||
<span
|
||||
v-if="key.max_concurrent || key.is_adaptive"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,10 +560,11 @@
|
||||
@batch-assign="handleBatchAssign"
|
||||
/>
|
||||
|
||||
<!-- 模型映射 -->
|
||||
<MappingsTab
|
||||
<!-- 模型名称映射 -->
|
||||
<ModelAliasesTab
|
||||
v-if="provider"
|
||||
:key="`mappings-${provider.id}`"
|
||||
ref="modelAliasesTabRef"
|
||||
:key="`aliases-${provider.id}`"
|
||||
:provider="provider"
|
||||
@refresh="handleRelatedDataRefresh"
|
||||
/>
|
||||
@@ -653,18 +686,22 @@ import {
|
||||
Power,
|
||||
Layers,
|
||||
GripVertical,
|
||||
Copy
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||
import {
|
||||
KeyFormDialog,
|
||||
KeyAllowedModelsDialog,
|
||||
MappingsTab,
|
||||
ModelsTab,
|
||||
ModelAliasesTab,
|
||||
BatchAssignModelsDialog
|
||||
} from '@/features/providers/components'
|
||||
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
||||
@@ -678,6 +715,7 @@ import {
|
||||
updateEndpoint,
|
||||
updateEndpointKey,
|
||||
batchUpdateKeyPriority,
|
||||
revealEndpointKey,
|
||||
type ProviderEndpoint,
|
||||
type EndpointAPIKey,
|
||||
type Model
|
||||
@@ -704,6 +742,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const loading = ref(false)
|
||||
const provider = ref<any>(null)
|
||||
@@ -727,6 +766,10 @@ const recoveringEndpointId = ref<string | null>(null)
|
||||
const togglingEndpointId = 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 editingModel = ref<Model | null>(null)
|
||||
@@ -734,6 +777,9 @@ const deleteModelConfirmOpen = ref(false)
|
||||
const modelToDelete = ref<Model | null>(null)
|
||||
const batchAssignDialogOpen = ref(false)
|
||||
|
||||
// ModelAliasesTab 组件引用
|
||||
const modelAliasesTabRef = ref<InstanceType<typeof ModelAliasesTab> | null>(null)
|
||||
|
||||
// 拖动排序相关状态
|
||||
const dragState = ref({
|
||||
isDragging: false,
|
||||
@@ -755,7 +801,9 @@ const hasBlockingDialogOpen = computed(() =>
|
||||
deleteKeyConfirmOpen.value ||
|
||||
modelFormDialogOpen.value ||
|
||||
deleteModelConfirmOpen.value ||
|
||||
batchAssignDialogOpen.value
|
||||
batchAssignDialogOpen.value ||
|
||||
// 检测 ModelAliasesTab 子组件的 Dialog 是否打开
|
||||
modelAliasesTabRef.value?.dialogOpen
|
||||
)
|
||||
|
||||
// 监听 providerId 变化
|
||||
@@ -791,6 +839,9 @@ watch(() => props.open, (newOpen) => {
|
||||
currentEndpoint.value = null
|
||||
editingKey.value = null
|
||||
keyToDelete.value = null
|
||||
|
||||
// 清除已显示的密钥(安全考虑)
|
||||
revealedKeys.value.clear()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -879,6 +930,43 @@ function handleConfigKeyModels(key: EndpointAPIKey) {
|
||||
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) {
|
||||
keyToDelete.value = key
|
||||
deleteKeyConfirmOpen.value = true
|
||||
@@ -1243,16 +1331,6 @@ function getHealthScoreBarColor(score: number): string {
|
||||
return 'bg-red-500 dark:bg-red-400'
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制到剪贴板')
|
||||
} catch {
|
||||
showError('复制失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 Provider 信息
|
||||
async function loadProvider() {
|
||||
if (!props.providerId) return
|
||||
@@ -1296,6 +1374,16 @@ async function loadEndpoints() {
|
||||
showError(err.response?.data?.detail || '加载端点失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.open) {
|
||||
handleClose()
|
||||
}
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,6 +7,7 @@ export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vu
|
||||
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
||||
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
|
||||
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
|
||||
export { default as ModelAliasDialog } from './ModelAliasDialog.vue'
|
||||
|
||||
export { default as MappingsTab } from './provider-tabs/MappingsTab.vue'
|
||||
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
|
||||
export { default as ModelAliasesTab } from './provider-tabs/ModelAliasesTab.vue'
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题头部 -->
|
||||
<div class="p-4 border-b border-border/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold leading-none">
|
||||
别名与映射管理
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!hideAddButton"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
||||
创建别名/映射
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
|
||||
<!-- 别名列表 -->
|
||||
<div
|
||||
v-else-if="mappings.length > 0"
|
||||
class="overflow-x-auto"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-semibold">
|
||||
名称
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-semibold w-24">
|
||||
类型
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-semibold">
|
||||
指向模型
|
||||
</th>
|
||||
<th
|
||||
v-if="!hideAddButton"
|
||||
class="px-4 py-3 font-semibold w-28 text-center"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="mapping in mappings"
|
||||
:key="mapping.id"
|
||||
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 状态指示灯 -->
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="mapping.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||
:title="mapping.is_active ? '活跃' : '停用'"
|
||||
/>
|
||||
<span class="font-mono">{{ mapping.alias }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ mapping.mapping_type === 'mapping' ? '映射' : '别名' }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{ mapping.global_model_display_name || mapping.global_model_name }}
|
||||
</td>
|
||||
<td
|
||||
v-if="!hideAddButton"
|
||||
class="px-4 py-3"
|
||||
>
|
||||
<div class="flex justify-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="编辑"
|
||||
@click="openEditDialog(mapping)"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="togglingId === mapping.id"
|
||||
:title="mapping.is_active ? '点击停用' : '点击启用'"
|
||||
@click="toggleActive(mapping)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
title="删除"
|
||||
@click="confirmDelete(mapping)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="p-8 text-center text-muted-foreground"
|
||||
>
|
||||
<ArrowLeftRight class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p class="text-sm">
|
||||
暂无特定别名/映射
|
||||
</p>
|
||||
<p class="text-xs mt-1">
|
||||
点击上方按钮添加
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 使用共享的 AliasDialog 组件 -->
|
||||
<AliasDialog
|
||||
:open="dialogOpen"
|
||||
:editing-alias="editingAlias"
|
||||
:global-models="availableModels"
|
||||
:fixed-provider="fixedProviderOption"
|
||||
:show-provider-select="true"
|
||||
@update:open="handleDialogVisibility"
|
||||
@submit="handleAliasSubmit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ArrowLeftRight, Plus, Edit, Trash2, Power } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import AliasDialog from '@/features/models/components/AliasDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
getAliases,
|
||||
createAlias,
|
||||
updateAlias,
|
||||
deleteAlias,
|
||||
type ModelAlias,
|
||||
type CreateModelAliasRequest,
|
||||
type UpdateModelAliasRequest,
|
||||
} from '@/api/endpoints/aliases'
|
||||
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
provider: any
|
||||
hideAddButton?: boolean
|
||||
}>(), {
|
||||
hideAddButton: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const togglingId = ref<string | null>(null)
|
||||
const mappings = ref<ModelAlias[]>([])
|
||||
const availableModels = ref<GlobalModelResponse[]>([])
|
||||
const dialogOpen = ref(false)
|
||||
const editingAlias = ref<ModelAlias | null>(null)
|
||||
|
||||
// 固定的 Provider 选项(传递给 AliasDialog)
|
||||
const fixedProviderOption = computed(() => ({
|
||||
id: props.provider.id,
|
||||
name: props.provider.name,
|
||||
display_name: props.provider.display_name
|
||||
}))
|
||||
|
||||
// 加载映射 (实际返回的是该 Provider 的别名列表)
|
||||
async function loadMappings() {
|
||||
try {
|
||||
loading.value = true
|
||||
mappings.value = await getAliases({ provider_id: props.provider.id })
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载失败', '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载可用的 GlobalModel 列表
|
||||
async function loadAvailableModels() {
|
||||
try {
|
||||
const response = await listGlobalModels({ limit: 1000, is_active: true })
|
||||
availableModels.value = response.models || []
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载模型列表失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开创建对话框
|
||||
function openCreateDialog() {
|
||||
editingAlias.value = null
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
function openEditDialog(alias: ModelAlias) {
|
||||
editingAlias.value = alias
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
// 处理对话框可见性变化
|
||||
function handleDialogVisibility(value: boolean) {
|
||||
dialogOpen.value = value
|
||||
if (!value) {
|
||||
editingAlias.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理别名提交(来自 AliasDialog 组件)
|
||||
async function handleAliasSubmit(data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean) {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit && editingAlias.value) {
|
||||
// 更新
|
||||
await updateAlias(editingAlias.value.id, data as UpdateModelAliasRequest)
|
||||
success(data.mapping_type === 'mapping' ? '映射已更新' : '别名已更新')
|
||||
} else {
|
||||
// 创建 - 确保 provider_id 设置为当前 Provider
|
||||
const createData = data as CreateModelAliasRequest
|
||||
createData.provider_id = props.provider.id
|
||||
await createAlias(createData)
|
||||
success(data.mapping_type === 'mapping' ? '映射已创建' : '别名已创建')
|
||||
}
|
||||
dialogOpen.value = false
|
||||
editingAlias.value = null
|
||||
await loadMappings()
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail || err.message
|
||||
let errorMessage = detail
|
||||
if (detail === '映射已存在') {
|
||||
errorMessage = '该名称已存在,请使用其他名称'
|
||||
}
|
||||
showError(errorMessage, isEdit ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换启用状态
|
||||
async function toggleActive(alias: ModelAlias) {
|
||||
if (togglingId.value) return
|
||||
|
||||
togglingId.value = alias.id
|
||||
try {
|
||||
const newStatus = !alias.is_active
|
||||
await updateAlias(alias.id, { is_active: newStatus })
|
||||
alias.is_active = newStatus
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||
} finally {
|
||||
togglingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
async function confirmDelete(alias: ModelAlias) {
|
||||
const typeName = alias.mapping_type === 'mapping' ? '映射' : '别名'
|
||||
if (!confirm(`确定要删除${typeName} "${alias.alias}" 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAlias(alias.id)
|
||||
success(`${typeName}已删除`)
|
||||
await loadMappings()
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMappings()
|
||||
loadAvailableModels()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题头部 -->
|
||||
<div class="p-4 border-b border-border/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||
模型名称映射
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
||||
添加映射
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
|
||||
<!-- 分组映射列表 -->
|
||||
<div
|
||||
v-else-if="aliasGroups.length > 0"
|
||||
class="divide-y divide-border/40"
|
||||
>
|
||||
<div
|
||||
v-for="group in aliasGroups"
|
||||
:key="`${group.model.id}-${group.apiFormatsKey}`"
|
||||
class="transition-colors"
|
||||
>
|
||||
<!-- 分组头部(可点击展开) -->
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
|
||||
@click="toggleAliasGroupExpand(`${group.model.id}-${group.apiFormatsKey}`)"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<!-- 展开/收起图标 -->
|
||||
<ChevronRight
|
||||
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform"
|
||||
:class="{ 'rotate-90': expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`) }"
|
||||
/>
|
||||
<!-- 模型名称 -->
|
||||
<span class="font-semibold text-sm truncate">
|
||||
{{ group.model.global_model_display_name || group.model.provider_model_name }}
|
||||
</span>
|
||||
<!-- 作用域标签 -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Badge
|
||||
v-if="group.apiFormats.length === 0"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
全部
|
||||
</Badge>
|
||||
<Badge
|
||||
v-for="format in group.apiFormats"
|
||||
v-else
|
||||
:key="format"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ API_FORMAT_LABELS[format] || format }}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 映射数量 -->
|
||||
<span class="text-xs text-muted-foreground shrink-0">
|
||||
({{ group.aliases.length }} 个映射)
|
||||
</span>
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<div
|
||||
class="flex items-center gap-1.5 ml-4 shrink-0"
|
||||
@click.stop
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="编辑映射组"
|
||||
@click="editGroup(group)"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
title="删除映射组"
|
||||
@click="deleteGroup(group)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开的映射列表 -->
|
||||
<div
|
||||
v-show="expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`)"
|
||||
class="bg-muted/30 border-t border-border/30"
|
||||
>
|
||||
<div class="px-4 py-2 space-y-1">
|
||||
<div
|
||||
v-for="mapping in group.aliases"
|
||||
:key="mapping.name"
|
||||
class="flex items-center justify-between gap-2 py-1"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<!-- 优先级标签 -->
|
||||
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-background border text-xs font-medium shrink-0">
|
||||
{{ mapping.priority }}
|
||||
</span>
|
||||
<!-- 映射名称 -->
|
||||
<span class="font-mono text-sm truncate">
|
||||
{{ mapping.name }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 测试按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
title="测试映射"
|
||||
:disabled="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
|
||||
@click="testMapping(group, mapping)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
|
||||
class="w-3 h-3 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="p-8 text-center text-muted-foreground"
|
||||
>
|
||||
<Tag class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p class="text-sm">
|
||||
暂无模型映射
|
||||
</p>
|
||||
<p class="text-xs mt-1">
|
||||
点击上方"添加映射"按钮为模型创建名称映射
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 添加/编辑映射对话框 -->
|
||||
<ModelMappingDialog
|
||||
v-model:open="dialogOpen"
|
||||
:provider-id="provider.id"
|
||||
:provider-api-formats="providerApiFormats"
|
||||
:models="models"
|
||||
:editing-group="editingGroup"
|
||||
@saved="onDialogSaved"
|
||||
/>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<AlertDialog
|
||||
v-model="deleteConfirmOpen"
|
||||
title="删除映射组"
|
||||
:description="deleteConfirmDescription"
|
||||
confirm-text="删除"
|
||||
cancel-text="取消"
|
||||
type="danger"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="deleteConfirmOpen = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
|
||||
import { Card, Button, Badge } from '@/components/ui'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
getProviderModels,
|
||||
testModel,
|
||||
API_FORMAT_LABELS,
|
||||
type Model,
|
||||
type ProviderModelAlias
|
||||
} from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseTestModelError } from '@/utils/errorParser'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const models = ref<Model[]>([])
|
||||
const dialogOpen = ref(false)
|
||||
const deleteConfirmOpen = ref(false)
|
||||
const editingGroup = ref<AliasGroup | null>(null)
|
||||
const deletingGroup = ref<AliasGroup | null>(null)
|
||||
const testingMapping = ref<string | null>(null)
|
||||
|
||||
// 列表展开状态
|
||||
const expandedAliasGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 获取 Provider 支持的 API 格式
|
||||
const providerApiFormats = computed(() => {
|
||||
const formats = props.provider?.api_formats
|
||||
if (Array.isArray(formats) && formats.length > 0) {
|
||||
const order = Object.keys(API_FORMAT_LABELS)
|
||||
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// 生成作用域唯一键
|
||||
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||
if (!formats || formats.length === 0) return ''
|
||||
return [...formats].sort().join(',')
|
||||
}
|
||||
|
||||
// 按"模型+作用域"分组的映射列表
|
||||
const aliasGroups = computed<AliasGroup[]>(() => {
|
||||
const groups: AliasGroup[] = []
|
||||
const groupMap = new Map<string, AliasGroup>()
|
||||
|
||||
for (const model of models.value) {
|
||||
if (!model.provider_model_mappings || !Array.isArray(model.provider_model_mappings)) continue
|
||||
|
||||
for (const alias of model.provider_model_mappings) {
|
||||
const apiFormatsKey = getApiFormatsKey(alias.api_formats)
|
||||
const groupKey = `${model.id}|${apiFormatsKey}`
|
||||
|
||||
if (!groupMap.has(groupKey)) {
|
||||
const group: AliasGroup = {
|
||||
model,
|
||||
apiFormatsKey,
|
||||
apiFormats: alias.api_formats || [],
|
||||
aliases: []
|
||||
}
|
||||
groupMap.set(groupKey, group)
|
||||
groups.push(group)
|
||||
}
|
||||
groupMap.get(groupKey)!.aliases.push(alias)
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
group.aliases.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => {
|
||||
const nameA = (a.model.global_model_display_name || a.model.provider_model_name || '').toLowerCase()
|
||||
const nameB = (b.model.global_model_display_name || b.model.provider_model_name || '').toLowerCase()
|
||||
if (nameA !== nameB) return nameA.localeCompare(nameB)
|
||||
return a.apiFormatsKey.localeCompare(b.apiFormatsKey)
|
||||
})
|
||||
})
|
||||
|
||||
// 加载模型
|
||||
async function loadModels() {
|
||||
try {
|
||||
loading.value = true
|
||||
models.value = await getProviderModels(props.provider.id)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载失败', '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除确认描述
|
||||
const deleteConfirmDescription = computed(() => {
|
||||
if (!deletingGroup.value) return ''
|
||||
const { model, aliases, apiFormats } = deletingGroup.value
|
||||
const modelName = model.global_model_display_name || model.provider_model_name
|
||||
const scopeText = apiFormats.length === 0 ? '全部' : apiFormats.map(f => API_FORMAT_LABELS[f] || f).join(', ')
|
||||
const aliasNames = aliases.map(a => a.name).join(', ')
|
||||
return `确定要删除模型「${modelName}」在作用域「${scopeText}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
|
||||
})
|
||||
|
||||
// 切换映射组展开状态
|
||||
function toggleAliasGroupExpand(groupKey: string) {
|
||||
if (expandedAliasGroups.value.has(groupKey)) {
|
||||
expandedAliasGroups.value.delete(groupKey)
|
||||
} else {
|
||||
expandedAliasGroups.value.add(groupKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开添加对话框
|
||||
function openAddDialog() {
|
||||
editingGroup.value = null
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
// 编辑分组
|
||||
function editGroup(group: AliasGroup) {
|
||||
editingGroup.value = group
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
// 删除分组
|
||||
function deleteGroup(group: AliasGroup) {
|
||||
deletingGroup.value = group
|
||||
deleteConfirmOpen.value = true
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
async function confirmDelete() {
|
||||
if (!deletingGroup.value) return
|
||||
|
||||
const { model, aliases, apiFormatsKey } = deletingGroup.value
|
||||
|
||||
try {
|
||||
const currentAliases = model.provider_model_mappings || []
|
||||
const aliasNamesToRemove = new Set(aliases.map(a => a.name))
|
||||
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||
const currentKey = getApiFormatsKey(a.api_formats)
|
||||
return !(currentKey === apiFormatsKey && aliasNamesToRemove.has(a.name))
|
||||
})
|
||||
|
||||
await updateModel(props.provider.id, model.id, {
|
||||
provider_model_mappings: newAliases.length > 0 ? newAliases : null
|
||||
})
|
||||
|
||||
showSuccess('映射组已删除')
|
||||
deleteConfirmOpen.value = false
|
||||
deletingGroup.value = null
|
||||
await loadModels()
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '删除失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 对话框保存后回调
|
||||
async function onDialogSaved() {
|
||||
await loadModels()
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 测试模型映射
|
||||
async function testMapping(group: any, mapping: any) {
|
||||
const testingKey = `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`
|
||||
testingMapping.value = testingKey
|
||||
|
||||
try {
|
||||
// 根据分组的 API 格式来确定应该使用的格式
|
||||
let apiFormat = null
|
||||
if (group.apiFormats.length === 1) {
|
||||
apiFormat = group.apiFormats[0]
|
||||
} else if (group.apiFormats.length === 0) {
|
||||
// 如果没有指定格式,但分组显示为"全部",则使用模型的默认格式
|
||||
apiFormat = group.model.effective_api_format || group.model.api_format
|
||||
}
|
||||
|
||||
const result = await testModel({
|
||||
provider_id: props.provider.id,
|
||||
model_name: mapping.name, // 使用映射名称进行测试
|
||||
message: "hello",
|
||||
api_format: apiFormat
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(`映射 "${mapping.name}" 测试成功`)
|
||||
|
||||
// 如果有响应内容,可以显示更多信息
|
||||
if (result.data?.response?.choices?.[0]?.message?.content) {
|
||||
const content = result.data.response.choices[0].message.content
|
||||
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
|
||||
} else if (result.data?.content_preview) {
|
||||
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
|
||||
}
|
||||
} else {
|
||||
showError(`映射测试失败: ${parseTestModelError(result)}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||
showError(`映射测试失败: ${errorMsg}`)
|
||||
} finally {
|
||||
testingMapping.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 provider 变化
|
||||
watch(() => props.provider?.id, (newId) => {
|
||||
if (newId) {
|
||||
loadModels()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
if (props.provider?.id) {
|
||||
loadModels()
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露给父组件,用于检测是否有弹窗打开
|
||||
defineExpose({
|
||||
dialogOpen: computed(() => dialogOpen.value || deleteConfirmOpen.value)
|
||||
})
|
||||
</script>
|
||||
@@ -213,6 +213,7 @@ import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProviderModels, type Model } from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
|
||||
@@ -227,6 +228,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -244,12 +246,7 @@ const sortedModels = computed(() => {
|
||||
|
||||
// 复制模型 ID 到剪贴板
|
||||
async function copyModelId(modelId: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(modelId)
|
||||
showSuccess('已复制到剪贴板')
|
||||
} catch {
|
||||
showError('复制失败', '错误')
|
||||
}
|
||||
await copyToClipboard(modelId)
|
||||
}
|
||||
|
||||
// 加载模型
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -479,10 +479,25 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
return groups
|
||||
})
|
||||
|
||||
// 计算链路总耗时(从第一个节点开始到最后一个节点结束)
|
||||
// 计算链路总耗时(使用成功候选的 latency_ms 字段)
|
||||
// 优先使用 latency_ms,因为它与 Usage.response_time_ms 使用相同的时间基准
|
||||
// 避免 finished_at - started_at 带来的额外延迟(数据库操作时间)
|
||||
const totalTraceLatency = computed(() => {
|
||||
if (!timeline.value || timeline.value.length === 0) return 0
|
||||
|
||||
// 查找成功的候选,使用其 latency_ms
|
||||
const successCandidate = timeline.value.find(c => c.status === 'success')
|
||||
if (successCandidate?.latency_ms != null) {
|
||||
return successCandidate.latency_ms
|
||||
}
|
||||
|
||||
// 如果没有成功的候选,查找失败但有 latency_ms 的候选
|
||||
const failedWithLatency = timeline.value.find(c => c.status === 'failed' && c.latency_ms != null)
|
||||
if (failedWithLatency?.latency_ms != null) {
|
||||
return failedWithLatency.latency_ms
|
||||
}
|
||||
|
||||
// 回退:使用 finished_at - started_at 计算
|
||||
let earliestStart: number | null = null
|
||||
let latestEnd: number | null = null
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</h3>
|
||||
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
<span>{{ detail?.model || '-' }}</span>
|
||||
<template v-if="detail?.target_model">
|
||||
<template v-if="detail?.target_model && detail.target_model !== detail.model">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -472,6 +472,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from '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 Badge from '@/components/ui/badge.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 currentExpandDepth = ref(1)
|
||||
const dataSource = ref<'client' | 'provider'>('client')
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const historicalPricing = ref<{
|
||||
input_price: string
|
||||
output_price: string
|
||||
@@ -783,7 +786,7 @@ function copyJsonToClipboard(tabName: string) {
|
||||
}
|
||||
|
||||
if (data) {
|
||||
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
|
||||
copyToClipboard(JSON.stringify(data, null, 2), false)
|
||||
copiedStates.value[tabName] = true
|
||||
setTimeout(() => {
|
||||
copiedStates.value[tabName] = false
|
||||
@@ -897,6 +900,16 @@ const providerHeadersWithDiff = computed(() => {
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.isOpen) {
|
||||
handleClose()
|
||||
}
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -136,11 +136,20 @@
|
||||
<!-- 分隔线 -->
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
<!-- 自动刷新按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
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>
|
||||
|
||||
<Table>
|
||||
@@ -177,8 +186,9 @@
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[70px] text-right">
|
||||
<div class="inline-block max-w-[2rem] leading-tight">
|
||||
响应时间
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span>首字</span>
|
||||
<span class="text-muted-foreground font-normal">总耗时</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -356,15 +366,48 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-4 w-[70px]">
|
||||
<span
|
||||
v-if="record.status === 'pending' || record.status === 'streaming'"
|
||||
class="text-primary tabular-nums"
|
||||
<!-- pending 状态:只显示增长的总时间 -->
|
||||
<div
|
||||
v-if="record.status === 'pending'"
|
||||
class="flex flex-col items-end text-xs gap-0.5"
|
||||
>
|
||||
{{ getElapsedTime(record) }}
|
||||
</span>
|
||||
<span v-else-if="record.response_time_ms">
|
||||
{{ (record.response_time_ms / 1000).toFixed(2) }}s
|
||||
</span>
|
||||
<span class="text-muted-foreground">-</span>
|
||||
<span class="text-primary tabular-nums">
|
||||
{{ getElapsedTime(record) }}
|
||||
</span>
|
||||
</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
|
||||
v-else-if="record.response_time_ms != null"
|
||||
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-muted-foreground tabular-nums">{{ (record.response_time_ms / 1000).toFixed(2) }}s</span>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground"
|
||||
@@ -394,6 +437,7 @@ import { ref, computed, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
TableCard,
|
||||
Badge,
|
||||
Button,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -406,8 +450,8 @@ import {
|
||||
TableHead,
|
||||
TableCell,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
} from '@/components/ui'
|
||||
import { RefreshCcw } from 'lucide-vue-next'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { formatDateTime } from '../composables'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
@@ -439,6 +483,8 @@ const props = defineProps<{
|
||||
pageSize: number
|
||||
totalRecords: number
|
||||
pageSizeOptions: number[]
|
||||
// 自动刷新
|
||||
autoRefresh: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -449,6 +495,7 @@ const emit = defineEmits<{
|
||||
'update:filterStatus': [value: string]
|
||||
'update:currentPage': [value: number]
|
||||
'update:pageSize': [value: number]
|
||||
'update:autoRefresh': [value: boolean]
|
||||
'refresh': []
|
||||
'showDetail': [id: string]
|
||||
}>()
|
||||
@@ -543,13 +590,14 @@ function formatApiFormat(format: string): string {
|
||||
}
|
||||
|
||||
// 获取实际使用的模型(优先 target_model,其次 model_version)
|
||||
// 只有当实际模型与请求模型不同时才返回,用于显示映射箭头
|
||||
function getActualModel(record: UsageRecord): string | null {
|
||||
// 优先显示模型映射
|
||||
if (record.target_model) {
|
||||
if (record.target_model && record.target_model !== record.model) {
|
||||
return record.target_model
|
||||
}
|
||||
// 其次显示 Provider 返回的实际版本(如 Gemini 的 modelVersion)
|
||||
if (record.request_metadata?.model_version) {
|
||||
if (record.request_metadata?.model_version && record.request_metadata.model_version !== record.model) {
|
||||
return record.request_metadata.model_version
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface UsageRecord {
|
||||
cost: number
|
||||
actual_cost?: number
|
||||
response_time_ms?: number
|
||||
first_byte_time_ms?: number // 首字时间 (TTFB)
|
||||
is_stream: boolean
|
||||
status_code?: number
|
||||
error_message?: string
|
||||
|
||||
@@ -86,6 +86,34 @@
|
||||
</p>
|
||||
</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">
|
||||
<Label
|
||||
for="form-email"
|
||||
@@ -423,6 +451,7 @@ const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: 10,
|
||||
role: 'user' as 'admin' | 'user',
|
||||
@@ -443,6 +472,7 @@ function resetForm() {
|
||||
form.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: 10,
|
||||
role: 'user',
|
||||
@@ -461,6 +491,7 @@ function loadUserData() {
|
||||
form.value = {
|
||||
username: props.user.username,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: props.user.email || '',
|
||||
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
||||
role: props.user.role,
|
||||
@@ -486,7 +517,9 @@ const isFormValid = computed(() => {
|
||||
const hasUsername = form.value.username.trim().length > 0
|
||||
const hasEmail = form.value.email.trim().length > 0
|
||||
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
|
||||
})
|
||||
|
||||
// 加载访问控制选项
|
||||
|
||||
@@ -313,7 +313,6 @@ import {
|
||||
Gauge,
|
||||
Layers,
|
||||
FolderTree,
|
||||
Tag,
|
||||
Box,
|
||||
LogOut,
|
||||
SunMoon,
|
||||
@@ -321,6 +320,7 @@ import {
|
||||
Megaphone,
|
||||
Menu,
|
||||
X,
|
||||
Mail,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -411,7 +411,6 @@ const navigation = computed(() => {
|
||||
{ name: '用户管理', href: '/admin/users', icon: Users },
|
||||
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
|
||||
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
||||
{ name: '别名映射', href: '/admin/aliases', icon: Tag },
|
||||
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
||||
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
|
||||
]
|
||||
@@ -423,6 +422,7 @@ const navigation = computed(() => {
|
||||
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
|
||||
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
||||
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
||||
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
||||
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { User, LoginResponse } from '@/api/auth'
|
||||
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 { Profile, UsageResponse } from '@/api/me'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
@@ -185,18 +185,20 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
|
||||
output: 700000,
|
||||
cache_creation: 50000,
|
||||
cache_read: 200000
|
||||
}
|
||||
},
|
||||
// 普通用户专用字段
|
||||
monthly_cost: 45.67
|
||||
}
|
||||
|
||||
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
|
||||
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' },
|
||||
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' },
|
||||
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' },
|
||||
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' },
|
||||
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' },
|
||||
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' },
|
||||
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' },
|
||||
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' }
|
||||
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
|
||||
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
|
||||
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
|
||||
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
|
||||
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
|
||||
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
|
||||
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
|
||||
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
|
||||
]
|
||||
|
||||
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
|
||||
@@ -231,11 +233,11 @@ function generateDailyStats(): DailyStatsResponse {
|
||||
unique_models: 8 + Math.floor(Math.random() * 5),
|
||||
unique_providers: 4 + Math.floor(Math.random() * 3),
|
||||
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: 'gpt-4o', 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: 'gemini-2.0-flash', 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-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-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-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).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-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 {
|
||||
daily_stats: dailyStats,
|
||||
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: 'gpt-4o', 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: '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: '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-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-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-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-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-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: {
|
||||
start_date: dailyStats[0].date,
|
||||
@@ -336,7 +338,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
|
||||
// ========== API Key 数据 ==========
|
||||
|
||||
export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
export const MOCK_USER_API_KEYS = [
|
||||
{
|
||||
id: 'key-uuid-001',
|
||||
key_display: 'sk-ae...x7f9',
|
||||
@@ -346,7 +348,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
is_active: true,
|
||||
is_standalone: false,
|
||||
total_requests: 1234,
|
||||
total_cost_usd: 45.67
|
||||
total_cost_usd: 45.67,
|
||||
force_capabilities: null
|
||||
},
|
||||
{
|
||||
id: 'key-uuid-002',
|
||||
@@ -357,7 +360,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
is_active: true,
|
||||
is_standalone: false,
|
||||
total_requests: 5678,
|
||||
total_cost_usd: 123.45
|
||||
total_cost_usd: 123.45,
|
||||
force_capabilities: { cache_1h: true }
|
||||
},
|
||||
{
|
||||
id: 'key-uuid-003',
|
||||
@@ -367,7 +371,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
is_active: false,
|
||||
is_standalone: false,
|
||||
total_requests: 100,
|
||||
total_cost_usd: 2.34
|
||||
total_cost_usd: 2.34,
|
||||
force_capabilities: null
|
||||
}
|
||||
]
|
||||
|
||||
@@ -611,41 +616,42 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
|
||||
id: 'gm-001',
|
||||
name: 'claude-haiku-4-5-20251001',
|
||||
display_name: 'claude-haiku-4-5',
|
||||
description: 'Anthropic 最快速的 Claude 4 系列模型',
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 1.00, output_price_per_1m: 5.00, cache_creation_price_per_1m: 1.25, cache_read_price_per_1m: 0.1 }]
|
||||
},
|
||||
default_supports_vision: true,
|
||||
default_supports_function_calling: true,
|
||||
default_supports_streaming: true,
|
||||
default_supports_extended_thinking: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
vision: true,
|
||||
function_calling: true,
|
||||
extended_thinking: true,
|
||||
description: 'Anthropic 最快速的 Claude 4 系列模型'
|
||||
},
|
||||
provider_count: 3,
|
||||
alias_count: 2,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gm-002',
|
||||
name: 'claude-opus-4-5-20251101',
|
||||
display_name: 'claude-opus-4-5',
|
||||
description: 'Anthropic 最强大的模型',
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5.00, output_price_per_1m: 25.00, cache_creation_price_per_1m: 6.25, cache_read_price_per_1m: 0.5 }]
|
||||
},
|
||||
default_supports_vision: true,
|
||||
default_supports_function_calling: true,
|
||||
default_supports_streaming: true,
|
||||
default_supports_extended_thinking: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
vision: true,
|
||||
function_calling: true,
|
||||
extended_thinking: true,
|
||||
description: 'Anthropic 最强大的模型'
|
||||
},
|
||||
provider_count: 2,
|
||||
alias_count: 1,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gm-003',
|
||||
name: 'claude-sonnet-4-5-20250929',
|
||||
display_name: 'claude-sonnet-4-5',
|
||||
description: 'Anthropic 平衡型模型,支持 1h 缓存和 CLI 1M 上下文',
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [
|
||||
@@ -677,116 +683,124 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
default_supports_vision: true,
|
||||
default_supports_function_calling: true,
|
||||
default_supports_streaming: true,
|
||||
default_supports_extended_thinking: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
vision: true,
|
||||
function_calling: true,
|
||||
extended_thinking: true,
|
||||
description: 'Anthropic 平衡型模型,支持 1h 缓存和 CLI 1M 上下文'
|
||||
},
|
||||
supported_capabilities: ['cache_1h', 'cli_1m'],
|
||||
provider_count: 3,
|
||||
alias_count: 2,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gm-004',
|
||||
name: 'gemini-3-pro-image-preview',
|
||||
display_name: 'gemini-3-pro-image-preview',
|
||||
description: 'Google Gemini 3 Pro 图像生成预览版',
|
||||
is_active: true,
|
||||
default_price_per_request: 0.300,
|
||||
default_tiered_pricing: {
|
||||
tiers: []
|
||||
},
|
||||
default_supports_vision: true,
|
||||
default_supports_function_calling: false,
|
||||
default_supports_streaming: true,
|
||||
default_supports_image_generation: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
vision: true,
|
||||
function_calling: false,
|
||||
image_generation: true,
|
||||
description: 'Google Gemini 3 Pro 图像生成预览版'
|
||||
},
|
||||
provider_count: 1,
|
||||
alias_count: 0,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gm-005',
|
||||
name: 'gemini-3-pro-preview',
|
||||
display_name: 'gemini-3-pro-preview',
|
||||
description: 'Google Gemini 3 Pro 预览版',
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 2.00, output_price_per_1m: 12.00 }]
|
||||
},
|
||||
default_supports_vision: true,
|
||||
default_supports_function_calling: true,
|
||||
default_supports_streaming: true,
|
||||
default_supports_extended_thinking: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
vision: true,
|
||||
function_calling: true,
|
||||
extended_thinking: true,
|
||||
description: 'Google Gemini 3 Pro 预览版'
|
||||
},
|
||||
provider_count: 1,
|
||||
alias_count: 0,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gm-006',
|
||||
name: 'gpt-5.1',
|
||||
display_name: 'gpt-5.1',
|
||||
description: 'OpenAI GPT-5.1 模型',
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
|
||||
},
|
||||
default_supports_vision: true,
|
||||
default_supports_function_calling: true,
|
||||
default_supports_streaming: true,
|
||||
default_supports_extended_thinking: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
vision: true,
|
||||
function_calling: true,
|
||||
extended_thinking: true,
|
||||
description: 'OpenAI GPT-5.1 模型'
|
||||
},
|
||||
provider_count: 2,
|
||||
alias_count: 1,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gm-007',
|
||||
name: 'gpt-5.1-codex',
|
||||
display_name: 'gpt-5.1-codex',
|
||||
description: 'OpenAI GPT-5.1 Codex 代码专用模型',
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
|
||||
},
|
||||
default_supports_vision: true,
|
||||
default_supports_function_calling: true,
|
||||
default_supports_streaming: true,
|
||||
default_supports_extended_thinking: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
vision: true,
|
||||
function_calling: true,
|
||||
extended_thinking: true,
|
||||
description: 'OpenAI GPT-5.1 Codex 代码专用模型'
|
||||
},
|
||||
provider_count: 2,
|
||||
alias_count: 0,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gm-008',
|
||||
name: 'gpt-5.1-codex-max',
|
||||
display_name: 'gpt-5.1-codex-max',
|
||||
description: 'OpenAI GPT-5.1 Codex Max 代码专用增强版',
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
|
||||
},
|
||||
default_supports_vision: true,
|
||||
default_supports_function_calling: true,
|
||||
default_supports_streaming: true,
|
||||
default_supports_extended_thinking: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
vision: true,
|
||||
function_calling: true,
|
||||
extended_thinking: true,
|
||||
description: 'OpenAI GPT-5.1 Codex Max 代码专用增强版'
|
||||
},
|
||||
provider_count: 2,
|
||||
alias_count: 0,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gm-009',
|
||||
name: 'gpt-5.1-codex-mini',
|
||||
display_name: 'gpt-5.1-codex-mini',
|
||||
description: 'OpenAI GPT-5.1 Codex Mini 轻量代码模型',
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
|
||||
},
|
||||
default_supports_vision: true,
|
||||
default_supports_function_calling: true,
|
||||
default_supports_streaming: true,
|
||||
default_supports_extended_thinking: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
vision: true,
|
||||
function_calling: true,
|
||||
extended_thinking: true,
|
||||
description: 'OpenAI GPT-5.1 Codex Mini 轻量代码模型'
|
||||
},
|
||||
provider_count: 2,
|
||||
alias_count: 0,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
]
|
||||
@@ -804,16 +818,16 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
||||
quota_usd: 100,
|
||||
used_usd: 45.32,
|
||||
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: '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: '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: '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: '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-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-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-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: [
|
||||
{
|
||||
id: 'usage-001',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
input_tokens: 1500,
|
||||
output_tokens: 800,
|
||||
total_tokens: 2300,
|
||||
@@ -828,7 +842,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
||||
{
|
||||
id: 'usage-002',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
model: 'gpt-5.1',
|
||||
input_tokens: 2000,
|
||||
output_tokens: 500,
|
||||
total_tokens: 2500,
|
||||
|
||||
@@ -403,12 +403,12 @@ function getUsageRecords() {
|
||||
return cachedUsageRecords
|
||||
}
|
||||
|
||||
// Mock 别名数据
|
||||
// Mock 映射数据
|
||||
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-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-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-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-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-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: '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-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
|
||||
@@ -1000,17 +1000,11 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
display_name: m.display_name,
|
||||
description: m.description,
|
||||
icon_url: null,
|
||||
is_active: m.is_active,
|
||||
default_tiered_pricing: m.default_tiered_pricing,
|
||||
default_price_per_request: null,
|
||||
default_supports_vision: m.default_supports_vision,
|
||||
default_supports_function_calling: m.default_supports_function_calling,
|
||||
default_supports_streaming: m.default_supports_streaming,
|
||||
default_supports_extended_thinking: m.default_supports_extended_thinking || false,
|
||||
default_supports_image_generation: false,
|
||||
supported_capabilities: null
|
||||
default_price_per_request: m.default_price_per_request,
|
||||
supported_capabilities: m.supported_capabilities,
|
||||
config: m.config
|
||||
})),
|
||||
total: MOCK_GLOBAL_MODELS.length
|
||||
})
|
||||
@@ -1688,7 +1682,7 @@ registerDynamicRoute('GET', '/api/admin/models/mappings/:mappingId', async (_con
|
||||
requireAdmin()
|
||||
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
||||
if (!alias) {
|
||||
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
|
||||
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
|
||||
}
|
||||
return createMockResponse(alias)
|
||||
})
|
||||
@@ -1699,7 +1693,7 @@ registerDynamicRoute('PATCH', '/api/admin/models/mappings/:mappingId', async (co
|
||||
requireAdmin()
|
||||
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
||||
if (!alias) {
|
||||
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
|
||||
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
|
||||
}
|
||||
const body = JSON.parse(config.data || '{}')
|
||||
return createMockResponse({ ...alias, ...body, updated_at: new Date().toISOString() })
|
||||
@@ -1711,7 +1705,7 @@ registerDynamicRoute('DELETE', '/api/admin/models/mappings/:mappingId', async (_
|
||||
requireAdmin()
|
||||
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
||||
if (!alias) {
|
||||
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
|
||||
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
|
||||
}
|
||||
return createMockResponse({ message: '删除成功(演示模式)' })
|
||||
})
|
||||
@@ -2178,10 +2172,10 @@ function generateIntervalTimelineData(
|
||||
|
||||
// 模型列表(用于按模型区分颜色)
|
||||
const models = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-opus-4-20250514'
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-haiku-4-5-20251001',
|
||||
'claude-opus-4-5-20251101',
|
||||
'gpt-5.1'
|
||||
]
|
||||
|
||||
// 生成模拟的请求间隔数据
|
||||
|
||||
@@ -91,11 +91,6 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ModelManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModelManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'aliases',
|
||||
name: 'AliasManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/AliasManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'health-monitor',
|
||||
name: 'HealthMonitor',
|
||||
@@ -111,6 +106,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'SystemSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'AuditLogs',
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
try {
|
||||
users.value = await usersApi.getAllUsers()
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '获取用户列表失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取用户列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
users.value.push(newUser)
|
||||
return newUser
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '创建用户失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '创建用户失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -52,7 +52,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
}
|
||||
return updatedUser
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '更新用户失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '更新用户失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -67,7 +67,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
await usersApi.deleteUser(userId)
|
||||
users.value = users.value.filter(u => u.id !== userId)
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '删除用户失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '删除用户失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -78,7 +78,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
try {
|
||||
return await usersApi.getUserApiKeys(userId)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
try {
|
||||
return await usersApi.createApiKey(userId, name)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
try {
|
||||
await usersApi.deleteApiKey(userId, keyId)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
// 刷新用户列表以获取最新数据
|
||||
await fetchUsers()
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '重置配额失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '重置配额失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -1169,4 +1169,33 @@ body[theme-mode='dark'] .literary-annotation {
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
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')
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 搜索和过滤区域 -->
|
||||
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<h3 class="text-sm sm:text-base font-semibold shrink-0">
|
||||
别名管理
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="alias-search"
|
||||
v-model="aliasesSearch"
|
||||
placeholder="搜索别名或关联模型"
|
||||
class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm border-border/60 focus-visible:ring-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 提供商过滤器 -->
|
||||
<Select
|
||||
v-model:open="aliasProviderSelectOpen"
|
||||
:model-value="aliasProviderFilter"
|
||||
@update:model-value="aliasProviderFilter = $event"
|
||||
>
|
||||
<SelectTrigger class="w-28 sm:w-40 h-8 text-xs border-border/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部别名
|
||||
</SelectItem>
|
||||
<SelectItem value="global">
|
||||
仅全局别名
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
:value="provider.id"
|
||||
>
|
||||
{{ provider.display_name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="新建别名"
|
||||
@click="openCreateAliasDialog"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="loadingAliases"
|
||||
@click="loadAliases"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="loadingAliases"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2 class="w-10 h-10 animate-spin text-primary" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<Table class="hidden xl:table text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[200px]">
|
||||
别名
|
||||
</TableHead>
|
||||
<TableHead class="w-[280px]">
|
||||
关联模型
|
||||
</TableHead>
|
||||
<TableHead class="w-[70px] text-center">
|
||||
类型
|
||||
</TableHead>
|
||||
<TableHead class="w-[100px] text-center">
|
||||
作用域
|
||||
</TableHead>
|
||||
<TableHead class="w-[70px] text-center">
|
||||
状态
|
||||
</TableHead>
|
||||
<TableHead class="w-[100px] text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="filteredAliases.length === 0">
|
||||
<TableCell
|
||||
colspan="6"
|
||||
class="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{{ aliasProviderFilter === 'global' ? '暂无全局别名' : '暂无别名' }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="alias in paginatedAliases"
|
||||
:key="alias.id"
|
||||
>
|
||||
<TableCell>
|
||||
<span class="font-mono font-medium">{{ alias.alias }}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium">{{ alias.global_model_display_name || alias.global_model_name }}</span>
|
||||
<span class="text-xs text-muted-foreground font-mono">{{ alias.global_model_name }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge
|
||||
v-if="alias.provider_id"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ alias.provider_name || 'Provider 特定' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="default"
|
||||
class="text-xs"
|
||||
>
|
||||
全局
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge
|
||||
:variant="alias.is_active ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ alias.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑别名"
|
||||
@click="openEditAliasDialog(alias)"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:title="alias.is_active ? '停用别名' : '启用别名'"
|
||||
@click="toggleAliasStatus(alias)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除别名"
|
||||
@click="confirmDeleteAlias(alias)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div
|
||||
v-if="filteredAliases.length > 0"
|
||||
class="xl:hidden divide-y divide-border/40"
|
||||
>
|
||||
<div
|
||||
v-for="alias in paginatedAliases"
|
||||
:key="alias.id"
|
||||
class="p-4 space-y-2"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono font-medium truncate">{{ alias.alias }}</span>
|
||||
<Badge
|
||||
:variant="alias.is_active ? 'default' : 'secondary'"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ alias.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
<span class="font-medium">{{ alias.global_model_display_name || alias.global_model_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="openEditAliasDialog(alias)"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="toggleAliasStatus(alias)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="confirmDeleteAlias(alias)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="alias.provider_id"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ alias.provider_name || 'Provider 特定' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="default"
|
||||
class="text-xs"
|
||||
>
|
||||
全局
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="!loadingAliases && filteredAliases.length > 0"
|
||||
:current="aliasesCurrentPage"
|
||||
:total="filteredAliases.length"
|
||||
:page-size="aliasesPageSize"
|
||||
@update:current="aliasesCurrentPage = $event"
|
||||
@update:page-size="aliasesPageSize = $event"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 创建/编辑别名对话框 -->
|
||||
<AliasDialog
|
||||
:open="createAliasDialogOpen"
|
||||
:editing-alias="editingAlias"
|
||||
:global-models="globalModels"
|
||||
:providers="providers"
|
||||
@update:open="handleAliasDialogUpdate"
|
||||
@submit="handleAliasSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Edit,
|
||||
Loader2,
|
||||
Plus,
|
||||
Power,
|
||||
Search,
|
||||
Trash2
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Badge,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
RefreshButton,
|
||||
Pagination
|
||||
} from '@/components/ui'
|
||||
import AliasDialog from '@/features/models/components/AliasDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import {
|
||||
getAliases,
|
||||
createAlias,
|
||||
updateAlias,
|
||||
deleteAlias,
|
||||
type ModelAlias,
|
||||
type CreateModelAliasRequest,
|
||||
type UpdateModelAliasRequest
|
||||
} from '@/api/endpoints/aliases'
|
||||
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
|
||||
// 状态
|
||||
const loadingAliases = ref(false)
|
||||
const submitting = ref(false)
|
||||
const aliasesSearch = ref('')
|
||||
const aliasProviderFilter = ref<string>('all')
|
||||
const aliasProviderSelectOpen = ref(false)
|
||||
const createAliasDialogOpen = ref(false)
|
||||
const editingAliasId = ref<string | null>(null)
|
||||
|
||||
// 数据
|
||||
const allAliases = ref<ModelAlias[]>([])
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const providers = ref<any[]>([])
|
||||
|
||||
// 分页
|
||||
const aliasesCurrentPage = ref(1)
|
||||
const aliasesPageSize = ref(20)
|
||||
|
||||
// 编辑中的别名对象
|
||||
const editingAlias = computed(() => {
|
||||
if (!editingAliasId.value) return null
|
||||
return allAliases.value.find(a => a.id === editingAliasId.value) || null
|
||||
})
|
||||
|
||||
// 筛选后的别名列表
|
||||
const filteredAliases = computed(() => {
|
||||
let result = allAliases.value
|
||||
|
||||
// 按 Provider 筛选
|
||||
if (aliasProviderFilter.value === 'global') {
|
||||
result = result.filter(alias => !alias.provider_id)
|
||||
} else if (aliasProviderFilter.value !== 'all') {
|
||||
result = result.filter(alias => alias.provider_id === aliasProviderFilter.value)
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选
|
||||
const keyword = aliasesSearch.value.trim().toLowerCase()
|
||||
if (keyword) {
|
||||
result = result.filter(alias =>
|
||||
alias.alias.toLowerCase().includes(keyword) ||
|
||||
alias.global_model_name?.toLowerCase().includes(keyword) ||
|
||||
alias.global_model_display_name?.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 分页计算
|
||||
const paginatedAliases = computed(() => {
|
||||
const start = (aliasesCurrentPage.value - 1) * aliasesPageSize.value
|
||||
const end = start + aliasesPageSize.value
|
||||
return filteredAliases.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 搜索或筛选变化时重置到第一页
|
||||
watch([aliasesSearch, aliasProviderFilter], () => {
|
||||
aliasesCurrentPage.value = 1
|
||||
})
|
||||
|
||||
async function loadAliases() {
|
||||
loadingAliases.value = true
|
||||
try {
|
||||
allAliases.value = await getAliases({ limit: 1000 })
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '加载别名失败')
|
||||
} finally {
|
||||
loadingAliases.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGlobalModelsList() {
|
||||
try {
|
||||
const response = await listGlobalModels()
|
||||
globalModels.value = response.models || []
|
||||
} catch (err: any) {
|
||||
log.error('加载模型失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
providers.value = await getProvidersSummary()
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '加载 Provider 列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateAliasDialog() {
|
||||
editingAliasId.value = null
|
||||
createAliasDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEditAliasDialog(alias: ModelAlias) {
|
||||
editingAliasId.value = alias.id
|
||||
createAliasDialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleAliasDialogUpdate(value: boolean) {
|
||||
createAliasDialogOpen.value = value
|
||||
if (!value) {
|
||||
editingAliasId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAliasSubmit(data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean) {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit && editingAliasId.value) {
|
||||
await updateAlias(editingAliasId.value, data as UpdateModelAliasRequest)
|
||||
success(data.mapping_type === 'mapping' ? '映射已更新' : '别名已更新')
|
||||
} else {
|
||||
await createAlias(data as CreateModelAliasRequest)
|
||||
success(data.mapping_type === 'mapping' ? '映射已创建' : '别名已创建')
|
||||
}
|
||||
createAliasDialogOpen.value = false
|
||||
editingAliasId.value = null
|
||||
await loadAliases()
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail || err.message
|
||||
let errorMessage = detail
|
||||
if (detail === '映射已存在') {
|
||||
errorMessage = '目标作用域已存在同名别名,请先删除冲突的映射或选择其他作用域'
|
||||
}
|
||||
showError(errorMessage, isEdit ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteAlias(alias: ModelAlias) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除别名 "${alias.alias}" 吗?`,
|
||||
'删除别名'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await deleteAlias(alias.id)
|
||||
success('别名已删除')
|
||||
await loadAliases()
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAliasStatus(alias: ModelAlias) {
|
||||
try {
|
||||
await updateAlias(alias.id, { is_active: !alias.is_active })
|
||||
alias.is_active = !alias.is_active
|
||||
success(alias.is_active ? '别名已启用' : '别名已停用')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadAliases(),
|
||||
loadGlobalModelsList(),
|
||||
loadProviders()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
@@ -650,6 +650,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
||||
|
||||
import {
|
||||
@@ -693,6 +694,7 @@ import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const apiKeys = ref<AdminApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -751,15 +753,13 @@ const expiringSoonCount = computed(() => apiKeys.value.filter(key => isExpiringS
|
||||
const filteredApiKeys = computed(() => {
|
||||
let result = apiKeys.value
|
||||
|
||||
// 搜索筛选
|
||||
// 搜索筛选(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(key =>
|
||||
(key.name && key.name.toLowerCase().includes(query)) ||
|
||||
(key.key_display && key.key_display.toLowerCase().includes(query)) ||
|
||||
(key.username && key.username.toLowerCase().includes(query)) ||
|
||||
(key.user_email && key.user_email.toLowerCase().includes(query))
|
||||
)
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
result = result.filter(key => {
|
||||
const searchableText = `${key.name || ''} ${key.key_display || ''} ${key.username || ''} ${key.user_email || ''}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
@@ -929,20 +929,14 @@ function selectKey() {
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newKeyValue.value)
|
||||
success('API Key 已复制到剪贴板')
|
||||
} catch {
|
||||
error('复制失败,请手动复制')
|
||||
}
|
||||
await copyToClipboard(newKeyValue.value)
|
||||
}
|
||||
|
||||
async function copyKeyPrefix(apiKey: AdminApiKey) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
await navigator.clipboard.writeText(response.key)
|
||||
success('完整密钥已复制到剪贴板')
|
||||
await copyToClipboard(response.key)
|
||||
} catch (err) {
|
||||
log.error('复制密钥失败:', err)
|
||||
error('复制失败,请重试')
|
||||
@@ -1048,9 +1042,10 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
rate_limit: data.rate_limit,
|
||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
|
||||
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
|
||||
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
|
||||
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_models: data.allowed_models
|
||||
}
|
||||
await adminApi.updateApiKey(data.id, updateData)
|
||||
success('API Key 更新成功')
|
||||
@@ -1066,9 +1061,10 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
rate_limit: data.rate_limit,
|
||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
|
||||
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
|
||||
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
|
||||
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_models: data.allowed_models
|
||||
}
|
||||
const response = await adminApi.createStandaloneApiKey(createData)
|
||||
newKeyValue.value = response.key
|
||||
|
||||
@@ -18,10 +18,10 @@ import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import ScatterChart from '@/components/charts/ScatterChart.vue'
|
||||
import { Trash2, Eraser, Search, X, BarChart3, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||
import { Trash2, Eraser, Search, X, BarChart3, ChevronDown, ChevronRight, Database, ArrowRight } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { cacheApi, type CacheStats, type CacheConfig, type UserAffinity } from '@/api/cache'
|
||||
import { cacheApi, modelMappingCacheApi, type CacheStats, type CacheConfig, type UserAffinity, type ModelMappingCacheStats } from '@/api/cache'
|
||||
import type { TTLAnalysisUser } from '@/api/cache'
|
||||
import { formatNumber, formatTokens, formatCost, formatRemainingTime } from '@/utils/format'
|
||||
import {
|
||||
@@ -46,6 +46,14 @@ const clearingRowAffinityKey = ref<string | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const currentTime = ref(Math.floor(Date.now() / 1000))
|
||||
const analysisHoursSelectOpen = ref(false)
|
||||
|
||||
// ==================== 模型映射缓存 ====================
|
||||
|
||||
const modelMappingStats = ref<ModelMappingCacheStats | null>(null)
|
||||
const modelMappingLoading = ref(false)
|
||||
const clearingModelMapping = ref(false)
|
||||
const clearingModelName = ref<string | null>(null)
|
||||
|
||||
const { success: showSuccess, error: showError, info: showInfo } = useToast()
|
||||
const { confirm: showConfirm } = useConfirm()
|
||||
@@ -135,32 +143,37 @@ async function resetAffinitySearch() {
|
||||
await fetchAffinityList()
|
||||
}
|
||||
|
||||
async function clearUserCache(identifier: string, displayName?: string) {
|
||||
const target = identifier?.trim()
|
||||
if (!target) {
|
||||
showError('无法识别标识符')
|
||||
async function clearSingleAffinity(item: UserAffinity) {
|
||||
const affinityKey = item.affinity_key?.trim()
|
||||
const endpointId = item.endpoint_id?.trim()
|
||||
const modelId = item.global_model_id?.trim()
|
||||
const apiFormat = item.api_format?.trim()
|
||||
|
||||
if (!affinityKey || !endpointId || !modelId || !apiFormat) {
|
||||
showError('缓存记录信息不完整,无法删除')
|
||||
return
|
||||
}
|
||||
|
||||
const label = displayName || target
|
||||
const label = item.user_api_key_name || affinityKey
|
||||
const modelLabel = item.model_display_name || item.model_name || modelId
|
||||
const confirmed = await showConfirm({
|
||||
title: '确认清除',
|
||||
message: `确定要清除 ${label} 的缓存吗?`,
|
||||
message: `确定要清除 ${label} 在模型 ${modelLabel} 上的缓存亲和性吗?`,
|
||||
confirmText: '确认清除',
|
||||
variant: 'destructive'
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
clearingRowAffinityKey.value = target
|
||||
clearingRowAffinityKey.value = affinityKey
|
||||
try {
|
||||
await cacheApi.clearUserCache(target)
|
||||
await cacheApi.clearSingleAffinity(affinityKey, endpointId, modelId, apiFormat)
|
||||
showSuccess('清除成功')
|
||||
await fetchCacheStats()
|
||||
await fetchAffinityList(tableKeyword.value.trim() || undefined)
|
||||
} catch (error) {
|
||||
showError('清除失败')
|
||||
log.error('清除用户缓存失败', error)
|
||||
log.error('清除单条缓存失败', error)
|
||||
} finally {
|
||||
clearingRowAffinityKey.value = null
|
||||
}
|
||||
@@ -241,13 +254,107 @@ function stopCountdown() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模型映射缓存方法 ====================
|
||||
|
||||
async function fetchModelMappingStats() {
|
||||
modelMappingLoading.value = true
|
||||
try {
|
||||
modelMappingStats.value = await modelMappingCacheApi.getStats()
|
||||
} catch (error) {
|
||||
showError('获取模型映射缓存统计失败')
|
||||
log.error('获取模型映射缓存统计失败', error)
|
||||
} finally {
|
||||
modelMappingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllModelMappingCache() {
|
||||
const confirmed = await showConfirm({
|
||||
title: '确认清除',
|
||||
message: '确定要清除所有模型映射缓存吗?这会影响所有模型的名称解析。',
|
||||
confirmText: '确认清除',
|
||||
variant: 'destructive'
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
clearingModelMapping.value = true
|
||||
try {
|
||||
const result = await modelMappingCacheApi.clearAll()
|
||||
showSuccess(`已清除 ${result.deleted_count} 个缓存键`)
|
||||
await fetchModelMappingStats()
|
||||
} catch (error) {
|
||||
showError('清除模型映射缓存失败')
|
||||
log.error('清除模型映射缓存失败', error)
|
||||
} finally {
|
||||
clearingModelMapping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearModelMappingByName(modelName: string) {
|
||||
clearingModelName.value = modelName
|
||||
try {
|
||||
await modelMappingCacheApi.clearByName(modelName)
|
||||
showSuccess(`已清除 ${modelName} 的映射缓存`)
|
||||
await fetchModelMappingStats()
|
||||
} catch (error) {
|
||||
showError('清除缓存失败')
|
||||
log.error('清除模型映射缓存失败', error)
|
||||
} finally {
|
||||
clearingModelName.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function clearProviderModelMapping(providerId: string, globalModelId: string, displayName?: string) {
|
||||
const confirmed = await showConfirm({
|
||||
title: '确认清除',
|
||||
message: `确定要清除 ${displayName || 'Provider 模型映射'} 的缓存吗?`,
|
||||
confirmText: '确认清除',
|
||||
variant: 'destructive'
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await modelMappingCacheApi.clearProviderModel(providerId, globalModelId)
|
||||
showSuccess('已清除 Provider 模型映射缓存')
|
||||
await fetchModelMappingStats()
|
||||
} catch (error) {
|
||||
showError('清除缓存失败')
|
||||
log.error('清除 Provider 模型映射缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTTL(ttl: number | null): string {
|
||||
if (ttl === null || ttl < 0) return '-'
|
||||
if (ttl < 60) return `${ttl}s`
|
||||
const minutes = Math.floor(ttl / 60)
|
||||
const seconds = ttl % 60
|
||||
if (seconds === 0) return `${minutes}m`
|
||||
return `${minutes}m${seconds}s`
|
||||
}
|
||||
|
||||
function getUnmappedStatusBadge(status: string): { variant: 'default' | 'secondary' | 'destructive' | 'outline', text: string } {
|
||||
switch (status) {
|
||||
case 'not_found':
|
||||
return { variant: 'secondary', text: '未找到' }
|
||||
case 'invalid':
|
||||
return { variant: 'destructive', text: '无效' }
|
||||
case 'error':
|
||||
return { variant: 'destructive', text: '错误' }
|
||||
default:
|
||||
return { variant: 'outline', text: status }
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 刷新所有数据 ====================
|
||||
|
||||
async function refreshData() {
|
||||
await Promise.all([
|
||||
fetchCacheStats(),
|
||||
fetchCacheConfig(),
|
||||
fetchAffinityList()
|
||||
fetchAffinityList(),
|
||||
fetchModelMappingStats()
|
||||
])
|
||||
}
|
||||
|
||||
@@ -272,6 +379,7 @@ onMounted(() => {
|
||||
fetchCacheStats()
|
||||
fetchCacheConfig()
|
||||
fetchAffinityList()
|
||||
fetchModelMappingStats()
|
||||
startCountdown()
|
||||
refreshAnalysis()
|
||||
})
|
||||
@@ -516,7 +624,7 @@ onBeforeUnmount(() => {
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
|
||||
:disabled="clearingRowAffinityKey === item.affinity_key"
|
||||
title="清除缓存"
|
||||
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
|
||||
@click="clearSingleAffinity(item)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -566,7 +674,7 @@ onBeforeUnmount(() => {
|
||||
variant="ghost"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive shrink-0"
|
||||
:disabled="clearingRowAffinityKey === item.affinity_key"
|
||||
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
|
||||
@click="clearSingleAffinity(item)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -599,6 +707,344 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 模型映射缓存管理 -->
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<Database class="h-5 w-5 text-muted-foreground hidden sm:block" />
|
||||
<h3 class="text-sm sm:text-base font-semibold">
|
||||
模型映射缓存
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground/70 hover:text-destructive"
|
||||
title="清除全部映射缓存"
|
||||
:disabled="clearingModelMapping || !modelMappingStats?.available"
|
||||
@click="clearAllModelMappingCache"
|
||||
>
|
||||
<Eraser class="h-4 w-4" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="modelMappingLoading"
|
||||
@click="fetchModelMappingStats"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 映射缓存表格 -->
|
||||
<Table
|
||||
v-if="modelMappingStats?.available && modelMappingStats.mappings && modelMappingStats.mappings.length > 0"
|
||||
class="hidden md:table"
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[25%]">
|
||||
全局模型
|
||||
</TableHead>
|
||||
<TableHead class="w-8 text-center" />
|
||||
<TableHead class="w-[30%]">
|
||||
映射模型
|
||||
</TableHead>
|
||||
<TableHead class="w-[25%]">
|
||||
提供商
|
||||
</TableHead>
|
||||
<TableHead class="w-[10%] text-center">
|
||||
剩余
|
||||
</TableHead>
|
||||
<TableHead class="w-[5%] text-right">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="mapping in modelMappingStats.mappings"
|
||||
:key="mapping.mapping_name"
|
||||
>
|
||||
<TableCell>
|
||||
<div v-if="mapping.global_model_name">
|
||||
<div class="text-sm font-medium">
|
||||
{{ mapping.global_model_display_name || mapping.global_model_name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="mapping.global_model_display_name && mapping.global_model_display_name !== mapping.global_model_name"
|
||||
class="text-xs text-muted-foreground font-mono"
|
||||
>
|
||||
{{ mapping.global_model_name }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm text-muted-foreground"
|
||||
>-</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<ArrowRight class="h-4 w-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm font-mono">{{ mapping.mapping_name }}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
v-if="mapping.providers && mapping.providers.length > 0"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<Badge
|
||||
v-for="provider in mapping.providers.slice(0, 3)"
|
||||
:key="provider"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ provider }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="mapping.providers.length > 3"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
+{{ mapping.providers.length - 3 }}
|
||||
</Badge>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm text-muted-foreground"
|
||||
>-</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<span class="text-xs text-muted-foreground">{{ formatTTL(mapping.ttl) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-6 w-6 text-muted-foreground/50 hover:text-destructive"
|
||||
:disabled="clearingModelName === mapping.mapping_name"
|
||||
title="清除缓存"
|
||||
@click="clearModelMappingByName(mapping.mapping_name)"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div
|
||||
v-if="modelMappingStats?.available && modelMappingStats.mappings && modelMappingStats.mappings.length > 0"
|
||||
class="md:hidden divide-y divide-border/40"
|
||||
>
|
||||
<div
|
||||
v-for="mapping in modelMappingStats.mappings"
|
||||
:key="`m-${mapping.mapping_name}`"
|
||||
class="p-4 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium truncate">{{ mapping.global_model_display_name || mapping.global_model_name || '-' }}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-6 w-6 text-muted-foreground/50 hover:text-destructive shrink-0"
|
||||
:disabled="clearingModelName === mapping.mapping_name"
|
||||
@click="clearModelMappingByName(mapping.mapping_name)"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ArrowRight class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="font-mono">{{ mapping.mapping_name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="mapping.providers && mapping.providers.length > 0"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<Badge
|
||||
v-for="provider in mapping.providers"
|
||||
:key="provider"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ provider }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未映射条目(NOT_FOUND 等) -->
|
||||
<div
|
||||
v-if="modelMappingStats?.available && modelMappingStats.unmapped && modelMappingStats.unmapped.length > 0"
|
||||
class="px-6 py-4 border-t border-border/40"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground mb-2">
|
||||
未映射的缓存条目
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="entry in modelMappingStats.unmapped"
|
||||
:key="entry.mapping_name"
|
||||
:variant="getUnmappedStatusBadge(entry.status).variant"
|
||||
class="text-xs font-mono cursor-pointer"
|
||||
:title="`${getUnmappedStatusBadge(entry.status).text} - 点击清除`"
|
||||
@click="clearModelMappingByName(entry.mapping_name)"
|
||||
>
|
||||
{{ entry.mapping_name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider 模型映射缓存 -->
|
||||
<div
|
||||
v-if="modelMappingStats?.available && modelMappingStats.provider_model_mappings && modelMappingStats.provider_model_mappings.length > 0"
|
||||
class="border-t border-border/40"
|
||||
>
|
||||
<div class="px-6 py-3 text-xs text-muted-foreground border-b border-border/30 bg-muted/20">
|
||||
Provider 模型映射缓存
|
||||
</div>
|
||||
<!-- 桌面端表格 -->
|
||||
<Table class="hidden md:table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[15%]">
|
||||
提供商
|
||||
</TableHead>
|
||||
<TableHead class="w-[25%]">
|
||||
请求名称
|
||||
</TableHead>
|
||||
<TableHead class="w-8 text-center" />
|
||||
<TableHead class="w-[25%]">
|
||||
映射模型
|
||||
</TableHead>
|
||||
<TableHead class="w-[10%] text-center">
|
||||
剩余
|
||||
</TableHead>
|
||||
<TableHead class="w-[10%] text-center">
|
||||
次数
|
||||
</TableHead>
|
||||
<TableHead class="w-[7%] text-right">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template
|
||||
v-for="(mapping, index) in modelMappingStats.provider_model_mappings"
|
||||
:key="index"
|
||||
>
|
||||
<TableRow
|
||||
v-for="(alias, aliasIndex) in (mapping.aliases || [])"
|
||||
:key="`${index}-${aliasIndex}`"
|
||||
>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ mapping.provider_name }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm font-mono">{{ alias }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<ArrowRight class="h-4 w-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm font-mono font-medium">{{ mapping.provider_model_name }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<span class="text-xs text-muted-foreground">{{ formatTTL(mapping.ttl) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<span class="text-sm">{{ mapping.hit_count || 0 }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
|
||||
title="清除缓存"
|
||||
@click="clearProviderModelMapping(mapping.provider_id, mapping.global_model_id, `${mapping.provider_name} - ${alias}`)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<!-- 移动端卡片 -->
|
||||
<div class="md:hidden divide-y divide-border/40">
|
||||
<template
|
||||
v-for="(mapping, index) in modelMappingStats.provider_model_mappings"
|
||||
:key="`m-pm-${index}`"
|
||||
>
|
||||
<div
|
||||
v-for="(alias, aliasIndex) in (mapping.aliases || [])"
|
||||
:key="`m-pm-${index}-${aliasIndex}`"
|
||||
class="p-4 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ mapping.provider_name }}
|
||||
</Badge>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ formatTTL(mapping.ttl) }}</span>
|
||||
<span class="text-xs">{{ mapping.hit_count || 0 }}次</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-6 w-6 text-muted-foreground/70 hover:text-destructive"
|
||||
title="清除缓存"
|
||||
@click="clearProviderModelMapping(mapping.provider_id, mapping.global_model_id, `${mapping.provider_name} - ${alias}`)"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="font-mono">{{ alias }}</span>
|
||||
<ArrowRight class="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
||||
<span class="font-mono font-medium">{{ mapping.provider_model_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无缓存状态 -->
|
||||
<div
|
||||
v-else-if="modelMappingStats?.available && (!modelMappingStats.mappings || modelMappingStats.mappings.length === 0) && (!modelMappingStats.unmapped || modelMappingStats.unmapped.length === 0) && (!modelMappingStats.provider_model_mappings || modelMappingStats.provider_model_mappings.length === 0)"
|
||||
class="px-6 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无模型解析缓存
|
||||
</div>
|
||||
|
||||
<!-- Redis 未启用 -->
|
||||
<div
|
||||
v-else-if="modelMappingStats && !modelMappingStats.available"
|
||||
class="px-6 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ modelMappingStats.message || 'Redis 未启用' }}
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div
|
||||
v-else-if="modelMappingLoading"
|
||||
class="px-6 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
加载中...
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- TTL 分析区域 -->
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
|
||||
@@ -611,7 +1057,10 @@ onBeforeUnmount(() => {
|
||||
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔,推荐合适的缓存 TTL</span>
|
||||
</div>
|
||||
<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">
|
||||
<SelectValue placeholder="时间段" />
|
||||
</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>
|
||||
@@ -111,9 +111,6 @@
|
||||
<TableHead class="w-[80px] text-center">
|
||||
提供商
|
||||
</TableHead>
|
||||
<TableHead class="w-[70px] text-center">
|
||||
别名/映射
|
||||
</TableHead>
|
||||
<TableHead class="w-[80px] text-center">
|
||||
调用次数
|
||||
</TableHead>
|
||||
@@ -128,7 +125,7 @@
|
||||
<TableBody>
|
||||
<TableRow v-if="loading">
|
||||
<TableCell
|
||||
colspan="8"
|
||||
colspan="7"
|
||||
class="text-center py-8"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
|
||||
@@ -136,7 +133,7 @@
|
||||
</TableRow>
|
||||
<TableRow v-else-if="filteredGlobalModels.length === 0">
|
||||
<TableCell
|
||||
colspan="8"
|
||||
colspan="7"
|
||||
class="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
没有找到匹配的模型
|
||||
@@ -171,27 +168,27 @@
|
||||
<div class="space-y-1 w-fit">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Zap
|
||||
v-if="model.default_supports_streaming"
|
||||
v-if="model.config?.streaming !== false"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="流式输出"
|
||||
/>
|
||||
<Image
|
||||
v-if="model.default_supports_image_generation"
|
||||
v-if="model.config?.image_generation === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="图像生成"
|
||||
/>
|
||||
<Eye
|
||||
v-if="model.default_supports_vision"
|
||||
v-if="model.config?.vision === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="视觉理解"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="model.default_supports_function_calling"
|
||||
v-if="model.config?.function_calling === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="工具调用"
|
||||
/>
|
||||
<Brain
|
||||
v-if="model.default_supports_extended_thinking"
|
||||
v-if="model.config?.extended_thinking === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="深度思考"
|
||||
/>
|
||||
@@ -244,11 +241,6 @@
|
||||
{{ model.provider_count || 0 }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="secondary">
|
||||
{{ model.alias_count || 0 }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<span class="text-sm font-mono">{{ formatUsageCount(model.usage_count || 0) }}</span>
|
||||
</TableCell>
|
||||
@@ -369,23 +361,23 @@
|
||||
<!-- 第二行:能力图标 -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Zap
|
||||
v-if="model.default_supports_streaming"
|
||||
v-if="model.config?.streaming !== false"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Image
|
||||
v-if="model.default_supports_image_generation"
|
||||
v-if="model.config?.image_generation === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Eye
|
||||
v-if="model.default_supports_vision"
|
||||
v-if="model.config?.vision === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="model.default_supports_function_calling"
|
||||
v-if="model.config?.function_calling === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Brain
|
||||
v-if="model.default_supports_extended_thinking"
|
||||
v-if="model.config?.extended_thinking === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -393,7 +385,6 @@
|
||||
<!-- 第三行:统计信息 -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>提供商 {{ model.provider_count || 0 }}</span>
|
||||
<span>别名 {{ model.alias_count || 0 }}</span>
|
||||
<span>调用 {{ formatUsageCount(model.usage_count || 0) }}</span>
|
||||
<span
|
||||
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
|
||||
@@ -425,25 +416,12 @@
|
||||
@success="handleModelFormSuccess"
|
||||
/>
|
||||
|
||||
<!-- 创建/编辑别名/映射对话框 -->
|
||||
<AliasDialog
|
||||
:open="createAliasDialogOpen"
|
||||
:editing-alias="editingAlias"
|
||||
:global-models="globalModels"
|
||||
:providers="providers"
|
||||
:fixed-target-model="isTargetModelFixed ? selectedModel : null"
|
||||
@update:open="handleAliasDialogUpdate"
|
||||
@submit="handleAliasSubmit"
|
||||
/>
|
||||
|
||||
<!-- 模型详情抽屉 -->
|
||||
<ModelDetailDrawer
|
||||
:model="selectedModel"
|
||||
:open="!!selectedModel"
|
||||
:providers="selectedModelProviders"
|
||||
:aliases="selectedModelAliases"
|
||||
:loading-providers="loadingModelProviders"
|
||||
:loading-aliases="loadingModelAliases"
|
||||
:has-blocking-dialog-open="hasBlockingDialogOpen"
|
||||
:capabilities="capabilities"
|
||||
@update:open="handleDrawerOpenChange"
|
||||
@@ -454,11 +432,6 @@
|
||||
@delete-provider="confirmDeleteProviderImplementation"
|
||||
@toggle-provider-status="toggleProviderStatus"
|
||||
@refresh-providers="refreshSelectedModelProviders"
|
||||
@add-alias="openAddAliasDialog"
|
||||
@edit-alias="openEditAliasDialog"
|
||||
@toggle-alias-status="toggleAliasStatusFromDrawer"
|
||||
@delete-alias="confirmDeleteAliasFromDrawer"
|
||||
@refresh-aliases="refreshSelectedModelAliases"
|
||||
/>
|
||||
|
||||
<!-- 批量添加关联提供商对话框 -->
|
||||
@@ -736,12 +709,11 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue'
|
||||
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
|
||||
import AliasDialog from '@/features/models/components/AliasDialog.vue'
|
||||
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
|
||||
import type { CreateModelAliasRequest, UpdateModelAliasRequest } from '@/api/endpoints/aliases'
|
||||
import type { Model } from '@/api/endpoints'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
@@ -765,20 +737,15 @@ import {
|
||||
updateGlobalModel,
|
||||
deleteGlobalModel,
|
||||
batchAssignToProviders,
|
||||
getGlobalModelProviders,
|
||||
type GlobalModelResponse,
|
||||
} from '@/api/global-models'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
getAliases,
|
||||
createAlias,
|
||||
updateAlias,
|
||||
deleteAlias,
|
||||
type ModelAlias,
|
||||
} from '@/api/endpoints/aliases'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -788,13 +755,9 @@ const searchQuery = ref('')
|
||||
const selectedModel = ref<GlobalModelResponse | null>(null)
|
||||
const createModelDialogOpen = ref(false)
|
||||
const editingModel = ref<GlobalModelResponse | null>(null)
|
||||
const createAliasDialogOpen = ref(false)
|
||||
const editingAliasId = ref<string | null>(null)
|
||||
const isTargetModelFixed = ref(false) // 目标模型是否固定(从模型详情抽屉打开时为 true)
|
||||
|
||||
// 数据
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const allAliases = ref<ModelAlias[]>([])
|
||||
const providers = ref<any[]>([])
|
||||
const capabilities = ref<CapabilityDefinition[]>([])
|
||||
|
||||
@@ -804,9 +767,7 @@ const catalogPageSize = ref(20)
|
||||
|
||||
// 选中模型的详细数据
|
||||
const selectedModelProviders = ref<any[]>([])
|
||||
const selectedModelAliases = ref<ModelAlias[]>([])
|
||||
const loadingModelProviders = ref(false)
|
||||
const loadingModelAliases = ref(false)
|
||||
|
||||
// 批量添加关联提供商
|
||||
const batchAddProvidersDialogOpen = ref(false)
|
||||
@@ -876,19 +837,10 @@ function hasTieredPricing(model: GlobalModelResponse): boolean {
|
||||
// 检测是否有对话框打开(防止误关闭抽屉)
|
||||
const hasBlockingDialogOpen = computed(() =>
|
||||
createModelDialogOpen.value ||
|
||||
createAliasDialogOpen.value ||
|
||||
batchAddProvidersDialogOpen.value ||
|
||||
editProviderDialogOpen.value
|
||||
)
|
||||
|
||||
// 编辑中的别名对象(用于传递给 AliasDialog)
|
||||
const editingAlias = computed(() => {
|
||||
if (!editingAliasId.value) return null
|
||||
return allAliases.value.find(a => a.id === editingAliasId.value) ||
|
||||
selectedModelAliases.value.find(a => a.id === editingAliasId.value) ||
|
||||
null
|
||||
})
|
||||
|
||||
// 能力筛选
|
||||
const capabilityFilters = ref({
|
||||
streaming: false,
|
||||
@@ -1053,30 +1005,30 @@ async function batchRemoveSelectedProviders() {
|
||||
const filteredGlobalModels = computed(() => {
|
||||
let result = globalModels.value
|
||||
|
||||
// 搜索
|
||||
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.display_name?.toLowerCase().includes(query)
|
||||
)
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
result = result.filter(m => {
|
||||
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
// 能力筛选
|
||||
if (capabilityFilters.value.streaming) {
|
||||
result = result.filter(m => m.default_supports_streaming)
|
||||
result = result.filter(m => m.config?.streaming !== false)
|
||||
}
|
||||
if (capabilityFilters.value.imageGeneration) {
|
||||
result = result.filter(m => m.default_supports_image_generation)
|
||||
result = result.filter(m => m.config?.image_generation === true)
|
||||
}
|
||||
if (capabilityFilters.value.vision) {
|
||||
result = result.filter(m => m.default_supports_vision)
|
||||
result = result.filter(m => m.config?.vision === true)
|
||||
}
|
||||
if (capabilityFilters.value.toolUse) {
|
||||
result = result.filter(m => m.default_supports_function_calling)
|
||||
result = result.filter(m => m.config?.function_calling === true)
|
||||
}
|
||||
if (capabilityFilters.value.extendedThinking) {
|
||||
result = result.filter(m => m.default_supports_extended_thinking)
|
||||
result = result.filter(m => m.config?.extended_thinking === true)
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -1117,67 +1069,44 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
|
||||
selectModel(model)
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(model: GlobalModelResponse) {
|
||||
selectedModel.value = model
|
||||
detailTab.value = 'basic'
|
||||
|
||||
// 加载该模型的关联提供商和别名
|
||||
await Promise.all([
|
||||
loadModelProviders(model.id),
|
||||
loadModelAliases(model.id)
|
||||
])
|
||||
// 加载该模型的关联提供商
|
||||
await loadModelProviders(model.id)
|
||||
}
|
||||
|
||||
// 加载指定模型的关联提供商
|
||||
async function loadModelProviders(_globalModelId: string) {
|
||||
loadingModelProviders.value = true
|
||||
try {
|
||||
// 使用 ModelCatalog API 获取详细的关联提供商信息
|
||||
const { getModelCatalog } = await import('@/api/endpoints')
|
||||
const catalogResponse = await getModelCatalog()
|
||||
// 使用新的 API 获取所有关联提供商(包括非活跃的)
|
||||
const response = await getGlobalModelProviders(_globalModelId)
|
||||
|
||||
// 查找当前 GlobalModel 对应的 catalog item
|
||||
const catalogItem = catalogResponse.models.find(
|
||||
m => m.global_model_name === selectedModel.value?.name
|
||||
)
|
||||
|
||||
if (catalogItem) {
|
||||
// 转换为展示格式,包含完整的模型实现信息
|
||||
selectedModelProviders.value = catalogItem.providers.map(p => ({
|
||||
id: p.provider_id,
|
||||
model_id: p.model_id,
|
||||
display_name: p.provider_display_name || p.provider_name,
|
||||
identifier: p.provider_name,
|
||||
provider_type: 'API',
|
||||
target_model: p.target_model,
|
||||
is_active: p.is_active,
|
||||
// 价格信息
|
||||
input_price_per_1m: p.input_price_per_1m,
|
||||
output_price_per_1m: p.output_price_per_1m,
|
||||
cache_creation_price_per_1m: p.cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m: p.cache_read_price_per_1m,
|
||||
cache_1h_creation_price_per_1m: p.cache_1h_creation_price_per_1m,
|
||||
price_per_request: p.price_per_request,
|
||||
effective_tiered_pricing: p.effective_tiered_pricing,
|
||||
tier_count: p.tier_count,
|
||||
// 能力信息
|
||||
supports_vision: p.supports_vision,
|
||||
supports_function_calling: p.supports_function_calling,
|
||||
supports_streaming: p.supports_streaming
|
||||
}))
|
||||
} else {
|
||||
selectedModelProviders.value = []
|
||||
}
|
||||
// 转换为展示格式
|
||||
selectedModelProviders.value = response.providers.map(p => ({
|
||||
id: p.provider_id,
|
||||
model_id: p.model_id,
|
||||
display_name: p.provider_display_name || p.provider_name,
|
||||
identifier: p.provider_name,
|
||||
provider_type: 'API',
|
||||
target_model: p.target_model,
|
||||
is_active: p.is_active,
|
||||
// 价格信息
|
||||
input_price_per_1m: p.input_price_per_1m,
|
||||
output_price_per_1m: p.output_price_per_1m,
|
||||
cache_creation_price_per_1m: p.cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m: p.cache_read_price_per_1m,
|
||||
cache_1h_creation_price_per_1m: p.cache_1h_creation_price_per_1m,
|
||||
price_per_request: p.price_per_request,
|
||||
effective_tiered_pricing: p.effective_tiered_pricing,
|
||||
tier_count: p.tier_count,
|
||||
// 能力信息
|
||||
supports_vision: p.supports_vision,
|
||||
supports_function_calling: p.supports_function_calling,
|
||||
supports_streaming: p.supports_streaming
|
||||
}))
|
||||
} catch (err: any) {
|
||||
log.error('加载关联提供商失败:', err)
|
||||
showError(parseApiError(err, '加载关联提供商失败'), '错误')
|
||||
@@ -1187,27 +1116,6 @@ async function loadModelProviders(_globalModelId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载指定模型的别名
|
||||
async function loadModelAliases(globalModelId: string) {
|
||||
loadingModelAliases.value = true
|
||||
try {
|
||||
const aliases = await getAliases({ limit: 1000 })
|
||||
selectedModelAliases.value = aliases.filter(a => a.global_model_id === globalModelId)
|
||||
} catch (err: any) {
|
||||
log.error('加载别名失败:', err)
|
||||
selectedModelAliases.value = []
|
||||
} finally {
|
||||
loadingModelAliases.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新当前选中模型的别名
|
||||
async function refreshSelectedModelAliases() {
|
||||
if (selectedModel.value) {
|
||||
await loadModelAliases(selectedModel.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新当前选中模型的关联提供商
|
||||
async function refreshSelectedModelProviders() {
|
||||
if (selectedModel.value) {
|
||||
@@ -1329,14 +1237,6 @@ async function confirmDeleteProviderImplementation(provider: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开添加别名对话框(从模型详情抽屉)
|
||||
function openAddAliasDialog() {
|
||||
if (!selectedModel.value) return
|
||||
editingAliasId.value = null
|
||||
isTargetModelFixed.value = true // 目标模型固定为当前选中模型
|
||||
createAliasDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openCreateModelDialog() {
|
||||
editingModel.value = null
|
||||
createModelDialogOpen.value = true
|
||||
@@ -1391,106 +1291,6 @@ async function toggleModelStatus(model: GlobalModelResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAliases() {
|
||||
try {
|
||||
allAliases.value = await getAliases({ limit: 1000 })
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '加载别名失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openEditAliasDialog(alias: ModelAlias) {
|
||||
editingAliasId.value = alias.id
|
||||
isTargetModelFixed.value = false
|
||||
createAliasDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 处理别名对话框关闭事件
|
||||
function handleAliasDialogUpdate(value: boolean) {
|
||||
createAliasDialogOpen.value = value
|
||||
if (!value) {
|
||||
editingAliasId.value = null
|
||||
isTargetModelFixed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理别名提交(来自 AliasDialog 组件)
|
||||
async function handleAliasSubmit(data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean) {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit && editingAliasId.value) {
|
||||
// 更新
|
||||
await updateAlias(editingAliasId.value, data as UpdateModelAliasRequest)
|
||||
success(data.mapping_type === 'mapping' ? '映射已更新' : '别名已更新')
|
||||
} else {
|
||||
// 创建
|
||||
await createAlias(data as CreateModelAliasRequest)
|
||||
success(data.mapping_type === 'mapping' ? '映射已创建' : '别名已创建')
|
||||
}
|
||||
createAliasDialogOpen.value = false
|
||||
editingAliasId.value = null
|
||||
isTargetModelFixed.value = false
|
||||
|
||||
// 刷新数据
|
||||
await loadAliases()
|
||||
if (selectedModel.value) {
|
||||
await loadModelAliases(selectedModel.value.id)
|
||||
}
|
||||
// 刷新外层模型列表以更新 alias_count
|
||||
await loadGlobalModels()
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail || err.message
|
||||
// 优化错误提示文案
|
||||
let errorMessage = detail
|
||||
if (detail === '映射已存在') {
|
||||
errorMessage = '目标作用域已存在同名别名,请先删除冲突的映射或选择其他作用域'
|
||||
}
|
||||
showError(errorMessage, isEdit ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteAlias(alias: ModelAlias) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除别名 "${alias.alias}" 吗?`,
|
||||
'删除别名'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await deleteAlias(alias.id)
|
||||
success('别名已删除')
|
||||
await loadAliases()
|
||||
// 刷新外层模型列表以更新 alias_count
|
||||
await loadGlobalModels()
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAliasStatus(alias: ModelAlias) {
|
||||
try {
|
||||
await updateAlias(alias.id, { is_active: !alias.is_active })
|
||||
alias.is_active = !alias.is_active
|
||||
success(alias.is_active ? '别名已启用' : '别名已停用')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 从抽屉中切换别名状态
|
||||
async function toggleAliasStatusFromDrawer(alias: ModelAlias) {
|
||||
await toggleAliasStatus(alias)
|
||||
await refreshSelectedModelAliases()
|
||||
}
|
||||
|
||||
// 从抽屉中删除别名
|
||||
async function confirmDeleteAliasFromDrawer(alias: ModelAlias) {
|
||||
await confirmDeleteAlias(alias)
|
||||
await refreshSelectedModelAliases()
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
await loadGlobalModels()
|
||||
}
|
||||
|
||||
@@ -505,13 +505,13 @@ const priorityModeConfig = computed(() => {
|
||||
const filteredProviders = computed(() => {
|
||||
let result = [...providers.value]
|
||||
|
||||
// 搜索筛选
|
||||
// 搜索筛选(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(p =>
|
||||
p.display_name.toLowerCase().includes(query) ||
|
||||
p.name.toLowerCase().includes(query)
|
||||
)
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
result = result.filter(p => {
|
||||
const searchableText = `${p.display_name} ${p.name}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
// 排序
|
||||
@@ -723,9 +723,19 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
||||
// 切换提供商状态
|
||||
async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
|
||||
try {
|
||||
await updateProvider(provider.id, { is_active: !provider.is_active })
|
||||
provider.is_active = !provider.is_active
|
||||
showSuccess(provider.is_active ? '提供商已启用' : '提供商已停用')
|
||||
const newStatus = !provider.is_active
|
||||
await updateProvider(provider.id, { is_active: newStatus })
|
||||
|
||||
// 更新抽屉内部的 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) {
|
||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<template #actions>
|
||||
<Button
|
||||
:disabled="loading"
|
||||
class="shadow-none hover:shadow-none"
|
||||
@click="saveSystemConfig"
|
||||
>
|
||||
{{ loading ? '保存中...' : '保存所有配置' }}
|
||||
@@ -15,6 +16,94 @@
|
||||
</PageHeader>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- 配置导出/导入 -->
|
||||
<CardSection
|
||||
title="配置管理"
|
||||
description="导出或导入提供商和模型配置,便于备份或迁移"
|
||||
>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
导出当前所有提供商、端点、API Key 和模型配置到 JSON 文件
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="exportLoading"
|
||||
@click="handleExportConfig"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
{{ exportLoading ? '导出中...' : '导出配置' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
从 JSON 文件导入配置,支持跳过、覆盖或报错三种冲突处理模式
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
ref="configFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="handleConfigFileSelect"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="importLoading"
|
||||
@click="triggerConfigFileSelect"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
{{ importLoading ? '导入中...' : '导入配置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 用户数据导出/导入 -->
|
||||
<CardSection
|
||||
title="用户数据管理"
|
||||
description="导出或导入用户及其 API Keys 数据(不含管理员)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
导出所有普通用户及其 API Keys 到 JSON 文件
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="exportUsersLoading"
|
||||
@click="handleExportUsers"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
{{ exportUsersLoading ? '导出中...' : '导出用户数据' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
从 JSON 文件导入用户数据(需相同 ENCRYPTION_KEY)
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
ref="usersFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="handleUsersFileSelect"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="importUsersLoading"
|
||||
@click="triggerUsersFileSelect"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
{{ importUsersLoading ? '导入中...' : '导入用户数据' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 基础配置 -->
|
||||
<CardSection
|
||||
title="基础配置"
|
||||
@@ -96,32 +185,13 @@
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- API Key 管理配置 -->
|
||||
<!-- 独立余额 Key 过期管理 -->
|
||||
<CardSection
|
||||
title="API Key 管理"
|
||||
description="API Key 相关配置"
|
||||
title="独立余额 Key 过期管理"
|
||||
description="独立余额 Key 的过期处理策略(普通用户 Key 不会过期)"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="api-key-expire"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
API密钥过期天数
|
||||
</Label>
|
||||
<Input
|
||||
id="api-key-expire"
|
||||
v-model.number="systemConfig.api_key_expire_days"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
0 表示永不过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full pt-6">
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-delete-expired-keys"
|
||||
@@ -135,7 +205,7 @@
|
||||
自动删除过期 Key
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
关闭时仅禁用过期 Key
|
||||
关闭时仅禁用过期 Key,不会物理删除
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,6 +429,25 @@
|
||||
避免单次操作过大影响性能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="audit-log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
审计日志保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="audit-log-retention-days"
|
||||
v-model.number="systemConfig.audit_log_retention_days"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后删除审计日志记录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清理策略说明 -->
|
||||
@@ -371,15 +460,317 @@
|
||||
<p>2. <strong>压缩日志阶段</strong>: body 字段被压缩存储,节省空间</p>
|
||||
<p>3. <strong>统计阶段</strong>: 仅保留 tokens、成本等统计信息</p>
|
||||
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
|
||||
<p>5. <strong>审计日志</strong>: 独立清理,记录用户登录、操作等安全事件</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
|
||||
<!-- 导入配置对话框 -->
|
||||
<Dialog
|
||||
v-model:open="importDialogOpen"
|
||||
title="导入配置"
|
||||
description="选择冲突处理模式并确认导入"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="importPreview"
|
||||
class="p-3 bg-muted rounded-lg text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
配置预览
|
||||
</p>
|
||||
<ul class="space-y-1 text-muted-foreground">
|
||||
<li>全局模型: {{ importPreview.global_models?.length || 0 }} 个</li>
|
||||
<li>提供商: {{ importPreview.providers?.length || 0 }} 个</li>
|
||||
<li>
|
||||
端点: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.endpoints?.length || 0), 0) }} 个
|
||||
</li>
|
||||
<li>
|
||||
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + p.endpoints?.reduce((s: number, e: any) => s + (e.keys?.length || 0), 0), 0) }} 个
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
|
||||
<Select
|
||||
v-model="mergeMode"
|
||||
v-model:open="mergeModeSelectOpen"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">
|
||||
跳过 - 保留现有配置
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite">
|
||||
覆盖 - 用导入配置替换
|
||||
</SelectItem>
|
||||
<SelectItem value="error">
|
||||
报错 - 遇到冲突时中止
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
<template v-if="mergeMode === 'skip'">
|
||||
已存在的配置将被保留,仅导入新配置
|
||||
</template>
|
||||
<template v-else-if="mergeMode === 'overwrite'">
|
||||
已存在的配置将被导入的配置覆盖
|
||||
</template>
|
||||
<template v-else>
|
||||
如果发现任何冲突,导入将中止并回滚
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
注意:相同的 API Keys 会自动跳过,不会创建重复记录。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="importDialogOpen = false; mergeModeSelectOpen = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="importLoading"
|
||||
@click="confirmImport"
|
||||
>
|
||||
{{ importLoading ? '导入中...' : '确认导入' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 导入结果对话框 -->
|
||||
<Dialog
|
||||
v-model:open="importResultDialogOpen"
|
||||
title="导入完成"
|
||||
>
|
||||
<div
|
||||
v-if="importResult"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<p class="font-medium">
|
||||
全局模型
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.global_models.created }},
|
||||
更新: {{ importResult.stats.global_models.updated }},
|
||||
跳过: {{ importResult.stats.global_models.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<p class="font-medium">
|
||||
提供商
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.providers.created }},
|
||||
更新: {{ importResult.stats.providers.updated }},
|
||||
跳过: {{ importResult.stats.providers.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<p class="font-medium">
|
||||
端点
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.endpoints.created }},
|
||||
更新: {{ importResult.stats.endpoints.updated }},
|
||||
跳过: {{ importResult.stats.endpoints.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<p class="font-medium">
|
||||
API Keys
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.keys.created }},
|
||||
跳过: {{ importResult.stats.keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg col-span-2">
|
||||
<p class="font-medium">
|
||||
模型配置
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.models.created }},
|
||||
更新: {{ importResult.stats.models.updated }},
|
||||
跳过: {{ importResult.stats.models.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="importResult.stats.errors.length > 0"
|
||||
class="p-3 bg-destructive/10 rounded-lg"
|
||||
>
|
||||
<p class="font-medium text-destructive mb-2">
|
||||
警告信息
|
||||
</p>
|
||||
<ul class="text-sm text-destructive space-y-1">
|
||||
<li
|
||||
v-for="(err, index) in importResult.stats.errors"
|
||||
:key="index"
|
||||
>
|
||||
{{ err }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="importResultDialogOpen = false">
|
||||
确定
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 用户数据导入对话框 -->
|
||||
<Dialog
|
||||
v-model:open="importUsersDialogOpen"
|
||||
title="导入用户数据"
|
||||
description="选择冲突处理模式并确认导入"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="importUsersPreview"
|
||||
class="p-3 bg-muted rounded-lg text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
数据预览
|
||||
</p>
|
||||
<ul class="space-y-1 text-muted-foreground">
|
||||
<li>用户: {{ importUsersPreview.users?.length || 0 }} 个</li>
|
||||
<li>
|
||||
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} 个
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
|
||||
<Select
|
||||
v-model="usersMergeMode"
|
||||
v-model:open="usersMergeModeSelectOpen"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">
|
||||
跳过 - 保留现有用户
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite">
|
||||
覆盖 - 用导入数据替换
|
||||
</SelectItem>
|
||||
<SelectItem value="error">
|
||||
报错 - 遇到冲突时中止
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
<template v-if="usersMergeMode === 'skip'">
|
||||
已存在的用户将被保留,仅导入新用户
|
||||
</template>
|
||||
<template v-else-if="usersMergeMode === 'overwrite'">
|
||||
已存在的用户将被导入的数据覆盖
|
||||
</template>
|
||||
<template v-else>
|
||||
如果发现任何冲突,导入将中止并回滚
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
注意:用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="importUsersDialogOpen = false; usersMergeModeSelectOpen = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="importUsersLoading"
|
||||
@click="confirmImportUsers"
|
||||
>
|
||||
{{ importUsersLoading ? '导入中...' : '确认导入' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 用户数据导入结果对话框 -->
|
||||
<Dialog
|
||||
v-model:open="importUsersResultDialogOpen"
|
||||
title="用户数据导入完成"
|
||||
>
|
||||
<div
|
||||
v-if="importUsersResult"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<p class="font-medium">
|
||||
用户
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importUsersResult.stats.users.created }},
|
||||
更新: {{ importUsersResult.stats.users.updated }},
|
||||
跳过: {{ importUsersResult.stats.users.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<p class="font-medium">
|
||||
API Keys
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importUsersResult.stats.api_keys.created }},
|
||||
跳过: {{ importUsersResult.stats.api_keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="importUsersResult.stats.errors.length > 0"
|
||||
class="p-3 bg-destructive/10 rounded-lg"
|
||||
>
|
||||
<p class="font-medium text-destructive mb-2">
|
||||
警告信息
|
||||
</p>
|
||||
<ul class="text-sm text-destructive space-y-1">
|
||||
<li
|
||||
v-for="(err, index) in importUsersResult.stats.errors"
|
||||
:key="index"
|
||||
>
|
||||
{{ err }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="importUsersResultDialogOpen = false">
|
||||
确定
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<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 Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
@@ -389,9 +780,12 @@ 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'
|
||||
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { adminApi, type ConfigExportData, type ConfigImportResponse, type UsersExportData, type UsersImportResponse } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
@@ -403,8 +797,7 @@ interface SystemConfig {
|
||||
// 用户注册
|
||||
enable_registration: boolean
|
||||
require_email_verification: boolean
|
||||
// API Key 管理
|
||||
api_key_expire_days: number
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: boolean
|
||||
// 日志记录
|
||||
request_log_level: string
|
||||
@@ -418,11 +811,34 @@ interface SystemConfig {
|
||||
header_retention_days: number
|
||||
log_retention_days: number
|
||||
cleanup_batch_size: number
|
||||
audit_log_retention_days: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const logLevelSelectOpen = ref(false)
|
||||
|
||||
// 导出/导入相关
|
||||
const exportLoading = ref(false)
|
||||
const importLoading = ref(false)
|
||||
const importDialogOpen = ref(false)
|
||||
const importResultDialogOpen = ref(false)
|
||||
const configFileInput = ref<HTMLInputElement | null>(null)
|
||||
const importPreview = ref<ConfigExportData | null>(null)
|
||||
const importResult = ref<ConfigImportResponse | null>(null)
|
||||
const mergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const mergeModeSelectOpen = ref(false)
|
||||
|
||||
// 用户数据导出/导入相关
|
||||
const exportUsersLoading = ref(false)
|
||||
const importUsersLoading = ref(false)
|
||||
const importUsersDialogOpen = ref(false)
|
||||
const importUsersResultDialogOpen = ref(false)
|
||||
const usersFileInput = ref<HTMLInputElement | null>(null)
|
||||
const importUsersPreview = ref<UsersExportData | null>(null)
|
||||
const importUsersResult = ref<UsersImportResponse | null>(null)
|
||||
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const usersMergeModeSelectOpen = ref(false)
|
||||
|
||||
const systemConfig = ref<SystemConfig>({
|
||||
// 基础配置
|
||||
default_user_quota_usd: 10.0,
|
||||
@@ -430,8 +846,7 @@ const systemConfig = ref<SystemConfig>({
|
||||
// 用户注册
|
||||
enable_registration: false,
|
||||
require_email_verification: false,
|
||||
// API Key 管理
|
||||
api_key_expire_days: 0,
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: false,
|
||||
// 日志记录
|
||||
request_log_level: 'basic',
|
||||
@@ -445,6 +860,7 @@ const systemConfig = ref<SystemConfig>({
|
||||
header_retention_days: 90,
|
||||
log_retention_days: 365,
|
||||
cleanup_batch_size: 1000,
|
||||
audit_log_retention_days: 30,
|
||||
})
|
||||
|
||||
// 计算属性:KB 和 字节 之间的转换
|
||||
@@ -486,8 +902,7 @@ async function loadSystemConfig() {
|
||||
// 用户注册
|
||||
'enable_registration',
|
||||
'require_email_verification',
|
||||
// API Key 管理
|
||||
'api_key_expire_days',
|
||||
// 独立余额 Key 过期管理
|
||||
'auto_delete_expired_keys',
|
||||
// 日志记录
|
||||
'request_log_level',
|
||||
@@ -501,6 +916,7 @@ async function loadSystemConfig() {
|
||||
'header_retention_days',
|
||||
'log_retention_days',
|
||||
'cleanup_batch_size',
|
||||
'audit_log_retention_days',
|
||||
]
|
||||
|
||||
for (const key of configs) {
|
||||
@@ -545,12 +961,7 @@ async function saveSystemConfig() {
|
||||
value: systemConfig.value.require_email_verification,
|
||||
description: '是否需要邮箱验证'
|
||||
},
|
||||
// API Key 管理
|
||||
{
|
||||
key: 'api_key_expire_days',
|
||||
value: systemConfig.value.api_key_expire_days,
|
||||
description: 'API密钥过期天数'
|
||||
},
|
||||
// 独立余额 Key 过期管理
|
||||
{
|
||||
key: 'auto_delete_expired_keys',
|
||||
value: systemConfig.value.auto_delete_expired_keys,
|
||||
@@ -608,6 +1019,11 @@ async function saveSystemConfig() {
|
||||
value: systemConfig.value.cleanup_batch_size,
|
||||
description: '每批次清理的记录数'
|
||||
},
|
||||
{
|
||||
key: 'audit_log_retention_days',
|
||||
value: systemConfig.value.audit_log_retention_days,
|
||||
description: '审计日志保留天数'
|
||||
},
|
||||
]
|
||||
|
||||
const promises = configItems.map(item =>
|
||||
@@ -623,4 +1039,185 @@ async function saveSystemConfig() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出配置
|
||||
async function handleExportConfig() {
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const data = await adminApi.exportConfig()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `aether-config-${new Date().toISOString().slice(0, 10)}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
success('配置已导出')
|
||||
} catch (err) {
|
||||
error('导出配置失败')
|
||||
log.error('导出配置失败:', err)
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 触发文件选择
|
||||
function triggerConfigFileSelect() {
|
||||
configFileInput.value?.click()
|
||||
}
|
||||
|
||||
// 文件大小限制 (10MB)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
// 处理文件选择
|
||||
function handleConfigFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error('文件大小不能超过 10MB')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string
|
||||
const data = JSON.parse(content) as ConfigExportData
|
||||
|
||||
// 验证版本
|
||||
if (data.version !== '1.0') {
|
||||
error(`不支持的配置版本: ${data.version}`)
|
||||
return
|
||||
}
|
||||
|
||||
importPreview.value = data
|
||||
mergeMode.value = 'skip'
|
||||
importDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('解析配置文件失败,请确保是有效的 JSON 文件')
|
||||
log.error('解析配置文件失败:', err)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
// 重置 input 以便能再次选择同一文件
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// 确认导入
|
||||
async function confirmImport() {
|
||||
if (!importPreview.value) return
|
||||
|
||||
importLoading.value = true
|
||||
try {
|
||||
const result = await adminApi.importConfig({
|
||||
...importPreview.value,
|
||||
merge_mode: mergeMode.value
|
||||
})
|
||||
importResult.value = result
|
||||
importDialogOpen.value = false
|
||||
mergeModeSelectOpen.value = false
|
||||
importResultDialogOpen.value = true
|
||||
success('配置导入成功')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '导入配置失败')
|
||||
log.error('导入配置失败:', err)
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出用户数据
|
||||
async function handleExportUsers() {
|
||||
exportUsersLoading.value = true
|
||||
try {
|
||||
const data = await adminApi.exportUsers()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `aether-users-${new Date().toISOString().slice(0, 10)}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
success('用户数据已导出')
|
||||
} catch (err) {
|
||||
error('导出用户数据失败')
|
||||
log.error('导出用户数据失败:', err)
|
||||
} finally {
|
||||
exportUsersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 触发用户数据文件选择
|
||||
function triggerUsersFileSelect() {
|
||||
usersFileInput.value?.click()
|
||||
}
|
||||
|
||||
// 处理用户数据文件选择
|
||||
function handleUsersFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error('文件大小不能超过 10MB')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string
|
||||
const data = JSON.parse(content) as UsersExportData
|
||||
|
||||
// 验证版本
|
||||
if (data.version !== '1.0') {
|
||||
error(`不支持的配置版本: ${data.version}`)
|
||||
return
|
||||
}
|
||||
|
||||
importUsersPreview.value = data
|
||||
usersMergeMode.value = 'skip'
|
||||
importUsersDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('解析用户数据文件失败,请确保是有效的 JSON 文件')
|
||||
log.error('解析用户数据文件失败:', err)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
// 重置 input 以便能再次选择同一文件
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// 确认导入用户数据
|
||||
async function confirmImportUsers() {
|
||||
if (!importUsersPreview.value) return
|
||||
|
||||
importUsersLoading.value = true
|
||||
try {
|
||||
const result = await adminApi.importUsers({
|
||||
...importUsersPreview.value,
|
||||
merge_mode: usersMergeMode.value
|
||||
})
|
||||
importUsersResult.value = result
|
||||
importUsersDialogOpen.value = false
|
||||
usersMergeModeSelectOpen.value = false
|
||||
importUsersResultDialogOpen.value = true
|
||||
success('用户数据导入成功')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '导入用户数据失败')
|
||||
log.error('导入用户数据失败:', err)
|
||||
} finally {
|
||||
importUsersLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -701,6 +701,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { usageApi, type UsageByUser } from '@/api/usage'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
@@ -748,6 +749,7 @@ import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger, confirmWarning } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
// 用户表单对话框状态
|
||||
@@ -791,11 +793,13 @@ const filteredUsers = computed(() => {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
|
||||
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
u => u.username.toLowerCase().includes(query) || u.email?.toLowerCase().includes(query)
|
||||
)
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
filtered = filtered.filter(u => {
|
||||
const searchableText = `${u.username} ${u.email || ''}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
if (filterRole.value !== 'all') {
|
||||
@@ -873,7 +877,8 @@ async function toggleUserStatus(user: any) {
|
||||
const action = user.is_active ? '禁用' : '启用'
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要${action}用户 ${user.username} 吗?`,
|
||||
`${action}用户`
|
||||
`${action}用户`,
|
||||
action
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -882,7 +887,7 @@ async function toggleUserStatus(user: any) {
|
||||
await usersStore.updateUser(user.id, { is_active: !user.is_active })
|
||||
success(`用户已${action}`)
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', `${action}用户失败`)
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', `${action}用户失败`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,7 +958,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
||||
closeUserFormDialog()
|
||||
} catch (err: any) {
|
||||
const title = data.id ? '更新用户失败' : '创建用户失败'
|
||||
error(err.response?.data?.detail || '未知错误', title)
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', title)
|
||||
} finally {
|
||||
userFormDialogRef.value?.setSaving(false)
|
||||
}
|
||||
@@ -987,7 +992,7 @@ async function createApiKey() {
|
||||
showNewApiKeyDialog.value = true
|
||||
await loadUserApiKeys(selectedUser.value.id)
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '创建 API Key 失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '创建 API Key 失败')
|
||||
} finally {
|
||||
creatingApiKey.value = false
|
||||
}
|
||||
@@ -998,12 +1003,7 @@ function selectApiKey() {
|
||||
}
|
||||
|
||||
async function copyApiKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newApiKey.value)
|
||||
success('API Key已复制到剪贴板')
|
||||
} catch {
|
||||
error('复制失败,请手动复制')
|
||||
}
|
||||
await copyToClipboard(newApiKey.value)
|
||||
}
|
||||
|
||||
async function closeNewApiKeyDialog() {
|
||||
@@ -1024,7 +1024,7 @@ async function deleteApiKey(apiKey: any) {
|
||||
await loadUserApiKeys(selectedUser.value.id)
|
||||
success('API Key已删除')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '删除 API Key 失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '删除 API Key 失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1032,11 +1032,10 @@ async function copyFullKey(apiKey: any) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
await navigator.clipboard.writeText(response.key)
|
||||
success('完整密钥已复制到剪贴板')
|
||||
await copyToClipboard(response.key)
|
||||
} catch (err: any) {
|
||||
log.error('复制密钥失败:', err)
|
||||
error(err.response?.data?.detail || '未知错误', '复制密钥失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '复制密钥失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,7 +1051,7 @@ async function resetQuota(user: any) {
|
||||
await usersStore.resetUserQuota(user.id)
|
||||
success('配额已重置')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '重置配额失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '重置配额失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,7 +1067,7 @@ async function deleteUser(user: any) {
|
||||
await usersStore.deleteUser(user.id)
|
||||
success('用户已删除')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '删除用户失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '删除用户失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -102,9 +102,9 @@
|
||||
<!-- Main Content -->
|
||||
<main class="relative z-10">
|
||||
<!-- 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
|
||||
class="transform-gpu logo-container"
|
||||
class="mt-16 transform-gpu logo-container"
|
||||
:class="[currentSection === SECTIONS.HOME ? 'home-section' : '', `logo-transition-${scrollDirection}`]"
|
||||
:style="fixedLogoStyle"
|
||||
>
|
||||
@@ -151,7 +151,7 @@
|
||||
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="h-80 w-full mb-16" />
|
||||
<div class="h-80 w-full mb-16 mt-8" />
|
||||
<h1
|
||||
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)"
|
||||
@@ -166,7 +166,7 @@
|
||||
整合 Claude Code、Codex CLI、Gemini CLI 等多个 AI 编程助手
|
||||
</p>
|
||||
<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)"
|
||||
@click="scrollToSection(SECTIONS.CLAUDE)"
|
||||
>
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
||||
<Card class="relative p-3 sm:p-4 border-book-cloth/30">
|
||||
<Clock class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
|
||||
<Clock 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">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
平均响应
|
||||
@@ -114,7 +114,7 @@
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="relative p-3 sm:p-4 border-kraft/30">
|
||||
<AlertTriangle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
|
||||
<AlertTriangle 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">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
错误率
|
||||
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="relative p-3 sm:p-4 border-book-cloth/25">
|
||||
<Shuffle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
|
||||
<Shuffle 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">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
转移次数
|
||||
@@ -142,13 +142,13 @@
|
||||
v-if="costStats"
|
||||
class="relative p-3 sm:p-4 border-manilla/40"
|
||||
>
|
||||
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
|
||||
<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">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
实际成本
|
||||
本月费用
|
||||
</p>
|
||||
<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>
|
||||
<Badge
|
||||
v-if="costStats.cost_savings > 0"
|
||||
@@ -162,14 +162,14 @@
|
||||
</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"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
本月缓存使用
|
||||
本月统计
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -178,9 +178,17 @@
|
||||
Monthly
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
||||
<Card 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-book-cloth" />
|
||||
<div
|
||||
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" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
缓存命中率
|
||||
@@ -190,8 +198,11 @@
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card 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-kraft" />
|
||||
<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" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
缓存读取
|
||||
@@ -201,8 +212,11 @@
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card 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-kraft" />
|
||||
<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" />
|
||||
<div class="pr-6">
|
||||
<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>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="tokenBreakdown"
|
||||
v-if="userMonthlyCost !== null"
|
||||
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-book-cloth" />
|
||||
<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">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
总Token
|
||||
本月费用
|
||||
</p>
|
||||
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
||||
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}
|
||||
</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) }}
|
||||
{{ formatCurrency(userMonthlyCost) }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -254,16 +265,16 @@
|
||||
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full max-h-[280px] sm:max-h-none">
|
||||
<div
|
||||
v-if="loadingAnnouncements"
|
||||
class="py-8 text-center"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<Loader2 class="h-5 w-5 animate-spin mx-auto text-muted-foreground" />
|
||||
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="announcements.length === 0"
|
||||
class="py-8 text-center"
|
||||
class="flex-1 flex flex-col items-center justify-center"
|
||||
>
|
||||
<Bell class="h-8 w-8 mx-auto text-muted-foreground/40" />
|
||||
<Bell class="h-8 w-8 text-muted-foreground/40" />
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
暂无公告
|
||||
</p>
|
||||
@@ -793,9 +804,8 @@ const statCardGlows = [
|
||||
'bg-kraft/30'
|
||||
]
|
||||
|
||||
const getStatIconColor = (index: number): string => {
|
||||
const colors = ['text-book-cloth', 'text-kraft', 'text-book-cloth', 'text-kraft']
|
||||
return colors[index % colors.length]
|
||||
const getStatIconColor = (_index: number): string => {
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
@@ -832,6 +842,12 @@ const cacheStats = ref<{
|
||||
total_cache_tokens: number
|
||||
} | null>(null)
|
||||
|
||||
const userMonthlyCost = ref<number | null>(null)
|
||||
|
||||
const hasCacheData = computed(() =>
|
||||
cacheStats.value && cacheStats.value.total_cache_tokens > 0
|
||||
)
|
||||
|
||||
const tokenBreakdown = ref<{
|
||||
input: number
|
||||
output: number
|
||||
@@ -1087,6 +1103,7 @@ async function loadDashboardData() {
|
||||
} else {
|
||||
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
||||
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
||||
if (statsData.monthly_cost !== undefined) userMonthlyCost.value = statsData.monthly_cost
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
:page-size="pageSize"
|
||||
:total-records="totalRecords"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:auto-refresh="globalAutoRefresh"
|
||||
@update:selected-period="handlePeriodChange"
|
||||
@update:filter-user="handleFilterUserChange"
|
||||
@update:filter-model="handleFilterModelChange"
|
||||
@@ -72,6 +73,7 @@
|
||||
@update:filter-status="handleFilterStatusChange"
|
||||
@update:current-page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@update:auto-refresh="handleAutoRefreshChange"
|
||||
@refresh="refreshData"
|
||||
@export="exportData"
|
||||
@show-detail="showRequestDetail"
|
||||
@@ -214,7 +216,10 @@ const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
||||
|
||||
// 自动刷新定时器
|
||||
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() {
|
||||
@@ -278,9 +283,35 @@ watch(hasActiveRequests, (hasActive) => {
|
||||
}
|
||||
}, { 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(() => {
|
||||
stopAutoRefresh()
|
||||
stopGlobalAutoRefresh()
|
||||
})
|
||||
|
||||
// 用户页面的前端分页
|
||||
|
||||
@@ -226,8 +226,8 @@
|
||||
<div
|
||||
v-for="announcement in announcements"
|
||||
:key="announcement.id"
|
||||
class="p-4 space-y-2 cursor-pointer transition-colors"
|
||||
:class="[
|
||||
'p-4 space-y-2 cursor-pointer transition-colors',
|
||||
announcement.is_read ? 'hover:bg-muted/30' : 'bg-primary/5 hover:bg-primary/10'
|
||||
]"
|
||||
@click="viewAnnouncementDetail(announcement)"
|
||||
|
||||
@@ -165,17 +165,17 @@
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5">
|
||||
<Eye
|
||||
v-if="model.default_supports_vision"
|
||||
v-if="model.config?.vision === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="Vision"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="model.default_supports_function_calling"
|
||||
v-if="model.config?.function_calling === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="Tool Use"
|
||||
/>
|
||||
<Brain
|
||||
v-if="model.default_supports_extended_thinking"
|
||||
v-if="model.config?.extended_thinking === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="Extended Thinking"
|
||||
/>
|
||||
@@ -253,15 +253,15 @@
|
||||
<!-- 第二行:能力图标 -->
|
||||
<div class="flex gap-1.5">
|
||||
<Eye
|
||||
v-if="model.default_supports_vision"
|
||||
v-if="model.config?.vision === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="model.default_supports_function_calling"
|
||||
v-if="model.config?.function_calling === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Brain
|
||||
v-if="model.default_supports_extended_thinking"
|
||||
v-if="model.config?.extended_thinking === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -342,6 +342,7 @@ import {
|
||||
Plus,
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
@@ -370,6 +371,7 @@ import { useRowClick } from '@/composables/useRowClick'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -474,24 +476,24 @@ async function toggleCapability(modelName: string, capName: string) {
|
||||
const filteredModels = computed(() => {
|
||||
let result = models.value
|
||||
|
||||
// 搜索
|
||||
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.display_name?.toLowerCase().includes(query)
|
||||
)
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
result = result.filter(m => {
|
||||
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
// 能力筛选
|
||||
if (capabilityFilters.value.vision) {
|
||||
result = result.filter(m => m.default_supports_vision)
|
||||
result = result.filter(m => m.config?.vision === true)
|
||||
}
|
||||
if (capabilityFilters.value.toolUse) {
|
||||
result = result.filter(m => m.default_supports_function_calling)
|
||||
result = result.filter(m => m.config?.function_calling === true)
|
||||
}
|
||||
if (capabilityFilters.value.extendedThinking) {
|
||||
result = result.filter(m => m.default_supports_extended_thinking)
|
||||
result = result.filter(m => m.config?.extended_thinking === true)
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -565,16 +567,6 @@ function hasTieredPricing(model: PublicGlobalModel): boolean {
|
||||
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(() => {
|
||||
refreshData()
|
||||
})
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="savingProfile"
|
||||
class="shadow-none hover:shadow-none"
|
||||
>
|
||||
{{ savingProfile ? '保存中...' : '保存修改' }}
|
||||
</Button>
|
||||
@@ -107,6 +108,7 @@
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="changingPassword"
|
||||
class="shadow-none hover:shadow-none"
|
||||
>
|
||||
{{ changingPassword ? '修改中...' : '修改密码' }}
|
||||
</Button>
|
||||
@@ -320,6 +322,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { meApi, type Profile } from '@/api/me'
|
||||
import { useDarkMode, type ThemeMode } from '@/composables/useDarkMode'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
@@ -338,6 +341,7 @@ import { log } from '@/utils/logger'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { success, error: showError } = useToast()
|
||||
const { setThemeMode } = useDarkMode()
|
||||
|
||||
const profile = ref<Profile | null>(null)
|
||||
|
||||
@@ -375,20 +379,8 @@ function handleThemeChange(value: string) {
|
||||
themeSelectOpen.value = false
|
||||
updatePreferences()
|
||||
|
||||
// 应用主题
|
||||
if (value === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (value === 'light') {
|
||||
document.documentElement.classList.remove('dark')
|
||||
} else {
|
||||
// system: 跟随系统
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
// 使用 useDarkMode 统一切换主题
|
||||
setThemeMode(value as ThemeMode)
|
||||
}
|
||||
|
||||
function handleLanguageChange(value: string) {
|
||||
@@ -418,10 +410,16 @@ async function loadProfile() {
|
||||
async function loadPreferences() {
|
||||
try {
|
||||
const prefs = await meApi.getPreferences()
|
||||
|
||||
// 主题以本地 localStorage 为准(useDarkMode 在应用启动时已初始化)
|
||||
// 这样可以避免刷新页面时主题被服务端旧值覆盖
|
||||
const { themeMode: currentThemeMode } = useDarkMode()
|
||||
const localTheme = currentThemeMode.value
|
||||
|
||||
preferencesForm.value = {
|
||||
avatar_url: prefs.avatar_url || '',
|
||||
bio: prefs.bio || '',
|
||||
theme: prefs.theme || 'light',
|
||||
theme: localTheme, // 使用本地主题,而非服务端返回值
|
||||
language: prefs.language || 'zh-CN',
|
||||
timezone: prefs.timezone || 'Asia/Shanghai',
|
||||
notifications: {
|
||||
@@ -431,11 +429,12 @@ async function loadPreferences() {
|
||||
}
|
||||
}
|
||||
|
||||
// 应用主题
|
||||
if (preferencesForm.value.theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (preferencesForm.value.theme === 'light') {
|
||||
document.documentElement.classList.remove('dark')
|
||||
// 如果本地主题和服务端不一致,同步到服务端(静默更新,不提示用户)
|
||||
const serverTheme = prefs.theme || 'light'
|
||||
if (localTheme !== serverTheme) {
|
||||
meApi.updatePreferences({ theme: localTheme }).catch(() => {
|
||||
// 静默失败,不影响用户体验
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载偏好设置失败:', error)
|
||||
@@ -478,8 +477,8 @@ async function changePassword() {
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.value.new_password.length < 8) {
|
||||
showError('密码长度至少8位')
|
||||
if (passwordForm.value.new_password.length < 6) {
|
||||
showError('密码长度至少6位')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="model.description"
|
||||
v-if="model.config?.description"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ model.description }}
|
||||
{{ model.config?.description }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -73,10 +73,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.streaming !== false ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.streaming !== false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
@@ -90,10 +90,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.image_generation === true ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.image_generation === true ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
@@ -107,10 +107,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_vision ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.vision === true ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.vision === true ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
@@ -124,10 +124,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.function_calling === true ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.function_calling === true ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
@@ -141,10 +141,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'"
|
||||
:variant="model.config?.extended_thinking === true ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
|
||||
{{ model.config?.extended_thinking === true ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,7 +350,9 @@ import {
|
||||
Layers,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -374,6 +376,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
interface Props {
|
||||
model: PublicGlobalModel | null
|
||||
@@ -407,15 +410,6 @@ function handleClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstTierPrice(
|
||||
tieredPricing: TieredPricingConfig | undefined | null,
|
||||
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 '-'
|
||||
return get1hCachePrice(tieredPricing.tiers[0])
|
||||
}
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.open) {
|
||||
handleClose()
|
||||
}
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
12
migrate.sh
Executable file
12
migrate.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# 数据库迁移脚本 - 在 Docker 容器内执行 Alembic 迁移
|
||||
|
||||
set -e
|
||||
|
||||
CONTAINER_NAME="aether-app"
|
||||
|
||||
echo "Running database migrations in container: $CONTAINER_NAME"
|
||||
|
||||
docker exec $CONTAINER_NAME alembic upgrade head
|
||||
|
||||
echo "Database migration completed successfully"
|
||||
@@ -13,7 +13,7 @@ authors = [
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"License :: Other/Proprietary License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
A proxy server that enables AI models to work with multiple API providers.
|
||||
"""
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
# 注意: dotenv 加载已统一移至 src/config/settings.py
|
||||
# 不要在此处重复加载
|
||||
|
||||
try:
|
||||
from src._version import __version__
|
||||
|
||||
@@ -7,6 +7,7 @@ from .api_keys import router as api_keys_router
|
||||
from .endpoints import router as endpoints_router
|
||||
from .models import router as models_router
|
||||
from .monitoring import router as monitoring_router
|
||||
from .provider_query import router as provider_query_router
|
||||
from .provider_strategy import router as provider_strategy_router
|
||||
from .providers import router as providers_router
|
||||
from .security import router as security_router
|
||||
@@ -26,5 +27,6 @@ router.include_router(provider_strategy_router)
|
||||
router.include_router(adaptive_router)
|
||||
router.include_router(models_router)
|
||||
router.include_router(security_router)
|
||||
router.include_router(provider_query_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -223,7 +223,7 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
allowed_providers=self.key_data.allowed_providers,
|
||||
allowed_api_formats=self.key_data.allowed_api_formats,
|
||||
allowed_models=self.key_data.allowed_models,
|
||||
rate_limit=self.key_data.rate_limit or 100,
|
||||
rate_limit=self.key_data.rate_limit, # None 表示不限制
|
||||
expire_days=self.key_data.expire_days,
|
||||
initial_balance_usd=self.key_data.initial_balance_usd,
|
||||
is_standalone=True, # 标记为独立Key
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@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}")
|
||||
async def delete_endpoint_key(
|
||||
key_id: str,
|
||||
@@ -246,6 +257,15 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
if "api_key" in update_data:
|
||||
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():
|
||||
setattr(key, field, value)
|
||||
key.updated_at = datetime.now(timezone.utc)
|
||||
@@ -253,7 +273,7 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
db.commit()
|
||||
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:
|
||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||
@@ -284,6 +304,30 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
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
|
||||
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
|
||||
@@ -5,7 +5,7 @@ ProviderEndpoint CRUD 管理 API
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import and_, func
|
||||
@@ -27,6 +27,16 @@ router = APIRouter(tags=["Endpoint Management"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
def mask_proxy_password(proxy_config: Optional[dict]) -> Optional[dict]:
|
||||
"""对代理配置中的密码进行脱敏处理"""
|
||||
if not proxy_config:
|
||||
return None
|
||||
masked = dict(proxy_config)
|
||||
if masked.get("password"):
|
||||
masked["password"] = "***"
|
||||
return masked
|
||||
|
||||
|
||||
@router.get("/providers/{provider_id}/endpoints", response_model=List[ProviderEndpointResponse])
|
||||
async def list_provider_endpoints(
|
||||
provider_id: str,
|
||||
@@ -153,6 +163,7 @@ class AdminListProviderEndpointsAdapter(AdminApiAdapter):
|
||||
"api_format": endpoint.api_format,
|
||||
"total_keys": total_keys_map.get(endpoint.id, 0),
|
||||
"active_keys": active_keys_map.get(endpoint.id, 0),
|
||||
"proxy": mask_proxy_password(endpoint.proxy),
|
||||
}
|
||||
endpoint_dict.pop("_sa_instance_state", None)
|
||||
result.append(ProviderEndpointResponse(**endpoint_dict))
|
||||
@@ -202,6 +213,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
||||
rate_limit=self.endpoint_data.rate_limit,
|
||||
is_active=True,
|
||||
config=self.endpoint_data.config,
|
||||
proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -215,12 +227,13 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
||||
endpoint_dict = {
|
||||
k: v
|
||||
for k, v in new_endpoint.__dict__.items()
|
||||
if k not in {"api_format", "_sa_instance_state"}
|
||||
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||
}
|
||||
return ProviderEndpointResponse(
|
||||
**endpoint_dict,
|
||||
provider_name=provider.name,
|
||||
api_format=new_endpoint.api_format,
|
||||
proxy=mask_proxy_password(new_endpoint.proxy),
|
||||
total_keys=0,
|
||||
active_keys=0,
|
||||
)
|
||||
@@ -259,12 +272,13 @@ class AdminGetProviderEndpointAdapter(AdminApiAdapter):
|
||||
endpoint_dict = {
|
||||
k: v
|
||||
for k, v in endpoint_obj.__dict__.items()
|
||||
if k not in {"api_format", "_sa_instance_state"}
|
||||
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||
}
|
||||
return ProviderEndpointResponse(
|
||||
**endpoint_dict,
|
||||
provider_name=provider.name,
|
||||
api_format=endpoint_obj.api_format,
|
||||
proxy=mask_proxy_password(endpoint_obj.proxy),
|
||||
total_keys=total_keys,
|
||||
active_keys=active_keys,
|
||||
)
|
||||
@@ -284,6 +298,17 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||
|
||||
update_data = self.endpoint_data.model_dump(exclude_unset=True)
|
||||
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
|
||||
if "proxy" in update_data:
|
||||
if update_data["proxy"] is not None:
|
||||
new_proxy = dict(update_data["proxy"])
|
||||
# 只有当密码字段未提供时才保留原密码(空字符串视为显式清除)
|
||||
if "password" not in new_proxy and endpoint.proxy:
|
||||
old_password = endpoint.proxy.get("password")
|
||||
if old_password:
|
||||
new_proxy["password"] = old_password
|
||||
update_data["proxy"] = new_proxy
|
||||
# proxy 为 None 时保留,用于清除代理配置
|
||||
for field, value in update_data.items():
|
||||
setattr(endpoint, field, value)
|
||||
endpoint.updated_at = datetime.now(timezone.utc)
|
||||
@@ -311,12 +336,13 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
||||
endpoint_dict = {
|
||||
k: v
|
||||
for k, v in endpoint.__dict__.items()
|
||||
if k not in {"api_format", "_sa_instance_state"}
|
||||
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||
}
|
||||
return ProviderEndpointResponse(
|
||||
**endpoint_dict,
|
||||
provider_name=provider.name if provider else "Unknown",
|
||||
api_format=endpoint.api_format,
|
||||
proxy=mask_proxy_password(endpoint.proxy),
|
||||
total_keys=total_keys,
|
||||
active_keys=active_keys,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user