Initial commit
62
.dockerignore
Normal file
@@ -0,0 +1,62 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
.uv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/.vite/
|
||||
# frontend/dist/ - 注释掉,因为我们需要预构建的dist文件
|
||||
|
||||
# Development
|
||||
.git/
|
||||
.gitignore
|
||||
.github/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
Dockerfile.*
|
||||
|
||||
# Deployment
|
||||
deploy/
|
||||
scripts/
|
||||
31
.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# ==================== 必须配置(启动前) ====================
|
||||
# 以下配置项必须在项目启动前设置
|
||||
|
||||
# 数据库密码
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
|
||||
# JWT密钥(使用 python generate_keys.py 生成)
|
||||
# 用于用户登录 token 签名,更换后所有用户需重新登录
|
||||
JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||
|
||||
# 独立加密密钥(用于加密 Provider API Key 等敏感数据)
|
||||
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
|
||||
ENCRYPTION_KEY=change-this-to-another-secure-random-string
|
||||
|
||||
# 管理员账号(仅首次初始化时使用, 创建完成后可在系统内修改密码)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123456
|
||||
|
||||
# ==================== 可选配置(有默认值) ====================
|
||||
# 以下配置项有合理的默认值,可按需调整
|
||||
|
||||
# 应用端口(默认 8084)
|
||||
# APP_PORT=8084
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
# API_KEY_PREFIX=sk
|
||||
|
||||
# 日志级别(默认 INFO,可选:DEBUG, INFO, WARNING, ERROR)
|
||||
# LOG_LEVEL=INFO
|
||||
57
.github/workflows/deploy-pages.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
env:
|
||||
GITHUB_PAGES: 'true'
|
||||
run: npm run build
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: frontend/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
226
.gitignore
vendored
Normal file
@@ -0,0 +1,226 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
# AI Assistant Configuration
|
||||
.claude/
|
||||
.serena/
|
||||
.gemini*/
|
||||
|
||||
### Python ###
|
||||
*.db
|
||||
*.db-*
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
# But allow frontend lib directory
|
||||
!frontend/src/lib/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Captured requests (debugging data)
|
||||
captured_requests/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Logs folder
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Git backup
|
||||
.git.backup/
|
||||
|
||||
# Database backups
|
||||
backups/
|
||||
|
||||
# Cloud database configuration (contains sensitive credentials)
|
||||
.env.cloud
|
||||
|
||||
# Runtime lock files
|
||||
.locks/
|
||||
# Demo and test files
|
||||
frontend/public/*-demo.html
|
||||
frontend/public/*-measure.html
|
||||
frontend/public/*-firework.svg
|
||||
|
||||
# Debug and experimental files
|
||||
debug_*.html
|
||||
extracted_*.ts
|
||||
|
||||
# Deploy script cache
|
||||
.deps-hash
|
||||
.code-hash
|
||||
.migration-hash
|
||||
16
Dockerfile.app
Normal file
@@ -0,0 +1,16 @@
|
||||
# 应用镜像:基于基础镜像,只复制代码(秒级构建)
|
||||
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
|
||||
FROM aether-base:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制后端代码
|
||||
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
|
||||
126
Dockerfile.base
Normal file
@@ -0,0 +1,126 @@
|
||||
# 基础镜像:包含所有依赖,只在依赖变化时需要重建
|
||||
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
||||
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/*
|
||||
|
||||
# pip 镜像源
|
||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# Python 依赖(安装到系统,不用 -e 模式)
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN mkdir -p src && touch src/__init__.py && \
|
||||
pip install --no-cache-dir .
|
||||
|
||||
# 前端依赖
|
||||
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"]
|
||||
30
LICENSE
Normal file
@@ -0,0 +1,30 @@
|
||||
Aether 非商业开源许可证
|
||||
|
||||
版权所有 (c) 2025 Aether 贡献者
|
||||
|
||||
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
||||
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
||||
|
||||
1. 仅限非商业用途
|
||||
本软件不得用于商业目的。商业目的包括但不限于:
|
||||
- 出售本软件或任何衍生作品
|
||||
- 使用本软件提供付费服务
|
||||
- 将本软件用于商业产品或服务
|
||||
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
|
||||
|
||||
2. 署名要求
|
||||
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
||||
|
||||
3. 分发要求
|
||||
本软件或衍生作品的任何分发必须使用相同的许可条款。
|
||||
|
||||
4. 禁止再许可
|
||||
您不得以不同的条款将本软件再许可给他人。
|
||||
|
||||
5. 商业许可
|
||||
如需商业使用,请联系版权持有人以获取单独的商业许可。
|
||||
|
||||
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
||||
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
||||
索赔、损害或其他责任承担责任,无论是在合同诉讼、侵权诉讼或其他诉讼中,
|
||||
还是因本软件或本软件的使用或其他交易而产生的责任。
|
||||
140
README.md
Normal file
@@ -0,0 +1,140 @@
|
||||
<p align="center">
|
||||
<img src="frontend/public/aether_adaptive.svg" width="120" height="120" alt="Aether Logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Aether</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>开源 AI API 网关</strong><br>
|
||||
支持 Claude / OpenAI / Gemini 及其 CLI 客户端的统一接入层
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#特性">特性</a> •
|
||||
<a href="#架构">架构</a> •
|
||||
<a href="#部署">部署</a> •
|
||||
<a href="#环境变量">环境变量</a> •
|
||||
<a href="#qa">Q&A</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 简介
|
||||
|
||||
Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户管理、智能负载均衡、成本配额控制和健康监控能力。通过统一的 API 入口,可以无缝对接 Claude、OpenAI、Gemini 等主流 AI 服务及其 CLI 工具。
|
||||
|
||||
### 页面预览
|
||||
|
||||
| 首页 | 仪表盘 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 健康监控 | 用户管理 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 提供商管理 | 使用记录 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 模型详情 | 关联提供商 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 链路追踪 | 系统设置 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
## 部署
|
||||
|
||||
### Docker Compose(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 克隆代码
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd aether
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
|
||||
# 3. 部署
|
||||
./deploy.sh # 自动构建、启动、迁移
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
# 启动依赖
|
||||
docker-compose up -d postgres redis
|
||||
|
||||
# 后端
|
||||
uv sync
|
||||
./dev.sh
|
||||
|
||||
# 前端
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 必需配置
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DB_PASSWORD` | PostgreSQL 数据库密码 |
|
||||
| `REDIS_PASSWORD` | Redis 密码 |
|
||||
| `JWT_SECRET_KEY` | JWT 签名密钥(使用 `generate_keys.py` 生成) |
|
||||
| `ENCRYPTION_KEY` | API Key 加密密钥(更换后需重新配置 Provider Key) |
|
||||
| `ADMIN_EMAIL` | 初始管理员邮箱 |
|
||||
| `ADMIN_USERNAME` | 初始管理员用户名 |
|
||||
| `ADMIN_PASSWORD` | 初始管理员密码 |
|
||||
|
||||
### 可选配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `APP_PORT` | 8084 | 应用端口 |
|
||||
| `API_KEY_PREFIX` | sk | API Key 前缀 |
|
||||
| `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) |
|
||||
| `GUNICORN_WORKERS` | 4 | Gunicorn 工作进程数 |
|
||||
| `DB_PORT` | 5432 | PostgreSQL 端口 |
|
||||
| `REDIS_PORT` | 6379 | Redis 端口 |
|
||||
|
||||
## Q&A
|
||||
|
||||
### Q: 如何开启/关闭请求体记录?
|
||||
|
||||
1. 管理员在系统设置中, 设置日志记录的记录详细程度.
|
||||
|
||||
- Base: 基本请求信息。
|
||||
- Headers: Base + 请求头。
|
||||
- Full: Base + 请求头 + 请求体。
|
||||
|
||||
### Q: 管理员如何给模型配置1M上下文 / 1H缓存 能力支持?
|
||||
|
||||
1. 在模型管理中, 给模型设置1M上下文 / 1H缓存的能力支持, 并配置好价格.
|
||||
2. 在提供商管理中, 给端点添加支持1M上下文 / 1H缓存的能力的密钥并勾选1M上下文 / 1H缓存能里标签.
|
||||
|
||||
### Q: 用户如何使用1H缓存?
|
||||
|
||||
1. 用户在管理管理中针对指定模型使用1H缓存策略, 或者在密钥管理中针对指定密钥使用1H缓存策略.
|
||||
注意: 用户若对密钥设置强制1H缓存, 则该密钥只能使用支持1H缓存的模型.
|
||||
|
||||
### Q: 如何配置负载均衡?
|
||||
|
||||
在管理后台「提供商管理中」中切换调度模式,系统提供两种调度策略:
|
||||
|
||||
1. **提供商优先 (provider)**:按 Provider 优先级排序,同优先级内按 Key 的内部优先级排序,相同优先级通过哈希分散实现负载均衡。适合希望优先使用特定供应商的场景。
|
||||
|
||||
2. **全局 Key 优先 (global_key)**:忽略 Provider 层级,所有 Key 按全局优先级统一排序,相同优先级通过哈希分散实现负载均衡。适合跨 Provider 统一调度、最大化利用所有 Key 的场景。
|
||||
|
||||
### Q: 提供商免费套餐的计费模式会计入成本吗?
|
||||
|
||||
免费套餐的计费模式, 可以视作倍率为0, 因此产生的记录不会计入倍率费用。
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [Aether 非商业开源许可证](LICENSE)。
|
||||
51
alembic.ini
Normal file
@@ -0,0 +1,51 @@
|
||||
# Alembic 配置文件
|
||||
# 用于数据库版本化迁移
|
||||
|
||||
[alembic]
|
||||
# 迁移脚本存放目录
|
||||
script_location = alembic
|
||||
|
||||
# 模板文件
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# 时区(用于生成迁移文件的时间戳)
|
||||
timezone = UTC
|
||||
|
||||
# 数据库连接 URL(会被 env.py 从环境变量覆盖)
|
||||
# Docker 环境中会从 DATABASE_URL 环境变量读取
|
||||
sqlalchemy.url = postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether
|
||||
|
||||
# 日志配置
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
101
alembic/env.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Alembic 环境配置
|
||||
用于数据库迁移的运行时环境设置
|
||||
"""
|
||||
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# 加载 .env 文件(本地开发时需要)
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 导入所有数据库模型(确保 Alembic 能检测到所有表)
|
||||
from src.models.database import Base
|
||||
|
||||
# Alembic Config 对象
|
||||
config = context.config
|
||||
|
||||
# 从环境变量获取数据库 URL
|
||||
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker-compose 保持一致)
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
db_password = os.getenv("DB_PASSWORD", "")
|
||||
db_host = os.getenv("DB_HOST", "localhost")
|
||||
db_port = os.getenv("DB_PORT", "5432")
|
||||
db_name = os.getenv("DB_NAME", "aether")
|
||||
db_user = os.getenv("DB_USER", "postgres")
|
||||
database_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
# 配置日志
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# 目标元数据(包含所有表定义)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
离线模式运行迁移
|
||||
|
||||
在离线模式下,不需要连接数据库,
|
||||
只生成 SQL 脚本
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True, # 比较列类型变更
|
||||
compare_server_default=True, # 比较默认值变更
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
在线模式运行迁移
|
||||
|
||||
在线模式下,直接连接数据库执行迁移
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True, # 比较列类型变更
|
||||
compare_server_default=True, # 比较默认值变更
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
# 根据模式选择运行方式
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
${downgrades if downgrades else "pass"}
|
||||
771
alembic/versions/20251210_baseline.py
Normal file
@@ -0,0 +1,771 @@
|
||||
"""Baseline migration - all tables consolidated
|
||||
|
||||
Revision ID: 20251210_baseline
|
||||
Revises:
|
||||
Create Date: 2024-12-10
|
||||
|
||||
This is the consolidated baseline migration that creates all tables from scratch.
|
||||
Includes all schema changes up to circuit breaker v2.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers
|
||||
revision = "20251210_baseline"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create ENUM types
|
||||
op.execute("CREATE TYPE userrole AS ENUM ('admin', 'user')")
|
||||
op.execute(
|
||||
"CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier')"
|
||||
)
|
||||
|
||||
# ==================== users ====================
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("email", sa.String(255), unique=True, index=True, nullable=False),
|
||||
sa.Column("username", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.Enum("admin", "user", name="userrole", create_type=False),
|
||||
nullable=False,
|
||||
server_default="user",
|
||||
),
|
||||
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("model_capability_settings", sa.JSON, nullable=True),
|
||||
sa.Column("quota_usd", sa.Float, nullable=True),
|
||||
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("is_deleted", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# ==================== providers ====================
|
||||
op.create_table(
|
||||
"providers",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("display_name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("website", sa.String(500), nullable=True),
|
||||
sa.Column(
|
||||
"billing_type",
|
||||
sa.Enum(
|
||||
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
|
||||
),
|
||||
nullable=False,
|
||||
server_default="pay_as_you_go",
|
||||
),
|
||||
sa.Column("monthly_quota_usd", sa.Float, nullable=True),
|
||||
sa.Column("monthly_used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("quota_reset_day", sa.Integer, server_default="30"),
|
||||
sa.Column("quota_last_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("quota_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rpm_limit", sa.Integer, nullable=True),
|
||||
sa.Column("rpm_used", sa.Integer, server_default="0"),
|
||||
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("provider_priority", sa.Integer, server_default="100"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_limit", sa.Integer, nullable=True),
|
||||
sa.Column("config", sa.JSON, nullable=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
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== global_models ====================
|
||||
op.create_table(
|
||||
"global_models",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("display_name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("icon_url", sa.String(500), nullable=True),
|
||||
sa.Column("official_url", sa.String(500), nullable=True),
|
||||
sa.Column("default_price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("default_tiered_pricing", sa.JSON, nullable=False),
|
||||
sa.Column("default_supports_vision", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_function_calling", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_streaming", sa.Boolean, server_default="true", nullable=True),
|
||||
sa.Column("default_supports_extended_thinking", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_image_generation", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("supported_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("usage_count", sa.Integer, server_default="0", nullable=False, index=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
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== api_keys ====================
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("key_hash", sa.String(64), unique=True, index=True, nullable=False),
|
||||
sa.Column("key_encrypted", sa.Text, nullable=True),
|
||||
sa.Column("name", sa.String(100), nullable=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("balance_used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("current_balance_usd", sa.Float, nullable=True),
|
||||
sa.Column("is_standalone", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_api_formats", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, server_default="100"),
|
||||
sa.Column("concurrent_limit", sa.Integer, server_default="5", nullable=True),
|
||||
sa.Column("force_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("auto_delete_on_expiry", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== provider_endpoints ====================
|
||||
op.create_table(
|
||||
"provider_endpoints",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("api_format", sa.String(50), nullable=False),
|
||||
sa.Column("base_url", sa.String(500), nullable=False),
|
||||
sa.Column("headers", sa.JSON, nullable=True),
|
||||
sa.Column("timeout", sa.Integer, server_default="300"),
|
||||
sa.Column("max_retries", sa.Integer, server_default="3"),
|
||||
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("custom_path", sa.String(200), nullable=True),
|
||||
sa.Column("config", sa.JSON, nullable=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("provider_id", "api_format", name="uq_provider_api_format"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_endpoint_format_active", "provider_endpoints", ["api_format", "is_active"]
|
||||
)
|
||||
|
||||
# ==================== models ====================
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("provider_model_name", sa.String(200), nullable=False),
|
||||
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("tiered_pricing", sa.JSON, nullable=True),
|
||||
sa.Column("supports_vision", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_function_calling", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_streaming", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_extended_thinking", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_image_generation", sa.Boolean, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("is_available", sa.Boolean, server_default="true"),
|
||||
sa.Column("config", sa.JSON, nullable=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("provider_id", "provider_model_name", name="uq_provider_model"),
|
||||
)
|
||||
|
||||
# ==================== model_mappings ====================
|
||||
op.create_table(
|
||||
"model_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("source_model", sa.String(200), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"target_global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True, index=True
|
||||
),
|
||||
sa.Column("mapping_type", sa.String(20), nullable=False, server_default="alias", index=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("source_model", "provider_id", name="uq_model_mapping_source_provider"),
|
||||
)
|
||||
|
||||
# ==================== provider_api_keys ====================
|
||||
op.create_table(
|
||||
"provider_api_keys",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("api_key", sa.String(500), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("note", sa.String(500), nullable=True),
|
||||
sa.Column("rate_multiplier", sa.Float, server_default="1.0", nullable=False),
|
||||
sa.Column("internal_priority", sa.Integer, server_default="50"),
|
||||
sa.Column("global_priority", sa.Integer, nullable=True),
|
||||
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("daily_limit", sa.Integer, nullable=True),
|
||||
sa.Column("monthly_limit", sa.Integer, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("learned_max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_429_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("rpm_429_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("last_429_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_429_type", sa.String(50), nullable=True),
|
||||
sa.Column("last_concurrent_peak", sa.Integer, nullable=True),
|
||||
sa.Column("adjustment_history", sa.JSON, nullable=True),
|
||||
# Sliding window fields (replaces high_utilization_start)
|
||||
sa.Column("utilization_samples", sa.JSON, nullable=True),
|
||||
sa.Column("last_probe_increase_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("health_score", sa.Float, server_default="1.0"),
|
||||
sa.Column("consecutive_failures", sa.Integer, server_default="0"),
|
||||
sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cache_ttl_minutes", sa.Integer, server_default="5", nullable=False),
|
||||
sa.Column("max_probe_interval_minutes", sa.Integer, server_default="32", nullable=False),
|
||||
sa.Column("circuit_breaker_open", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column("circuit_breaker_open_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("next_probe_at", sa.DateTime(timezone=True), nullable=True),
|
||||
# Circuit breaker v2 fields
|
||||
sa.Column("request_results_window", sa.JSON, nullable=True),
|
||||
sa.Column("half_open_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("half_open_successes", sa.Integer, server_default="0", nullable=True),
|
||||
sa.Column("half_open_failures", sa.Integer, server_default="0", nullable=True),
|
||||
sa.Column("request_count", sa.Integer, server_default="0"),
|
||||
sa.Column("success_count", sa.Integer, server_default="0"),
|
||||
sa.Column("error_count", sa.Integer, server_default="0"),
|
||||
sa.Column("total_response_time_ms", sa.Integer, server_default="0"),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_msg", sa.Text, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=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
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== usage ====================
|
||||
op.create_table(
|
||||
"usage",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("request_id", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("provider", sa.String(100), nullable=False),
|
||||
sa.Column("model", sa.String(100), nullable=False),
|
||||
sa.Column("target_model", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("output_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("total_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("cache_creation_input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("cache_read_input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("input_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("output_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("request_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_input_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_output_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_request_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("rate_multiplier", sa.Float, server_default="1.0"),
|
||||
sa.Column("input_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("output_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("cache_creation_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("cache_read_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("request_type", sa.String(50), nullable=True),
|
||||
sa.Column("api_format", sa.String(50), nullable=True),
|
||||
sa.Column("is_stream", sa.Boolean, server_default="false"),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("response_time_ms", sa.Integer, nullable=True),
|
||||
sa.Column("status", sa.String(20), server_default="completed", nullable=False, index=True),
|
||||
sa.Column("request_headers", sa.JSON, nullable=True),
|
||||
sa.Column("request_body", sa.JSON, nullable=True),
|
||||
sa.Column("provider_request_headers", sa.JSON, nullable=True),
|
||||
sa.Column("response_headers", sa.JSON, nullable=True),
|
||||
sa.Column("response_body", sa.JSON, nullable=True),
|
||||
sa.Column("request_body_compressed", sa.LargeBinary, nullable=True),
|
||||
sa.Column("response_body_compressed", sa.LargeBinary, nullable=True),
|
||||
sa.Column("request_metadata", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== user_quotas ====================
|
||||
op.create_table(
|
||||
"user_quotas",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("quota_type", sa.String(50), nullable=False),
|
||||
sa.Column("quota_usd", sa.Float, nullable=False),
|
||||
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("is_active", sa.Boolean, 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
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== system_configs ====================
|
||||
op.create_table(
|
||||
"system_configs",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("key", sa.String(100), unique=True, nullable=False),
|
||||
sa.Column("value", sa.JSON, nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=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
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== user_preferences ====================
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("bio", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"default_provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True
|
||||
),
|
||||
sa.Column("theme", sa.String(20), server_default="light"),
|
||||
sa.Column("language", sa.String(10), server_default="zh-CN"),
|
||||
sa.Column("timezone", sa.String(50), server_default="Asia/Shanghai"),
|
||||
sa.Column("email_notifications", sa.Boolean, server_default="true"),
|
||||
sa.Column("usage_alerts", sa.Boolean, server_default="true"),
|
||||
sa.Column("announcement_notifications", sa.Boolean, 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
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== announcements ====================
|
||||
op.create_table(
|
||||
"announcements",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("type", sa.String(20), server_default="info"),
|
||||
sa.Column("priority", sa.Integer, server_default="0"),
|
||||
sa.Column(
|
||||
"author_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", index=True),
|
||||
sa.Column("is_pinned", sa.Boolean, server_default="false"),
|
||||
sa.Column("start_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== announcement_reads ====================
|
||||
op.create_table(
|
||||
"announcement_reads",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"announcement_id", sa.String(36), sa.ForeignKey("announcements.id"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"read_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "announcement_id", name="uq_user_announcement"),
|
||||
)
|
||||
|
||||
# ==================== audit_logs ====================
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("event_type", sa.String(50), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("api_key_id", sa.String(36), nullable=True),
|
||||
sa.Column("description", sa.Text, nullable=False),
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||
sa.Column("request_id", sa.String(100), nullable=True, index=True),
|
||||
sa.Column("event_metadata", sa.JSON, nullable=True),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== request_candidates ====================
|
||||
op.create_table(
|
||||
"request_candidates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("request_id", sa.String(100), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("candidate_index", sa.Integer, nullable=False),
|
||||
sa.Column("retry_index", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("skip_reason", sa.Text, nullable=True),
|
||||
sa.Column("is_cached", sa.Boolean, server_default="false"),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_type", sa.String(50), nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("latency_ms", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_requests", sa.Integer, nullable=True),
|
||||
sa.Column("extra_data", sa.JSON, nullable=True),
|
||||
sa.Column("required_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"request_id", "candidate_index", "retry_index", name="uq_request_candidate_with_retry"
|
||||
),
|
||||
)
|
||||
op.create_index("idx_request_candidates_request_id", "request_candidates", ["request_id"])
|
||||
op.create_index("idx_request_candidates_status", "request_candidates", ["status"])
|
||||
op.create_index("idx_request_candidates_provider_id", "request_candidates", ["provider_id"])
|
||||
|
||||
# ==================== stats_daily ====================
|
||||
op.create_table(
|
||||
"stats_daily",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False, unique=True, index=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("actual_total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("input_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("output_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("cache_creation_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("cache_read_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("fallback_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("unique_models", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("unique_providers", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== stats_summary ====================
|
||||
op.create_table(
|
||||
"stats_summary",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("cutoff_date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("all_time_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("all_time_output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"all_time_cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False
|
||||
),
|
||||
sa.Column("all_time_cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("all_time_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("all_time_actual_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("total_users", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("active_users", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("total_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("active_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== stats_user_daily ====================
|
||||
op.create_table(
|
||||
"stats_user_daily",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "date", name="uq_stats_user_daily"),
|
||||
)
|
||||
op.create_index("idx_stats_user_daily_user_date", "stats_user_daily", ["user_id", "date"])
|
||||
|
||||
# ==================== api_key_provider_mappings ====================
|
||||
op.create_table(
|
||||
"api_key_provider_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("priority_adjustment", sa.Integer, server_default="0"),
|
||||
sa.Column("weight_multiplier", sa.Float, server_default="1.0"),
|
||||
sa.Column("is_enabled", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("api_key_id", "provider_id", name="uq_apikey_provider"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_apikey_provider_enabled", "api_key_provider_mappings", ["api_key_id", "is_enabled"]
|
||||
)
|
||||
|
||||
# ==================== provider_usage_tracking ====================
|
||||
op.create_table(
|
||||
"provider_usage_tracking",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("successful_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("failed_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_response_time_ms", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
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
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_provider_window", "provider_usage_tracking", ["provider_id", "window_start"]
|
||||
)
|
||||
op.create_index("idx_window_time", "provider_usage_tracking", ["window_start", "window_end"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop tables in reverse order (respecting foreign key dependencies)
|
||||
op.drop_table("provider_usage_tracking")
|
||||
op.drop_table("api_key_provider_mappings")
|
||||
op.drop_table("stats_user_daily")
|
||||
op.drop_table("stats_summary")
|
||||
op.drop_table("stats_daily")
|
||||
op.drop_table("request_candidates")
|
||||
op.drop_table("audit_logs")
|
||||
op.drop_table("announcement_reads")
|
||||
op.drop_table("announcements")
|
||||
op.drop_table("user_preferences")
|
||||
op.drop_table("system_configs")
|
||||
op.drop_table("user_quotas")
|
||||
op.drop_table("usage")
|
||||
op.drop_table("provider_api_keys")
|
||||
op.drop_table("model_mappings")
|
||||
op.drop_table("models")
|
||||
op.drop_table("provider_endpoints")
|
||||
op.drop_table("api_keys")
|
||||
op.drop_table("global_models")
|
||||
op.drop_table("providers")
|
||||
op.drop_table("users")
|
||||
|
||||
# Drop ENUM types
|
||||
op.execute("DROP TYPE IF EXISTS providerbillingtype")
|
||||
op.execute("DROP TYPE IF EXISTS userrole")
|
||||
85
alembic/versions/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Aether - 数据库迁移说明
|
||||
|
||||
## 当前版本
|
||||
|
||||
- **Revision ID**: `aether_baseline`
|
||||
- **创建日期**: 2025-12-06
|
||||
- **状态**: 全新基线
|
||||
|
||||
## 迁移历史
|
||||
|
||||
所有历史增量迁移已清理,当前以完整 schema 作为新起点。
|
||||
|
||||
## 核心数据库结构
|
||||
|
||||
### 用户系统
|
||||
- **users**: 用户账户管理
|
||||
- **api_keys**: API 密钥管理
|
||||
- **user_quotas**: 用户配额管理
|
||||
- **user_preferences**: 用户偏好设置
|
||||
|
||||
### Provider 三层架构
|
||||
- **providers**: LLM 提供商配置
|
||||
- **provider_endpoints**: Provider 的 API 端点配置
|
||||
- **provider_api_keys**: Endpoint 的具体 API 密钥
|
||||
- **api_key_provider_mappings**: 用户 API Key 到 Provider 的映射关系
|
||||
|
||||
### 模型系统
|
||||
- **global_models**: 统一模型定义(GlobalModel)
|
||||
- **models**: Provider 的模型实现和价格配置
|
||||
- **model_mappings**: 统一的别名与降级映射表
|
||||
|
||||
### 监控和追踪
|
||||
- **usage**: API 使用记录
|
||||
- **request_candidates**: 请求候选记录
|
||||
- **provider_usage_tracking**: Provider 使用统计
|
||||
- **audit_logs**: 系统审计日志
|
||||
|
||||
### 系统功能
|
||||
- **announcements**: 系统公告
|
||||
- **announcement_reads**: 公告阅读记录
|
||||
- **system_configs**: 系统配置
|
||||
|
||||
## 从旧数据库迁移
|
||||
|
||||
如需从旧数据库迁移数据,请使用迁移脚本:
|
||||
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export OLD_DATABASE_URL="postgresql://user:pass@old-host:5432/old_db"
|
||||
export NEW_DATABASE_URL="postgresql://user:pass@new-host:5432/aether"
|
||||
|
||||
# 干运行(查看迁移量)
|
||||
python scripts/migrate_data.py --dry-run
|
||||
|
||||
# 执行迁移
|
||||
python scripts/migrate_data.py
|
||||
|
||||
# 只迁移特定表
|
||||
python scripts/migrate_data.py --tables users,providers,api_keys
|
||||
|
||||
# 跳过大表
|
||||
python scripts/migrate_data.py --skip usage,audit_logs
|
||||
```
|
||||
|
||||
## 新数据库初始化
|
||||
|
||||
```bash
|
||||
# 1. 运行迁移创建表结构
|
||||
DATABASE_URL="postgresql://user:pass@host:5432/aether" uv run alembic upgrade head
|
||||
|
||||
# 2. 初始化管理员账户
|
||||
python -m src.database.init_db
|
||||
```
|
||||
|
||||
## 未来迁移
|
||||
|
||||
基于 `aether_baseline` 创建增量迁移:
|
||||
|
||||
```bash
|
||||
# 修改模型后,生成新的迁移
|
||||
DATABASE_URL="..." uv run alembic revision --autogenerate -m "描述变更"
|
||||
|
||||
# 应用迁移
|
||||
DATABASE_URL="..." uv run alembic upgrade head
|
||||
```
|
||||
213
deploy.sh
Executable file
@@ -0,0 +1,213 @@
|
||||
#!/bin/bash
|
||||
# 智能部署脚本 - 自动检测依赖/代码/迁移变化
|
||||
#
|
||||
# 用法:
|
||||
# 部署/更新: ./deploy.sh (自动检测所有变化)
|
||||
# 强制重建: ./deploy.sh --rebuild-base
|
||||
# 强制全部重建: ./deploy.sh --force
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 兼容 docker-compose 和 docker compose
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
DC="docker-compose"
|
||||
else
|
||||
DC="docker compose"
|
||||
fi
|
||||
|
||||
# 缓存文件
|
||||
HASH_FILE=".deps-hash"
|
||||
CODE_HASH_FILE=".code-hash"
|
||||
MIGRATION_HASH_FILE=".migration-hash"
|
||||
|
||||
# 计算依赖文件的哈希值
|
||||
calc_deps_hash() {
|
||||
cat pyproject.toml frontend/package.json frontend/package-lock.json 2>/dev/null | md5sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# 计算代码文件的哈希值
|
||||
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
|
||||
}
|
||||
|
||||
# 计算迁移文件的哈希值
|
||||
calc_migration_hash() {
|
||||
find alembic/versions -name "*.py" -type f 2>/dev/null | sort | xargs cat 2>/dev/null | md5sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# 检查依赖是否变化
|
||||
check_deps_changed() {
|
||||
local current_hash=$(calc_deps_hash)
|
||||
if [ -f "$HASH_FILE" ]; then
|
||||
local saved_hash=$(cat "$HASH_FILE")
|
||||
if [ "$current_hash" = "$saved_hash" ]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 检查代码是否变化
|
||||
check_code_changed() {
|
||||
local current_hash=$(calc_code_hash)
|
||||
if [ -f "$CODE_HASH_FILE" ]; then
|
||||
local saved_hash=$(cat "$CODE_HASH_FILE")
|
||||
if [ "$current_hash" = "$saved_hash" ]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 检查迁移是否变化
|
||||
check_migration_changed() {
|
||||
local current_hash=$(calc_migration_hash)
|
||||
if [ -f "$MIGRATION_HASH_FILE" ]; then
|
||||
local saved_hash=$(cat "$MIGRATION_HASH_FILE")
|
||||
if [ "$current_hash" = "$saved_hash" ]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 保存哈希
|
||||
save_deps_hash() { calc_deps_hash > "$HASH_FILE"; }
|
||||
save_code_hash() { calc_code_hash > "$CODE_HASH_FILE"; }
|
||||
save_migration_hash() { calc_migration_hash > "$MIGRATION_HASH_FILE"; }
|
||||
|
||||
# 构建基础镜像
|
||||
build_base() {
|
||||
echo ">>> Building base image (dependencies)..."
|
||||
docker build -f Dockerfile.base -t aether-base:latest .
|
||||
save_deps_hash
|
||||
}
|
||||
|
||||
# 构建应用镜像
|
||||
build_app() {
|
||||
echo ">>> Building app image (code only)..."
|
||||
docker build -f Dockerfile.app -t aether-app:latest .
|
||||
save_code_hash
|
||||
}
|
||||
|
||||
# 运行数据库迁移
|
||||
run_migration() {
|
||||
echo ">>> Running database migration..."
|
||||
|
||||
# 尝试运行 upgrade head,捕获错误
|
||||
UPGRADE_OUTPUT=$($DC exec -T app alembic upgrade head 2>&1) && {
|
||||
echo "$UPGRADE_OUTPUT"
|
||||
save_migration_hash
|
||||
return 0
|
||||
}
|
||||
|
||||
# 检查是否是因为找不到旧版本(基线重置场景)
|
||||
if echo "$UPGRADE_OUTPUT" | grep -q "Can't locate revision"; then
|
||||
echo ">>> Detected baseline reset: old revision not found in migrations"
|
||||
echo ">>> Clearing old version and stamping to new baseline..."
|
||||
|
||||
# 先清除旧的版本记录,再 stamp 到新基线
|
||||
$DC exec -T app python -c "
|
||||
from sqlalchemy import create_engine, text
|
||||
import os
|
||||
engine = create_engine(os.environ['DATABASE_URL'])
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text('DELETE FROM alembic_version'))
|
||||
conn.commit()
|
||||
print('Old version cleared')
|
||||
"
|
||||
# 获取最新的迁移版本(匹配 revision_id (head) 格式)
|
||||
LATEST_VERSION=$($DC exec -T app alembic heads 2>/dev/null | grep -oE '^[0-9a-zA-Z_]+' | head -1)
|
||||
if [ -n "$LATEST_VERSION" ]; then
|
||||
$DC exec -T app alembic stamp "$LATEST_VERSION"
|
||||
echo ">>> Database stamped to $LATEST_VERSION"
|
||||
save_migration_hash
|
||||
else
|
||||
echo ">>> ERROR: Could not determine latest migration version"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# 其他错误,直接输出并退出
|
||||
echo "$UPGRADE_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 强制全部重建
|
||||
if [ "$1" = "--force" ] || [ "$1" = "-f" ]; then
|
||||
echo ">>> Force rebuilding everything..."
|
||||
build_base
|
||||
build_app
|
||||
$DC up -d --force-recreate
|
||||
sleep 3
|
||||
run_migration
|
||||
docker image prune -f
|
||||
echo ">>> Done!"
|
||||
$DC ps
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 强制重建基础镜像
|
||||
if [ "$1" = "--rebuild-base" ] || [ "$1" = "-r" ]; then
|
||||
build_base
|
||||
echo ">>> Base image rebuilt. Run ./deploy.sh to deploy."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 拉取最新代码
|
||||
echo ">>> Pulling latest code..."
|
||||
git pull
|
||||
|
||||
# 标记是否需要重启
|
||||
NEED_RESTART=false
|
||||
|
||||
# 检查基础镜像是否存在,或依赖是否变化
|
||||
if ! docker image inspect aether-base:latest >/dev/null 2>&1; then
|
||||
echo ">>> Base image not found, building..."
|
||||
build_base
|
||||
NEED_RESTART=true
|
||||
elif check_deps_changed; then
|
||||
echo ">>> Dependencies changed, rebuilding base image..."
|
||||
build_base
|
||||
NEED_RESTART=true
|
||||
else
|
||||
echo ">>> Dependencies unchanged."
|
||||
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 check_code_changed; then
|
||||
echo ">>> Code changed, rebuilding app image..."
|
||||
build_app
|
||||
NEED_RESTART=true
|
||||
else
|
||||
echo ">>> Code unchanged."
|
||||
fi
|
||||
|
||||
# 只在有变化时重启
|
||||
if [ "$NEED_RESTART" = true ]; then
|
||||
echo ">>> Restarting services..."
|
||||
$DC up -d
|
||||
else
|
||||
echo ">>> No changes detected, skipping restart."
|
||||
fi
|
||||
|
||||
# 检查迁移变化
|
||||
if check_migration_changed; then
|
||||
echo ">>> Migration files changed, running database migration..."
|
||||
sleep 3
|
||||
run_migration
|
||||
else
|
||||
echo ">>> Migration unchanged."
|
||||
fi
|
||||
|
||||
# 清理
|
||||
docker image prune -f >/dev/null 2>&1 || true
|
||||
|
||||
echo ">>> Done!"
|
||||
$DC ps
|
||||
19
dev.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# 本地开发启动脚本
|
||||
clear
|
||||
|
||||
# 加载 .env 文件
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
# 构建 DATABASE_URL
|
||||
export DATABASE_URL="postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether"
|
||||
|
||||
# 启动 uvicorn(热重载模式)
|
||||
echo "🚀 启动本地开发服务器..."
|
||||
echo "📍 后端地址: http://localhost:8084"
|
||||
echo "📊 数据库: ${DATABASE_URL}"
|
||||
echo ""
|
||||
|
||||
uv run uvicorn src.main:app --reload --port 8084
|
||||
75
docker-compose.yml
Normal file
@@ -0,0 +1,75 @@
|
||||
# Aether 部署配置
|
||||
# 使用 ./deploy.sh 自动部署
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: aether-postgres
|
||||
environment:
|
||||
POSTGRES_DB: aether
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: aether-redis
|
||||
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
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
image: aether-app:latest
|
||||
container_name: aether-app
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
||||
PORT: 8084
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
JWT_ALGORITHM: HS256
|
||||
JWT_EXPIRATION_DELTA: 86400
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
API_KEY_PREFIX: ${API_KEY_PREFIX:-sk}
|
||||
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
|
||||
TZ: Asia/Shanghai
|
||||
PYTHONIOENCODING: utf-8
|
||||
LANG: C.UTF-8
|
||||
LC_ALL: C.UTF-8
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${APP_PORT:-8084}:80"
|
||||
volumes:
|
||||
# 挂载日志目录到主机,便于调试和持久化
|
||||
- ./logs:/app/logs
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
BIN
docs/screenshots/dashboard.png
Normal file
|
After Width: | Height: | Size: 583 KiB |
BIN
docs/screenshots/health.png
Normal file
|
After Width: | Height: | Size: 572 KiB |
BIN
docs/screenshots/home.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
docs/screenshots/model-detail.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
docs/screenshots/model-providers.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
docs/screenshots/providers.png
Normal file
|
After Width: | Height: | Size: 598 KiB |
BIN
docs/screenshots/settings.png
Normal file
|
After Width: | Height: | Size: 549 KiB |
BIN
docs/screenshots/tracing.png
Normal file
|
After Width: | Height: | Size: 323 KiB |
BIN
docs/screenshots/usage.png
Normal file
|
After Width: | Height: | Size: 640 KiB |
BIN
docs/screenshots/users.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
8
frontend/.eslintignore
Normal file
@@ -0,0 +1,8 @@
|
||||
dist
|
||||
node_modules
|
||||
*.d.ts
|
||||
vite.config.ts
|
||||
vitest.config.ts
|
||||
postcss.config.js
|
||||
tailwind.config.js
|
||||
components.json
|
||||
62
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
],
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
parser: '@typescript-eslint/parser',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'vue'],
|
||||
rules: {
|
||||
// TypeScript 规则
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
|
||||
// Vue 规则
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'error', // 防止 XSS 攻击
|
||||
'vue/component-api-style': ['error', ['script-setup']],
|
||||
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
|
||||
'vue/custom-event-name-casing': ['error', 'camelCase'],
|
||||
'vue/define-macros-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['defineProps', 'defineEmits'],
|
||||
},
|
||||
],
|
||||
'vue/html-comment-content-spacing': ['error', 'always'],
|
||||
'vue/no-unused-refs': 'error',
|
||||
'vue/no-useless-v-bind': 'error',
|
||||
'vue/padding-line-between-blocks': ['error', 'always'],
|
||||
'vue/prefer-separate-static-class': 'error',
|
||||
|
||||
// 一般规则
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'prefer-template': 'error',
|
||||
'prefer-arrow-callback': 'error',
|
||||
},
|
||||
ignorePatterns: ['dist', 'node_modules', '*.config.js', '*.config.ts'],
|
||||
}
|
||||
25
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
# dist
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
1532
frontend/DESIGN_SYSTEM.md
Normal file
16
frontend/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/style.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui"
|
||||
}
|
||||
}
|
||||
22
frontend/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/aether_adaptive.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Aether</title>
|
||||
|
||||
<!-- 预加载关键字体以优化首屏渲染 -->
|
||||
<link rel="preload" href="/fonts/TiemposText/TiemposText-Regular.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/fonts/TiemposText/TiemposText-Medium.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/fonts/StyreneA/StyreneA-Regular.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/fonts/StyreneA/StyreneA-Medium.woff2" as="font" type="font/woff2" crossorigin />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
6613
frontend/package-lock.json
generated
Normal file
58
frontend/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:with-typecheck": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .eslintignore",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/three": "^0.180.0",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"axios": "^1.12.1",
|
||||
"chart.js": "^4.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-vue-next": "^0.544.0",
|
||||
"marked": "^16.0.0",
|
||||
"pinia": "^3.0.3",
|
||||
"radix-vue": "^1.9.17",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.180.0",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
||||
"@typescript-eslint/parser": "^8.47.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/ui": "^4.0.10",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"baseline-browser-mapping": "^2.9.4",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2",
|
||||
"vitest": "^4.0.10",
|
||||
"vue-tsc": "^3.0.5"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
16
frontend/public/aether.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
9
frontend/public/aether_adaptive.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
1
frontend/public/claude-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/fonts/StyreneA/StyreneA-Bold.otf
Normal file
BIN
frontend/public/fonts/StyreneA/StyreneA-Bold.woff2
Normal file
BIN
frontend/public/fonts/StyreneA/StyreneA-Medium.otf
Normal file
BIN
frontend/public/fonts/StyreneA/StyreneA-Medium.woff2
Normal file
BIN
frontend/public/fonts/StyreneA/StyreneA-Regular.otf
Normal file
BIN
frontend/public/fonts/StyreneA/StyreneA-Regular.woff2
Normal file
BIN
frontend/public/fonts/TiemposText/TiemposText-Medium.otf
Normal file
BIN
frontend/public/fonts/TiemposText/TiemposText-Medium.woff2
Normal file
BIN
frontend/public/fonts/TiemposText/TiemposText-Regular.otf
Normal file
BIN
frontend/public/fonts/TiemposText/TiemposText-Regular.woff2
Normal file
BIN
frontend/public/fonts/TiemposText/TiemposText-Semibold.otf
Normal file
BIN
frontend/public/fonts/TiemposText/TiemposText-Semibold.woff2
Normal file
1
frontend/public/gemini-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
frontend/public/openai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" width="1em" height="1em" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
100
frontend/src/App.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
<ConfirmContainer />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onErrorCaptured } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ToastContainer from '@/components/ToastContainer.vue'
|
||||
import ConfirmContainer from '@/components/ConfirmContainer.vue'
|
||||
import apiClient from '@/api/client'
|
||||
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 立即检查token,如果存在就设置到store中
|
||||
const storedToken = apiClient.getToken()
|
||||
if (storedToken) {
|
||||
authStore.token = storedToken
|
||||
}
|
||||
|
||||
// 全局错误处理器 - 只处理特定错误,避免完全吞掉所有错误
|
||||
onErrorCaptured((error: Error) => {
|
||||
log.error('Error captured in component', error)
|
||||
// 对于非关键错误,不阻止传播
|
||||
return true
|
||||
})
|
||||
|
||||
// 统一的模块加载错误处理
|
||||
let moduleLoadFailureCount = 0
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// 处理未捕获的 Promise 拒绝
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const error = event.reason
|
||||
|
||||
// 只处理模块加载失败的情况
|
||||
if (error?.message?.includes('Failed to fetch dynamically imported module')) {
|
||||
event.preventDefault() // 阻止控制台显示这个特定错误
|
||||
|
||||
if (moduleLoadFailureCount < NETWORK_CONFIG.MODULE_LOAD_RETRY_LIMIT) {
|
||||
moduleLoadFailureCount++
|
||||
log.info(`模块加载失败,尝试刷新页面 (${moduleLoadFailureCount}/${NETWORK_CONFIG.MODULE_LOAD_RETRY_LIMIT})`)
|
||||
window.location.reload()
|
||||
} else {
|
||||
// 超过最大重试次数,显示友好提示
|
||||
alert('页面加载失败,请手动刷新浏览器。如问题持续,请清除浏览器缓存后重试。')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 其他 Promise 错误记录日志
|
||||
log.error('Unhandled promise rejection', event.reason)
|
||||
})
|
||||
|
||||
// 处理全局错误
|
||||
window.addEventListener('error', (event) => {
|
||||
// 过滤掉常见的无害警告
|
||||
const harmlessWarnings = [
|
||||
'ResizeObserver loop completed with undelivered notifications',
|
||||
'ResizeObserver loop limit exceeded'
|
||||
]
|
||||
|
||||
const isHarmless = harmlessWarnings.some(warning =>
|
||||
event.message?.includes(warning)
|
||||
)
|
||||
|
||||
if (isHarmless) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// 记录其他错误
|
||||
if (event.error) {
|
||||
log.error('Global error', event.error)
|
||||
} else {
|
||||
log.warn('Global error event', {
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 延迟检查认证状态,让页面先加载
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await authStore.checkAuth()
|
||||
} catch (error) {
|
||||
// 即使checkAuth失败,也不要做任何会导致退出的操作
|
||||
log.warn('Auth check failed, but keeping session', error)
|
||||
}
|
||||
}, AUTH_CONFIG.TOKEN_REFRESH_INTERVAL)
|
||||
})
|
||||
</script>
|
||||
177
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// API密钥管理相关接口定义
|
||||
export interface AdminApiKey {
|
||||
id: string // UUID
|
||||
user_id: string // UUID
|
||||
user_email?: string
|
||||
username?: string
|
||||
name?: string
|
||||
key_display?: string // 脱敏后的密钥显示
|
||||
is_active: boolean
|
||||
is_standalone: boolean // 是否为独立余额Key
|
||||
balance_used_usd?: number // 已使用余额(仅独立Key)
|
||||
current_balance_usd?: number | null // 当前余额(独立Key预付费模式,null表示无限制)
|
||||
total_requests?: number
|
||||
total_tokens?: number
|
||||
total_cost_usd?: number
|
||||
rate_limit?: number
|
||||
allowed_providers?: string[] | null // 允许的提供商列表
|
||||
allowed_api_formats?: string[] | null // 允许的 API 格式列表
|
||||
allowed_models?: string[] | null // 允许的模型列表
|
||||
auto_delete_on_expiry?: boolean // 过期后是否自动删除
|
||||
last_used_at?: string
|
||||
expires_at?: string
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface CreateStandaloneApiKeyRequest {
|
||||
name?: string
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number
|
||||
expire_days?: number | null // null = 永不过期
|
||||
initial_balance_usd: number // 初始余额,必须设置
|
||||
auto_delete_on_expiry?: boolean // 过期后是否自动删除
|
||||
}
|
||||
|
||||
export interface AdminApiKeysResponse {
|
||||
api_keys: AdminApiKey[]
|
||||
total: number
|
||||
limit: number
|
||||
skip: number
|
||||
}
|
||||
|
||||
export interface ApiKeyToggleResponse {
|
||||
id: string // UUID
|
||||
is_active: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
// 管理员API密钥管理相关API
|
||||
export const adminApi = {
|
||||
// 获取所有独立余额Keys列表
|
||||
async getAllApiKeys(params?: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
is_active?: boolean
|
||||
}): Promise<AdminApiKeysResponse> {
|
||||
const response = await apiClient.get<AdminApiKeysResponse>('/api/admin/api-keys', {
|
||||
params: params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 创建独立余额Key
|
||||
async createStandaloneApiKey(data: CreateStandaloneApiKeyRequest): Promise<AdminApiKey & { key: string }> {
|
||||
const response = await apiClient.post<AdminApiKey & { key: string }>(
|
||||
'/api/admin/api-keys',
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新独立余额Key
|
||||
async updateApiKey(keyId: string, data: Partial<CreateStandaloneApiKeyRequest>): Promise<AdminApiKey & { message: string }> {
|
||||
const response = await apiClient.put<AdminApiKey & { message: string }>(
|
||||
`/api/admin/api-keys/${keyId}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 切换API密钥状态(启用/禁用)
|
||||
async toggleApiKey(keyId: string): Promise<ApiKeyToggleResponse> {
|
||||
const response = await apiClient.patch<ApiKeyToggleResponse>(
|
||||
`/api/admin/api-keys/${keyId}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 删除API密钥
|
||||
async deleteApiKey(keyId: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete<{ message: string}>(
|
||||
`/api/admin/api-keys/${keyId}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 为独立余额Key调整余额
|
||||
async addApiKeyBalance(keyId: string, amountUsd: number): Promise<AdminApiKey & { message: string }> {
|
||||
const response = await apiClient.patch<AdminApiKey & { message: string }>(
|
||||
`/api/admin/api-keys/${keyId}/balance`,
|
||||
{ amount_usd: amountUsd }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取API密钥详情(可选包含完整密钥)
|
||||
async getApiKeyDetail(keyId: string, includeKey: boolean = false): Promise<AdminApiKey & { key?: string }> {
|
||||
const response = await apiClient.get<AdminApiKey & { key?: string }>(
|
||||
`/api/admin/api-keys/${keyId}`,
|
||||
{ params: { include_key: includeKey } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取完整的API密钥(用于复制)- 便捷方法
|
||||
async getFullApiKey(keyId: string): Promise<{ key: string }> {
|
||||
const response = await apiClient.get<{ key: string }>(
|
||||
`/api/admin/api-keys/${keyId}`,
|
||||
{ params: { include_key: true } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 系统配置相关
|
||||
// 获取所有系统配置
|
||||
async getAllSystemConfigs(): Promise<any[]> {
|
||||
const response = await apiClient.get<any[]>('/api/admin/system/configs')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取特定系统配置
|
||||
async getSystemConfig(key: string): Promise<{ key: string; value: any }> {
|
||||
const response = await apiClient.get<{ key: string; value: any }>(
|
||||
`/api/admin/system/configs/${key}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新系统配置
|
||||
async updateSystemConfig(
|
||||
key: string,
|
||||
value: any,
|
||||
description?: string
|
||||
): Promise<{ key: string; value: any; description?: string }> {
|
||||
const response = await apiClient.put<{ key: string; value: any; description?: string }>(
|
||||
`/api/admin/system/configs/${key}`,
|
||||
{ value, description }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 删除系统配置
|
||||
async deleteSystemConfig(key: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete<{ message: string }>(
|
||||
`/api/admin/system/configs/${key}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取系统统计
|
||||
async getSystemStats(): Promise<any> {
|
||||
const response = await apiClient.get<any>('/api/admin/system/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取可用的API格式列表
|
||||
async getApiFormats(): Promise<{ formats: Array<{ value: string; label: string; default_path: string; aliases: string[] }> }> {
|
||||
const response = await apiClient.get<{ formats: Array<{ value: string; label: string; default_path: string; aliases: string[] }> }>(
|
||||
'/api/admin/system/api-formats'
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
109
frontend/src/api/announcements.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface Announcement {
|
||||
id: string // UUID
|
||||
title: string
|
||||
content: string // Markdown格式
|
||||
type: 'info' | 'warning' | 'maintenance' | 'important'
|
||||
priority: number
|
||||
is_pinned: boolean
|
||||
is_active: boolean
|
||||
author: {
|
||||
id: string // UUID
|
||||
username: string
|
||||
}
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
is_read?: boolean
|
||||
}
|
||||
|
||||
export interface AnnouncementListResponse {
|
||||
items: Announcement[]
|
||||
total: number
|
||||
unread_count: number
|
||||
}
|
||||
|
||||
export interface CreateAnnouncementRequest {
|
||||
title: string
|
||||
content: string
|
||||
type?: 'info' | 'warning' | 'maintenance' | 'important'
|
||||
priority?: number
|
||||
is_pinned?: boolean
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
}
|
||||
|
||||
export interface UpdateAnnouncementRequest {
|
||||
title?: string
|
||||
content?: string
|
||||
type?: string
|
||||
priority?: number
|
||||
is_active?: boolean
|
||||
is_pinned?: boolean
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
}
|
||||
|
||||
export const announcementApi = {
|
||||
// 获取公告列表
|
||||
async getAnnouncements(params?: {
|
||||
active_only?: boolean
|
||||
unread_only?: boolean
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AnnouncementListResponse> {
|
||||
const response = await apiClient.get('/api/announcements', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取当前有效的公告
|
||||
async getActiveAnnouncements(): Promise<AnnouncementListResponse> {
|
||||
const response = await apiClient.get('/api/announcements/active')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取单个公告
|
||||
async getAnnouncement(id: string): Promise<Announcement> {
|
||||
const response = await apiClient.get(`/api/announcements/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 标记公告为已读
|
||||
async markAsRead(id: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.patch(`/api/announcements/${id}/read-status`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 标记所有公告为已读
|
||||
async markAllAsRead(): Promise<{ message: string }> {
|
||||
const response = await apiClient.post('/api/announcements/read-all')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取未读公告数量
|
||||
async getUnreadCount(): Promise<{ unread_count: number }> {
|
||||
const response = await apiClient.get('/api/announcements/users/me/unread-count')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 管理员方法
|
||||
// 创建公告
|
||||
async createAnnouncement(data: CreateAnnouncementRequest): Promise<{ id: string; title: string; message: string }> {
|
||||
const response = await apiClient.post('/api/announcements', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新公告
|
||||
async updateAnnouncement(id: string, data: UpdateAnnouncementRequest): Promise<{ message: string }> {
|
||||
const response = await apiClient.put(`/api/announcements/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 删除公告
|
||||
async deleteAnnouncement(id: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete(`/api/announcements/${id}`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
91
frontend/src/api/audit.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface AuditLog {
|
||||
id: string
|
||||
event_type: string
|
||||
user_id?: number
|
||||
description: string
|
||||
ip_address?: string
|
||||
status_code?: number
|
||||
error_message?: string
|
||||
metadata?: any
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AuditLogsResponse {
|
||||
items: AuditLog[]
|
||||
meta: PaginationMeta
|
||||
filters?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface AuditFilters {
|
||||
user_id?: string
|
||||
event_type?: string
|
||||
days?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
function normalizeAuditResponse(data: any): AuditLogsResponse {
|
||||
const items: AuditLog[] = data.items ?? data.logs ?? []
|
||||
const meta: PaginationMeta = data.meta ?? {
|
||||
total: data.total ?? items.length,
|
||||
limit: data.limit ?? items.length,
|
||||
offset: data.offset ?? 0,
|
||||
count: data.count ?? items.length
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
meta,
|
||||
filters: data.filters
|
||||
}
|
||||
}
|
||||
|
||||
export const auditApi = {
|
||||
// 获取当前用户的活动日志
|
||||
async getMyAuditLogs(filters?: {
|
||||
event_type?: string
|
||||
days?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AuditLogsResponse> {
|
||||
const response = await apiClient.get('/api/monitoring/my-audit-logs', { params: filters })
|
||||
return normalizeAuditResponse(response.data)
|
||||
},
|
||||
|
||||
// 获取所有审计日志 (管理员)
|
||||
async getAuditLogs(filters?: AuditFilters): Promise<AuditLogsResponse> {
|
||||
const response = await apiClient.get('/api/admin/monitoring/audit-logs', { params: filters })
|
||||
return normalizeAuditResponse(response.data)
|
||||
},
|
||||
|
||||
// 获取可疑活动 (管理员)
|
||||
async getSuspiciousActivities(hours: number = 24, limit: number = 100): Promise<{
|
||||
activities: AuditLog[]
|
||||
count: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/admin/monitoring/suspicious-activities', {
|
||||
params: { hours, limit }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 分析用户行为 (管理员)
|
||||
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
|
||||
analysis: any
|
||||
recommendations: string[]
|
||||
}> {
|
||||
const response = await apiClient.get(`/api/admin/monitoring/user-behavior/${userId}`, {
|
||||
params: { days }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
90
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
expires_in?: number
|
||||
user_id?: string // UUID
|
||||
email?: string
|
||||
username?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
language?: string
|
||||
notifications_enabled?: boolean
|
||||
[key: string]: unknown // 允许扩展其他偏好设置
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
total_requests?: number
|
||||
total_cost?: number
|
||||
last_request_at?: string
|
||||
[key: string]: unknown // 允许扩展其他统计数据
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string // UUID
|
||||
username: string
|
||||
email?: string
|
||||
role: string // 'admin' or 'user'
|
||||
is_active: boolean
|
||||
quota_usd?: number | null
|
||||
used_usd?: number
|
||||
total_usd?: number
|
||||
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_endpoints?: string[] | null // 允许使用的端点 ID 列表
|
||||
allowed_models?: string[] | null // 允许使用的模型名称列表
|
||||
created_at: string
|
||||
last_login_at?: string
|
||||
preferences?: UserPreferences
|
||||
stats?: UserStats
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
|
||||
apiClient.setToken(response.data.access_token)
|
||||
// 后端暂时没有返回 refresh_token
|
||||
if (response.data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
// 调用后端登出接口,将 Token 加入黑名单
|
||||
await apiClient.post('/api/auth/logout', {})
|
||||
} catch (error) {
|
||||
// 即使后端登出失败,也要清除本地认证信息
|
||||
console.warn('后端登出失败,仅清除本地认证信息:', error)
|
||||
} finally {
|
||||
// 清除本地认证信息
|
||||
apiClient.clearAuth()
|
||||
}
|
||||
},
|
||||
|
||||
async getCurrentUser(): Promise<User> {
|
||||
const response = await apiClient.get<User>('/api/users/me')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<LoginResponse> {
|
||||
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
apiClient.setToken(response.data.access_token)
|
||||
if (response.data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
158
frontend/src/api/cache.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 缓存监控 API 客户端
|
||||
*/
|
||||
|
||||
import api from './client'
|
||||
|
||||
export interface CacheStats {
|
||||
scheduler: string
|
||||
cache_reservation_ratio: number
|
||||
affinity_stats: {
|
||||
storage_type: string
|
||||
total_affinities: number
|
||||
active_affinities: number | string
|
||||
cache_hits: number
|
||||
cache_misses: number
|
||||
cache_hit_rate: number
|
||||
cache_invalidations: number
|
||||
provider_switches: number
|
||||
key_switches: number
|
||||
config: {
|
||||
default_ttl: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DynamicReservationConfig {
|
||||
probe_phase_requests: number
|
||||
probe_reservation: number
|
||||
stable_min_reservation: number
|
||||
stable_max_reservation: number
|
||||
low_load_threshold: number
|
||||
high_load_threshold: number
|
||||
success_count_for_full_confidence: number
|
||||
cooldown_hours_for_full_confidence: number
|
||||
}
|
||||
|
||||
export interface CacheConfig {
|
||||
cache_ttl_seconds: number
|
||||
cache_reservation_ratio: number
|
||||
dynamic_reservation?: {
|
||||
enabled: boolean
|
||||
config: DynamicReservationConfig
|
||||
description: Record<string, string>
|
||||
}
|
||||
description: {
|
||||
cache_ttl: string
|
||||
cache_reservation_ratio: string
|
||||
dynamic_reservation?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserAffinity {
|
||||
affinity_key: string
|
||||
user_api_key_name: string | null
|
||||
user_api_key_prefix: string | null // 用户 API Key 脱敏显示(前4...后4)
|
||||
is_standalone: boolean
|
||||
user_id: string | null
|
||||
username: string | null
|
||||
email: string | null
|
||||
provider_id: string
|
||||
provider_name: string | null
|
||||
endpoint_id: string
|
||||
endpoint_api_format: string | null
|
||||
endpoint_url: string | null
|
||||
key_id: string
|
||||
key_name: string | null
|
||||
key_prefix: string | null // Provider Key 脱敏显示(前4...后4)
|
||||
rate_multiplier: number
|
||||
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)
|
||||
created_at: number
|
||||
expire_at: number
|
||||
request_count: number
|
||||
}
|
||||
|
||||
export interface AffinityListResponse {
|
||||
items: UserAffinity[]
|
||||
total: number
|
||||
matched_user_id?: string | null
|
||||
}
|
||||
|
||||
export const cacheApi = {
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
async getStats(): Promise<CacheStats> {
|
||||
const response = await api.get('/api/admin/monitoring/cache/stats')
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取缓存配置
|
||||
*/
|
||||
async getConfig(): Promise<CacheConfig> {
|
||||
const response = await api.get('/api/admin/monitoring/cache/config')
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 查询用户缓存亲和性(现在返回该用户所有端点的亲和性列表)
|
||||
*
|
||||
* @param userIdentifier 用户标识符,支持:用户名、邮箱、User UUID、API Key ID
|
||||
*/
|
||||
async getUserAffinity(userIdentifier: string): Promise<UserAffinity[] | null> {
|
||||
const response = await api.get(`/api/admin/monitoring/cache/affinity/${userIdentifier}`)
|
||||
if (response.data.status === 'not_found') {
|
||||
return null
|
||||
}
|
||||
return response.data.affinities
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除用户缓存
|
||||
*
|
||||
* @param userIdentifier 用户标识符,支持:用户名、邮箱、User UUID、API Key ID
|
||||
*/
|
||||
async clearUserCache(userIdentifier: string): Promise<void> {
|
||||
await api.delete(`/api/admin/monitoring/cache/users/${userIdentifier}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
async clearAllCache(): Promise<{ count: number }> {
|
||||
const response = await api.delete('/api/admin/monitoring/cache')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除指定Provider的所有缓存
|
||||
*/
|
||||
async clearProviderCache(providerId: string): Promise<{ count: number; provider_id: string }> {
|
||||
const response = await api.delete(`/api/admin/monitoring/cache/providers/${providerId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取缓存亲和性列表
|
||||
*/
|
||||
async listAffinities(keyword?: string): Promise<AffinityListResponse> {
|
||||
const response = await api.get('/api/admin/monitoring/cache/affinities', {
|
||||
params: keyword ? { keyword } : undefined
|
||||
})
|
||||
return response.data.data
|
||||
}
|
||||
}
|
||||
|
||||
// 导出便捷函数
|
||||
export const {
|
||||
getStats,
|
||||
getConfig,
|
||||
getUserAffinity,
|
||||
clearUserCache,
|
||||
clearAllCache,
|
||||
clearProviderCache,
|
||||
listAffinities
|
||||
} = cacheApi
|
||||
254
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
// 在开发环境下使用代理,生产环境使用环境变量
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
/**
|
||||
* 判断请求是否为公共端点
|
||||
*/
|
||||
function isPublicEndpoint(url?: string, method?: string): boolean {
|
||||
if (!url) return false
|
||||
|
||||
const isHealthCheck = url.includes('/health') &&
|
||||
method?.toLowerCase() === 'get' &&
|
||||
!url.includes('/api/admin')
|
||||
|
||||
return url.includes('/public') ||
|
||||
url.includes('.json') ||
|
||||
isHealthCheck
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为认证相关请求
|
||||
*/
|
||||
function isAuthRequest(url?: string): boolean {
|
||||
return url?.includes('/auth/login') || url?.includes('/auth/refresh') || url?.includes('/auth/logout') || false
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为可刷新的认证错误
|
||||
*/
|
||||
function isRefreshableAuthError(errorDetail: string): boolean {
|
||||
const nonRefreshableErrors = [
|
||||
'用户不存在或已禁用',
|
||||
'需要管理员权限',
|
||||
'权限不足',
|
||||
'用户已禁用',
|
||||
]
|
||||
|
||||
return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg))
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance
|
||||
private token: string | null = null
|
||||
private isRefreshing = false
|
||||
private refreshPromise: Promise<AxiosResponse> | null = null
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: NETWORK_CONFIG.API_TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
this.setupInterceptors()
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置请求和响应拦截器
|
||||
*/
|
||||
private setupInterceptors(): void {
|
||||
// 请求拦截器
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
|
||||
config.url?.includes('/api/')
|
||||
|
||||
if (requiresAuth) {
|
||||
const token = this.getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => this.handleResponseError(error)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应错误
|
||||
*/
|
||||
private async handleResponseError(error: any): Promise<any> {
|
||||
const originalRequest = error.config
|
||||
|
||||
// 请求被取消
|
||||
if (axios.isCancel(error)) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 网络错误或服务器不可达
|
||||
if (!error.response) {
|
||||
log.warn('Network error or server unreachable', error.message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 认证请求错误,直接返回
|
||||
if (isAuthRequest(originalRequest?.url)) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 处理401错误
|
||||
if (error.response?.status === 401) {
|
||||
return this.handle401Error(error, originalRequest)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理401认证错误
|
||||
*/
|
||||
private async handle401Error(error: any, originalRequest: any): Promise<any> {
|
||||
// 如果不需要认证,直接返回错误
|
||||
if (isPublicEndpoint(originalRequest?.url, originalRequest?.method)) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 如果已经重试过,不再重试
|
||||
if (originalRequest._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const errorDetail = error.response?.data?.detail || ''
|
||||
log.debug('Got 401 error, attempting token refresh', { errorDetail })
|
||||
|
||||
// 检查是否为业务相关的401错误
|
||||
if (!isRefreshableAuthError(errorDetail)) {
|
||||
log.info('401 error but not authentication issue, keeping session', { errorDetail })
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 获取refresh token
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (!refreshToken) {
|
||||
log.info('No refresh token available, clearing invalid token')
|
||||
this.clearAuth()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 标记为已重试
|
||||
originalRequest._retry = true
|
||||
originalRequest._retryCount = (originalRequest._retryCount || 0) + 1
|
||||
|
||||
// 超过最大重试次数
|
||||
if (originalRequest._retryCount > AUTH_CONFIG.MAX_RETRY_COUNT) {
|
||||
log.error('Max retry attempts reached')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 如果正在刷新,等待刷新完成
|
||||
if (this.isRefreshing) {
|
||||
try {
|
||||
await this.refreshPromise
|
||||
originalRequest.headers.Authorization = `Bearer ${this.getToken()}`
|
||||
return this.client.request(originalRequest)
|
||||
} catch (refreshError) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始刷新token
|
||||
return this.refreshTokenAndRetry(refreshToken, originalRequest, error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新token并重试原始请求
|
||||
*/
|
||||
private async refreshTokenAndRetry(
|
||||
refreshToken: string,
|
||||
originalRequest: any,
|
||||
originalError: any
|
||||
): Promise<any> {
|
||||
this.isRefreshing = true
|
||||
this.refreshPromise = this.refreshToken(refreshToken)
|
||||
|
||||
try {
|
||||
const response = await this.refreshPromise
|
||||
this.setToken(response.data.access_token)
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
this.isRefreshing = false
|
||||
this.refreshPromise = null
|
||||
|
||||
// 重试原始请求
|
||||
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`
|
||||
return this.client.request(originalRequest)
|
||||
} catch (refreshError: any) {
|
||||
log.error('Token refresh failed', refreshError)
|
||||
this.isRefreshing = false
|
||||
this.refreshPromise = null
|
||||
this.clearAuth()
|
||||
return Promise.reject(originalError)
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
this.token = token
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
if (!this.token) {
|
||||
this.token = localStorage.getItem('access_token')
|
||||
}
|
||||
return this.token
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
this.token = null
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<AxiosResponse> {
|
||||
return this.client.post('/api/auth/refresh', { refresh_token: refreshToken })
|
||||
}
|
||||
|
||||
async request<T = any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.request<T>(config)
|
||||
}
|
||||
|
||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.get<T>(url, config)
|
||||
}
|
||||
|
||||
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.post<T>(url, data, config)
|
||||
}
|
||||
|
||||
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.put<T>(url, data, config)
|
||||
}
|
||||
|
||||
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.patch<T>(url, data, config)
|
||||
}
|
||||
|
||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.delete<T>(url, config)
|
||||
}
|
||||
}
|
||||
|
||||
export default new ApiClient()
|
||||
262
frontend/src/api/dashboard.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface DashboardStat {
|
||||
name: string
|
||||
value: string
|
||||
subValue?: string
|
||||
change?: string
|
||||
changeType?: 'increase' | 'decrease' | 'neutral'
|
||||
extraBadge?: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface RecentRequest {
|
||||
id: string // UUID
|
||||
user: string
|
||||
model: string
|
||||
tokens: number
|
||||
time: string
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
name: string
|
||||
status: 'active' | 'inactive'
|
||||
requests: number
|
||||
}
|
||||
|
||||
// 系统健康指标(管理员专用)
|
||||
export interface SystemHealth {
|
||||
avg_response_time: number
|
||||
error_rate: number
|
||||
error_requests: number
|
||||
fallback_count: number
|
||||
total_requests: number
|
||||
}
|
||||
|
||||
// 成本统计(管理员专用)
|
||||
export interface CostStats {
|
||||
total_cost: number
|
||||
total_actual_cost: number
|
||||
cost_savings: number
|
||||
}
|
||||
|
||||
// 缓存统计
|
||||
export interface CacheStats {
|
||||
cache_creation_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_cost?: number
|
||||
cache_read_cost?: number
|
||||
cache_hit_rate?: number
|
||||
total_cache_tokens: number
|
||||
}
|
||||
|
||||
// 用户统计(管理员专用)
|
||||
export interface UserStats {
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
|
||||
// Token 详细分类
|
||||
export interface TokenBreakdown {
|
||||
input: number
|
||||
output: number
|
||||
cache_creation: number
|
||||
cache_read: number
|
||||
}
|
||||
|
||||
export interface DashboardStatsResponse {
|
||||
stats: DashboardStat[]
|
||||
today?: {
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
actual_cost?: number
|
||||
cache_creation_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
}
|
||||
api_keys?: {
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
tokens?: {
|
||||
month: number
|
||||
}
|
||||
// 管理员专用字段
|
||||
system_health?: SystemHealth
|
||||
cost_stats?: CostStats
|
||||
cache_stats?: CacheStats
|
||||
users?: UserStats
|
||||
token_breakdown?: TokenBreakdown
|
||||
}
|
||||
|
||||
export interface RecentRequestsResponse {
|
||||
requests: RecentRequest[]
|
||||
}
|
||||
|
||||
export interface ProviderStatusResponse {
|
||||
providers: ProviderStatus[]
|
||||
}
|
||||
|
||||
export interface RequestDetail {
|
||||
id: string // UUID
|
||||
request_id: string
|
||||
user: {
|
||||
id: string // UUID
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
api_key: {
|
||||
id: string // UUID
|
||||
name: string
|
||||
display: string
|
||||
}
|
||||
provider: string
|
||||
api_format?: string
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
cost: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
// Additional token fields
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
// Additional cost fields
|
||||
input_cost?: number
|
||||
output_cost?: number
|
||||
total_cost?: number
|
||||
cache_creation_cost?: number
|
||||
cache_read_cost?: number
|
||||
request_cost?: number // 按次计费费用
|
||||
// Historical pricing fields (per 1M tokens)
|
||||
input_price_per_1m?: number
|
||||
output_price_per_1m?: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
price_per_request?: number // 按次计费价格
|
||||
request_type: string
|
||||
is_stream: boolean
|
||||
status_code: number
|
||||
error_message?: string
|
||||
response_time_ms: number
|
||||
created_at: string
|
||||
request_headers?: Record<string, any>
|
||||
request_body?: Record<string, any>
|
||||
provider_request_headers?: Record<string, any>
|
||||
response_headers?: Record<string, any>
|
||||
response_body?: Record<string, any>
|
||||
metadata?: Record<string, any>
|
||||
// 阶梯计费信息
|
||||
tiered_pricing?: {
|
||||
total_input_context: number // 总输入上下文 (input + cache_read)
|
||||
tier_index: number // 命中的阶梯索引 (0-based)
|
||||
tier_count: number // 阶梯总数
|
||||
source?: 'provider' | 'global' // 定价来源: 提供商或全局
|
||||
current_tier: { // 当前命中的阶梯配置
|
||||
up_to?: number | null
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: Array<{
|
||||
ttl_minutes: number
|
||||
cache_read_price_per_1m: number
|
||||
}>
|
||||
}
|
||||
tiers: Array<{ // 完整阶梯配置列表
|
||||
up_to?: number | null
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: Array<{
|
||||
ttl_minutes: number
|
||||
cache_read_price_per_1m: number
|
||||
}>
|
||||
}>
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface ModelBreakdown {
|
||||
model: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
export interface ModelSummary {
|
||||
model: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
avg_response_time: number
|
||||
cost_per_request: number
|
||||
tokens_per_request: number
|
||||
}
|
||||
|
||||
export interface DailyStat {
|
||||
date: string // ISO date string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
avg_response_time: number // in seconds
|
||||
unique_models: number
|
||||
unique_providers: number
|
||||
model_breakdown: ModelBreakdown[]
|
||||
}
|
||||
|
||||
export interface DailyStatsResponse {
|
||||
daily_stats: DailyStat[]
|
||||
model_summary: ModelSummary[]
|
||||
period: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
days: number
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
// 获取仪表盘统计数据
|
||||
async getStats(): Promise<DashboardStatsResponse> {
|
||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取最近的请求记录
|
||||
async getRecentRequests(limit: number = 10): Promise<RecentRequest[]> {
|
||||
const response = await apiClient.get<RecentRequestsResponse>('/api/dashboard/recent-requests', {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data.requests
|
||||
},
|
||||
|
||||
// 获取提供商状态
|
||||
async getProviderStatus(): Promise<ProviderStatus[]> {
|
||||
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
|
||||
return response.data.providers
|
||||
},
|
||||
|
||||
// 获取请求详情
|
||||
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
|
||||
async getRequestDetail(requestId: string): Promise<RequestDetail> {
|
||||
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取每日统计数据
|
||||
async getDailyStats(days: number = 7): Promise<DailyStatsResponse> {
|
||||
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
|
||||
params: { days }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
57
frontend/src/api/endpoints/adaptive.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import client from '../client'
|
||||
import type { AdaptiveStatsResponse } from './types'
|
||||
|
||||
/**
|
||||
* 启用/禁用 Key 的自适应模式
|
||||
*/
|
||||
export async function toggleAdaptiveMode(
|
||||
keyId: string,
|
||||
data: {
|
||||
enabled: boolean
|
||||
fixed_limit?: number
|
||||
}
|
||||
): Promise<{
|
||||
message: string
|
||||
key_id: string
|
||||
is_adaptive: boolean
|
||||
max_concurrent: number | null
|
||||
effective_limit: number | null
|
||||
}> {
|
||||
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/mode`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Key 的固定并发限制
|
||||
*/
|
||||
export async function setConcurrentLimit(
|
||||
keyId: string,
|
||||
limit: number
|
||||
): Promise<{
|
||||
message: string
|
||||
key_id: string
|
||||
is_adaptive: boolean
|
||||
max_concurrent: number
|
||||
previous_mode: string
|
||||
}> {
|
||||
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/limit`, null, {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Key 的自适应统计
|
||||
*/
|
||||
export async function getAdaptiveStats(keyId: string): Promise<AdaptiveStatsResponse> {
|
||||
const response = await client.get(`/api/admin/adaptive/keys/${keyId}/stats`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 Key 的学习状态
|
||||
*/
|
||||
export async function resetAdaptiveLearning(keyId: string): Promise<{ message: string; key_id: string }> {
|
||||
const response = await client.delete(`/api/admin/adaptive/keys/${keyId}/learning`)
|
||||
return response.data
|
||||
}
|
||||
121
frontend/src/api/endpoints/aliases.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 模型别名管理 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}`)
|
||||
}
|
||||
78
frontend/src/api/endpoints/endpoints.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import client from '../client'
|
||||
import type { ProviderEndpoint } from './types'
|
||||
|
||||
/**
|
||||
* 获取指定 Provider 的所有 Endpoints
|
||||
*/
|
||||
export async function getProviderEndpoints(providerId: string): Promise<ProviderEndpoint[]> {
|
||||
const response = await client.get(`/api/admin/endpoints/providers/${providerId}/endpoints`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Endpoint 详情
|
||||
*/
|
||||
export async function getEndpoint(endpointId: string): Promise<ProviderEndpoint> {
|
||||
const response = await client.get(`/api/admin/endpoints/${endpointId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 Provider 创建新的 Endpoint
|
||||
*/
|
||||
export async function createEndpoint(
|
||||
providerId: string,
|
||||
data: {
|
||||
provider_id: string
|
||||
api_format: string
|
||||
base_url: string
|
||||
custom_path?: string
|
||||
auth_type?: string
|
||||
auth_header?: string
|
||||
headers?: Record<string, string>
|
||||
timeout?: number
|
||||
max_retries?: number
|
||||
priority?: number
|
||||
weight?: number
|
||||
max_concurrent?: number
|
||||
rate_limit?: number
|
||||
is_active?: boolean
|
||||
config?: Record<string, any>
|
||||
}
|
||||
): Promise<ProviderEndpoint> {
|
||||
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Endpoint
|
||||
*/
|
||||
export async function updateEndpoint(
|
||||
endpointId: string,
|
||||
data: Partial<{
|
||||
base_url: string
|
||||
custom_path: string
|
||||
auth_type: string
|
||||
auth_header: string
|
||||
headers: Record<string, string>
|
||||
timeout: number
|
||||
max_retries: number
|
||||
priority: number
|
||||
weight: number
|
||||
max_concurrent: number
|
||||
rate_limit: number
|
||||
is_active: boolean
|
||||
config: Record<string, any>
|
||||
}>
|
||||
): Promise<ProviderEndpoint> {
|
||||
const response = await client.put(`/api/admin/endpoints/${endpointId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Endpoint
|
||||
*/
|
||||
export async function deleteEndpoint(endpointId: string): Promise<{ message: string; deleted_keys_count: number }> {
|
||||
const response = await client.delete(`/api/admin/endpoints/${endpointId}`)
|
||||
return response.data
|
||||
}
|
||||
85
frontend/src/api/endpoints/global-models.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import client from '../client'
|
||||
import type {
|
||||
GlobalModelCreate,
|
||||
GlobalModelUpdate,
|
||||
GlobalModelResponse,
|
||||
GlobalModelWithStats,
|
||||
GlobalModelListResponse
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* 获取 GlobalModel 列表
|
||||
*/
|
||||
export async function getGlobalModels(params?: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
is_active?: boolean
|
||||
search?: string
|
||||
}): Promise<GlobalModelListResponse> {
|
||||
const response = await client.get('/api/admin/models/global', { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 GlobalModel 详情
|
||||
*/
|
||||
export async function getGlobalModel(id: string): Promise<GlobalModelWithStats> {
|
||||
const response = await client.get(`/api/admin/models/global/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 GlobalModel
|
||||
*/
|
||||
export async function createGlobalModel(data: GlobalModelCreate): Promise<GlobalModelResponse> {
|
||||
const response = await client.post('/api/admin/models/global', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 GlobalModel
|
||||
*/
|
||||
export async function updateGlobalModel(
|
||||
id: string,
|
||||
data: GlobalModelUpdate
|
||||
): Promise<GlobalModelResponse> {
|
||||
const response = await client.patch(`/api/admin/models/global/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 GlobalModel
|
||||
*/
|
||||
export async function deleteGlobalModel(
|
||||
id: string,
|
||||
force: boolean = false
|
||||
): Promise<void> {
|
||||
await client.delete(`/api/admin/models/global/${id}`, { params: { force } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量为 GlobalModel 添加关联提供商
|
||||
*/
|
||||
export async function batchAssignToProviders(
|
||||
globalModelId: string,
|
||||
data: {
|
||||
provider_ids: string[]
|
||||
create_models: boolean
|
||||
}
|
||||
): Promise<{
|
||||
success: Array<{
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
model_id?: string
|
||||
}>
|
||||
errors: Array<{
|
||||
provider_id: string
|
||||
error: string
|
||||
}>
|
||||
}> {
|
||||
const response = await client.post(
|
||||
`/api/admin/models/global/${globalModelId}/assign-to-providers`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
88
frontend/src/api/endpoints/health.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import client from '../client'
|
||||
import type {
|
||||
HealthStatus,
|
||||
HealthSummary,
|
||||
EndpointStatusMonitorResponse,
|
||||
PublicEndpointStatusMonitorResponse
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* 获取健康状态摘要
|
||||
*/
|
||||
export async function getHealthSummary(): Promise<HealthSummary> {
|
||||
const response = await client.get('/api/admin/endpoints/health/summary')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Endpoint 健康状态
|
||||
*/
|
||||
export async function getEndpointHealth(endpointId: string): Promise<HealthStatus> {
|
||||
const response = await client.get(`/api/admin/endpoints/health/endpoint/${endpointId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Key 健康状态
|
||||
*/
|
||||
export async function getKeyHealth(keyId: string): Promise<HealthStatus> {
|
||||
const response = await client.get(`/api/admin/endpoints/health/key/${keyId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复Key健康状态(一键恢复:重置健康度 + 关闭熔断器 + 取消自动禁用)
|
||||
*/
|
||||
export async function recoverKeyHealth(keyId: string): Promise<{
|
||||
message: string
|
||||
details: {
|
||||
health_score: number
|
||||
circuit_breaker_open: boolean
|
||||
is_active: boolean
|
||||
}
|
||||
}> {
|
||||
const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量恢复所有熔断的Key健康状态
|
||||
*/
|
||||
export async function recoverAllKeysHealth(): Promise<{
|
||||
message: string
|
||||
recovered_count: number
|
||||
recovered_keys: Array<{
|
||||
key_id: string
|
||||
key_name: string
|
||||
endpoint_id: string
|
||||
}>
|
||||
}> {
|
||||
const response = await client.patch('/api/admin/endpoints/health/keys')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按 API 格式聚合的健康监控时间线(管理员版,含 provider/key 数量)
|
||||
*/
|
||||
export async function getEndpointStatusMonitor(params?: {
|
||||
lookback_hours?: number
|
||||
per_format_limit?: number
|
||||
}): Promise<EndpointStatusMonitorResponse> {
|
||||
const response = await client.get('/api/admin/endpoints/health/api-formats', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按 API 格式聚合的健康监控时间线(公开版,不含敏感信息)
|
||||
*/
|
||||
export async function getPublicEndpointStatusMonitor(params?: {
|
||||
lookback_hours?: number
|
||||
per_format_limit?: number
|
||||
}): Promise<PublicEndpointStatusMonitorResponse> {
|
||||
const response = await client.get('/api/public/health/api-formats', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
9
frontend/src/api/endpoints/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './types'
|
||||
export * from './providers'
|
||||
export * from './endpoints'
|
||||
export * from './keys'
|
||||
export * from './health'
|
||||
export * from './models'
|
||||
export * from './aliases'
|
||||
export * from './adaptive'
|
||||
export * from './global-models'
|
||||
132
frontend/src/api/endpoints/keys.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import client from '../client'
|
||||
import type { EndpointAPIKey } from './types'
|
||||
|
||||
/**
|
||||
* 能力定义类型
|
||||
*/
|
||||
export interface CapabilityDefinition {
|
||||
name: string
|
||||
display_name: string
|
||||
description: string
|
||||
match_mode: 'exclusive' | 'compatible'
|
||||
config_mode?: 'user_configurable' | 'auto_detect' | 'request_param'
|
||||
short_name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型支持的能力响应类型
|
||||
*/
|
||||
export interface ModelCapabilitiesResponse {
|
||||
model: string
|
||||
global_model_id?: string
|
||||
global_model_name?: string
|
||||
supported_capabilities: string[]
|
||||
capability_details: CapabilityDefinition[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有能力定义
|
||||
*/
|
||||
export async function getAllCapabilities(): Promise<CapabilityDefinition[]> {
|
||||
const response = await client.get('/api/capabilities')
|
||||
return response.data.capabilities
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户可配置的能力列表
|
||||
*/
|
||||
export async function getUserConfigurableCapabilities(): Promise<CapabilityDefinition[]> {
|
||||
const response = await client.get('/api/capabilities/user-configurable')
|
||||
return response.data.capabilities
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定模型支持的能力列表
|
||||
*/
|
||||
export async function getModelCapabilities(modelName: string): Promise<ModelCapabilitiesResponse> {
|
||||
const response = await client.get(`/api/capabilities/model/${encodeURIComponent(modelName)}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Endpoint 的所有 Keys
|
||||
*/
|
||||
export async function getEndpointKeys(endpointId: string): Promise<EndpointAPIKey[]> {
|
||||
const response = await client.get(`/api/admin/endpoints/${endpointId}/keys`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 Endpoint 添加 Key
|
||||
*/
|
||||
export async function addEndpointKey(
|
||||
endpointId: string,
|
||||
data: {
|
||||
endpoint_id: string
|
||||
api_key: string
|
||||
name: string // 密钥名称(必填)
|
||||
rate_multiplier?: number // 成本倍率(默认 1.0)
|
||||
internal_priority?: number // Endpoint 内部优先级(数字越小越优先)
|
||||
max_concurrent?: number // 最大并发数(留空=自适应模式)
|
||||
rate_limit?: number
|
||||
daily_limit?: number
|
||||
monthly_limit?: number
|
||||
cache_ttl_minutes?: number // 缓存 TTL(分钟),0=禁用
|
||||
max_probe_interval_minutes?: number // 熔断探测间隔(分钟)
|
||||
allowed_models?: string[] // 允许使用的模型列表
|
||||
capabilities?: Record<string, boolean> // 能力标签配置
|
||||
note?: string // 备注说明(可选)
|
||||
}
|
||||
): Promise<EndpointAPIKey> {
|
||||
const response = await client.post(`/api/admin/endpoints/${endpointId}/keys`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Endpoint Key
|
||||
*/
|
||||
export async function updateEndpointKey(
|
||||
keyId: string,
|
||||
data: Partial<{
|
||||
api_key: string
|
||||
name: string // 密钥名称
|
||||
rate_multiplier: number // 成本倍率
|
||||
internal_priority: number // Endpoint 内部优先级(提供商优先模式,数字越小越优先)
|
||||
global_priority: number // 全局 Key 优先级(全局 Key 优先模式,数字越小越优先)
|
||||
max_concurrent: number // 最大并发数(留空=自适应模式)
|
||||
rate_limit: number
|
||||
daily_limit: number
|
||||
monthly_limit: number
|
||||
cache_ttl_minutes: number // 缓存 TTL(分钟),0=禁用
|
||||
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
|
||||
allowed_models: string[] | null // 允许使用的模型列表,null 表示允许所有
|
||||
capabilities: Record<string, boolean> | null // 能力标签配置
|
||||
is_active: boolean
|
||||
note: string // 备注说明
|
||||
}>
|
||||
): Promise<EndpointAPIKey> {
|
||||
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Endpoint Key
|
||||
*/
|
||||
export async function deleteEndpointKey(keyId: string): Promise<{ message: string }> {
|
||||
const response = await client.delete(`/api/admin/endpoints/keys/${keyId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新 Endpoint Keys 的优先级(用于拖动排序)
|
||||
*/
|
||||
export async function batchUpdateKeyPriority(
|
||||
endpointId: string,
|
||||
priorities: Array<{ key_id: string; internal_priority: number }>
|
||||
): Promise<{ message: string; updated_count: number }> {
|
||||
const response = await client.put(`/api/admin/endpoints/${endpointId}/keys/batch-priority`, {
|
||||
priorities
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
145
frontend/src/api/endpoints/models.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import client from '../client'
|
||||
import type {
|
||||
Model,
|
||||
ModelCreate,
|
||||
ModelUpdate,
|
||||
ModelCatalogResponse,
|
||||
ProviderAvailableSourceModelsResponse,
|
||||
UpdateModelMappingRequest,
|
||||
UpdateModelMappingResponse,
|
||||
DeleteModelMappingResponse
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* 获取 Provider 的所有模型
|
||||
*/
|
||||
export async function getProviderModels(
|
||||
providerId: string,
|
||||
params?: {
|
||||
is_active?: boolean
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
): Promise<Model[]> {
|
||||
const response = await client.get(`/api/admin/providers/${providerId}/models`, { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建模型
|
||||
*/
|
||||
export async function createModel(
|
||||
providerId: string,
|
||||
data: ModelCreate
|
||||
): Promise<Model> {
|
||||
const response = await client.post(`/api/admin/providers/${providerId}/models`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型详情
|
||||
*/
|
||||
export async function getModel(
|
||||
providerId: string,
|
||||
modelId: string
|
||||
): Promise<Model> {
|
||||
const response = await client.get(`/api/admin/providers/${providerId}/models/${modelId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模型
|
||||
*/
|
||||
export async function updateModel(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
data: ModelUpdate
|
||||
): Promise<Model> {
|
||||
const response = await client.patch(`/api/admin/providers/${providerId}/models/${modelId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模型
|
||||
*/
|
||||
export async function deleteModel(
|
||||
providerId: string,
|
||||
modelId: string
|
||||
): Promise<{ message: string }> {
|
||||
const response = await client.delete(`/api/admin/providers/${providerId}/models/${modelId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建模型
|
||||
*/
|
||||
export async function batchCreateModels(
|
||||
providerId: string,
|
||||
modelsData: ModelCreate[]
|
||||
): Promise<Model[]> {
|
||||
const response = await client.post(`/api/admin/providers/${providerId}/models/batch`, modelsData)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统一模型目录
|
||||
*/
|
||||
export async function getModelCatalog(): Promise<ModelCatalogResponse> {
|
||||
const response = await client.get('/api/admin/models/catalog')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Provider 支持的统一模型列表
|
||||
*/
|
||||
export async function getProviderAvailableSourceModels(
|
||||
providerId: string
|
||||
): Promise<ProviderAvailableSourceModelsResponse> {
|
||||
const response = await client.get(`/api/admin/providers/${providerId}/available-source-models`)
|
||||
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
|
||||
*/
|
||||
export async function batchAssignModelsToProvider(
|
||||
providerId: string,
|
||||
globalModelIds: string[]
|
||||
): Promise<{
|
||||
success: Array<{
|
||||
global_model_id: string
|
||||
global_model_name: string
|
||||
model_id: string
|
||||
}>
|
||||
errors: Array<{
|
||||
global_model_id: string
|
||||
error: string
|
||||
}>
|
||||
}> {
|
||||
const response = await client.post(
|
||||
`/api/admin/providers/${providerId}/assign-global-models`,
|
||||
{ global_model_ids: globalModelIds }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
60
frontend/src/api/endpoints/providers.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import client from '../client'
|
||||
import type { ProviderWithEndpointsSummary } from './types'
|
||||
|
||||
/**
|
||||
* 获取 Providers 摘要(包含 Endpoints 统计)
|
||||
*/
|
||||
export async function getProvidersSummary(): Promise<ProviderWithEndpointsSummary[]> {
|
||||
const response = await client.get('/api/admin/providers/summary')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 Provider 的详细信息
|
||||
*/
|
||||
export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> {
|
||||
const response = await client.get(`/api/admin/providers/${providerId}/summary`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Provider 基础配置
|
||||
*/
|
||||
export async function updateProvider(
|
||||
providerId: string,
|
||||
data: Partial<{
|
||||
display_name: string
|
||||
description: string
|
||||
website: string
|
||||
provider_priority: number
|
||||
billing_type: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
|
||||
monthly_quota_usd: number
|
||||
quota_reset_day: number
|
||||
quota_last_reset_at: string // 周期开始时间
|
||||
quota_expires_at: string
|
||||
rpm_limit: number | null
|
||||
cache_ttl_minutes: number // 0表示不支持缓存,>0表示支持缓存并设置TTL(分钟)
|
||||
max_probe_interval_minutes: number
|
||||
is_active: boolean
|
||||
}>
|
||||
): Promise<ProviderWithEndpointsSummary> {
|
||||
const response = await client.patch(`/api/admin/providers/${providerId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Provider
|
||||
*/
|
||||
export async function createProvider(data: any): Promise<any> {
|
||||
const response = await client.post('/api/admin/providers/', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Provider
|
||||
*/
|
||||
export async function deleteProvider(providerId: string): Promise<{ message: string }> {
|
||||
const response = await client.delete(`/api/admin/providers/${providerId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
553
frontend/src/api/endpoints/types.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
export interface ProviderEndpoint {
|
||||
id: string
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
api_format: string
|
||||
base_url: string
|
||||
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
|
||||
auth_type: string
|
||||
auth_header?: string
|
||||
headers?: Record<string, string>
|
||||
timeout: number
|
||||
max_retries: number
|
||||
priority: number
|
||||
weight: number
|
||||
max_concurrent?: number
|
||||
rate_limit?: number
|
||||
health_score: number
|
||||
consecutive_failures: number
|
||||
last_failure_at?: string
|
||||
is_active: boolean
|
||||
config?: Record<string, any>
|
||||
total_keys: number
|
||||
active_keys: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface EndpointAPIKey {
|
||||
id: string
|
||||
endpoint_id: string
|
||||
api_key_masked: string
|
||||
api_key_plain?: string | null
|
||||
name: string // 密钥名称(必填,用于识别)
|
||||
rate_multiplier: number // 成本倍率(真实成本 = 表面成本 × 倍率)
|
||||
internal_priority: number // Endpoint 内部优先级
|
||||
global_priority?: number | null // 全局 Key 优先级
|
||||
max_concurrent?: number
|
||||
rate_limit?: number
|
||||
daily_limit?: number
|
||||
monthly_limit?: number
|
||||
allowed_models?: string[] | null // 允许使用的模型列表(null = 支持所有模型)
|
||||
capabilities?: Record<string, boolean> | null // 能力标签配置(如 cache_1h, context_1m)
|
||||
// 缓存与熔断配置
|
||||
cache_ttl_minutes: number // 缓存 TTL(分钟),0=禁用
|
||||
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
|
||||
health_score: number
|
||||
consecutive_failures: number
|
||||
last_failure_at?: string
|
||||
request_count: number
|
||||
success_count: number
|
||||
error_count: number
|
||||
success_rate: number
|
||||
avg_response_time_ms: number
|
||||
is_active: boolean
|
||||
note?: string // 备注说明(可选)
|
||||
last_used_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// 自适应并发字段
|
||||
is_adaptive?: boolean // 是否为自适应模式(max_concurrent=NULL)
|
||||
effective_limit?: number // 当前有效限制(自适应使用学习值,固定使用配置值)
|
||||
learned_max_concurrent?: number
|
||||
// 滑动窗口利用率采样
|
||||
utilization_samples?: Array<{ ts: number; util: number }> // 利用率采样窗口
|
||||
last_probe_increase_at?: string // 上次探测性扩容时间
|
||||
concurrent_429_count?: number
|
||||
rpm_429_count?: number
|
||||
last_429_at?: string
|
||||
last_429_type?: string
|
||||
// 熔断器字段(滑动窗口 + 半开模式)
|
||||
circuit_breaker_open?: boolean
|
||||
circuit_breaker_open_at?: string
|
||||
next_probe_at?: string
|
||||
half_open_until?: string
|
||||
half_open_successes?: number
|
||||
half_open_failures?: number
|
||||
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
|
||||
}
|
||||
|
||||
export interface EndpointHealthDetail {
|
||||
api_format: string
|
||||
health_score: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface EndpointHealthEvent {
|
||||
timestamp: string
|
||||
status: 'success' | 'failed' | 'skipped' | 'started'
|
||||
status_code?: number | null
|
||||
latency_ms?: number | null
|
||||
error_type?: string | null
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
export interface EndpointStatusMonitor {
|
||||
api_format: string
|
||||
total_attempts: number
|
||||
success_count: number
|
||||
failed_count: number
|
||||
skipped_count: number
|
||||
success_rate: number
|
||||
provider_count: number
|
||||
key_count: number
|
||||
last_event_at?: string | null
|
||||
events: EndpointHealthEvent[]
|
||||
timeline?: string[]
|
||||
time_range_start?: string | null
|
||||
time_range_end?: string | null
|
||||
}
|
||||
|
||||
export interface EndpointStatusMonitorResponse {
|
||||
generated_at: string
|
||||
formats: EndpointStatusMonitor[]
|
||||
}
|
||||
|
||||
// 公开版事件(不含敏感信息如 provider_id, key_id)
|
||||
export interface PublicHealthEvent {
|
||||
timestamp: string
|
||||
status: string
|
||||
status_code?: number | null
|
||||
latency_ms?: number | null
|
||||
error_type?: string | null
|
||||
}
|
||||
|
||||
// 公开版端点状态监控类型(返回 events,前端复用 EndpointHealthTimeline 组件)
|
||||
export interface PublicEndpointStatusMonitor {
|
||||
api_format: string
|
||||
api_path: string // 本站入口路径
|
||||
total_attempts: number
|
||||
success_count: number
|
||||
failed_count: number
|
||||
skipped_count: number
|
||||
success_rate: number
|
||||
last_event_at?: string | null
|
||||
events: PublicHealthEvent[]
|
||||
timeline?: string[]
|
||||
time_range_start?: string | null
|
||||
time_range_end?: string | null
|
||||
}
|
||||
|
||||
export interface PublicEndpointStatusMonitorResponse {
|
||||
generated_at: string
|
||||
formats: PublicEndpointStatusMonitor[]
|
||||
}
|
||||
|
||||
export interface ProviderWithEndpointsSummary {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string
|
||||
website?: string
|
||||
provider_priority: number
|
||||
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
|
||||
monthly_quota_usd?: number
|
||||
monthly_used_usd?: number
|
||||
quota_reset_day?: number
|
||||
quota_last_reset_at?: string // 当前周期开始时间
|
||||
quota_expires_at?: string
|
||||
rpm_limit?: number | null
|
||||
rpm_used?: number
|
||||
rpm_reset_at?: string
|
||||
is_active: boolean
|
||||
total_endpoints: number
|
||||
active_endpoints: number
|
||||
total_keys: number
|
||||
active_keys: number
|
||||
total_models: number
|
||||
active_models: number
|
||||
avg_health_score: number
|
||||
unhealthy_endpoints: number
|
||||
api_formats: string[]
|
||||
endpoint_health_details: EndpointHealthDetail[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
endpoint_id?: string
|
||||
endpoint_health_score?: number
|
||||
endpoint_consecutive_failures?: number
|
||||
endpoint_last_failure_at?: string
|
||||
endpoint_is_active?: boolean
|
||||
key_id?: string
|
||||
key_health_score?: number
|
||||
key_consecutive_failures?: number
|
||||
key_last_failure_at?: string
|
||||
key_is_active?: boolean
|
||||
key_statistics?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface HealthSummary {
|
||||
endpoints: {
|
||||
total: number
|
||||
active: number
|
||||
unhealthy: number
|
||||
}
|
||||
keys: {
|
||||
total: number
|
||||
active: number
|
||||
unhealthy: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConcurrencyStatus {
|
||||
endpoint_id?: string
|
||||
endpoint_current_concurrency: number
|
||||
endpoint_max_concurrent?: number
|
||||
key_id?: string
|
||||
key_current_concurrency: number
|
||||
key_max_concurrent?: number
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string
|
||||
provider_id: string
|
||||
global_model_id?: string // 关联的 GlobalModel ID
|
||||
provider_model_name: string // Provider 侧的模型名称(原 name)
|
||||
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
|
||||
price_per_request?: number | null // 按次计费价格
|
||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||
supports_vision?: boolean | null
|
||||
supports_function_calling?: boolean | null
|
||||
supports_streaming?: boolean | null
|
||||
supports_extended_thinking?: boolean | null
|
||||
supports_image_generation?: boolean | null
|
||||
// 有效值(合并 Model 和 GlobalModel 默认值后的结果)
|
||||
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置
|
||||
effective_input_price?: number | null
|
||||
effective_output_price?: number | null
|
||||
effective_price_per_request?: number | null // 有效按次计费价格
|
||||
effective_supports_vision?: boolean | null
|
||||
effective_supports_function_calling?: boolean | null
|
||||
effective_supports_streaming?: boolean | null
|
||||
effective_supports_extended_thinking?: boolean | null
|
||||
effective_supports_image_generation?: boolean | null
|
||||
is_active: boolean
|
||||
is_available: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// GlobalModel 信息(从后端 join 获取)
|
||||
global_model_name?: string
|
||||
global_model_display_name?: string
|
||||
}
|
||||
|
||||
export interface ModelCreate {
|
||||
provider_model_name: string // Provider 侧的模型名称(原 name)
|
||||
global_model_id: string // 关联的 GlobalModel ID(必填)
|
||||
// 计费配置(可选,为空时使用 GlobalModel 默认值)
|
||||
price_per_request?: number // 按次计费价格
|
||||
tiered_pricing?: TieredPricingConfig // 阶梯计费配置
|
||||
// 能力配置(可选,为空时使用 GlobalModel 默认值)
|
||||
supports_vision?: boolean
|
||||
supports_function_calling?: boolean
|
||||
supports_streaming?: boolean
|
||||
supports_extended_thinking?: boolean
|
||||
supports_image_generation?: boolean
|
||||
is_active?: boolean
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ModelUpdate {
|
||||
provider_model_name?: string
|
||||
global_model_id?: string
|
||||
price_per_request?: number | null // 按次计费价格(null 表示清空/使用默认值)
|
||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||
supports_vision?: boolean
|
||||
supports_function_calling?: boolean
|
||||
supports_streaming?: boolean
|
||||
supports_extended_thinking?: boolean
|
||||
supports_image_generation?: boolean
|
||||
is_active?: boolean
|
||||
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
|
||||
supports_streaming: boolean
|
||||
[key: string]: boolean
|
||||
}
|
||||
|
||||
export interface ProviderModelPriceInfo {
|
||||
input_price_per_1m?: number | null
|
||||
output_price_per_1m?: number | null
|
||||
cache_creation_price_per_1m?: number | null
|
||||
cache_read_price_per_1m?: number | null
|
||||
price_per_request?: number | null // 按次计费价格
|
||||
}
|
||||
|
||||
export interface ModelPriceRange {
|
||||
min_input: number | null
|
||||
max_input: number | null
|
||||
min_output: number | null
|
||||
max_output: number | null
|
||||
}
|
||||
|
||||
export interface ModelCatalogProviderDetail {
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
provider_display_name?: string | null
|
||||
model_id?: string | null
|
||||
target_model: string
|
||||
input_price_per_1m?: number | null
|
||||
output_price_per_1m?: number | null
|
||||
cache_creation_price_per_1m?: number | null
|
||||
cache_read_price_per_1m?: number | null
|
||||
cache_1h_creation_price_per_1m?: number | null // 1h 缓存创建价格
|
||||
price_per_request?: number | null // 按次计费价格
|
||||
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置(含继承)
|
||||
tier_count?: number // 阶梯数量
|
||||
supports_vision?: boolean | null
|
||||
supports_function_calling?: boolean | null
|
||||
supports_streaming?: boolean | null
|
||||
is_active: boolean
|
||||
mapping_id?: string | null
|
||||
}
|
||||
|
||||
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
|
||||
capabilities: ModelCapabilities // 能力聚合
|
||||
}
|
||||
|
||||
export interface ModelCatalogResponse {
|
||||
models: ModelCatalogItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
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
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ProviderAvailableSourceModelsResponse {
|
||||
models: ProviderAvailableSourceModel[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface BatchAssignProviderConfig {
|
||||
provider_id: string
|
||||
create_model?: boolean
|
||||
model_config?: ModelCreate
|
||||
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
|
||||
learned_limit: number | null
|
||||
concurrent_429_count: number
|
||||
rpm_429_count: number
|
||||
last_429_at: string | null
|
||||
last_429_type: string | null
|
||||
adjustment_count: number
|
||||
recent_adjustments: Array<{
|
||||
timestamp: string
|
||||
old_limit: number
|
||||
new_limit: number
|
||||
reason: string
|
||||
[key: string]: any
|
||||
}>
|
||||
}
|
||||
|
||||
// ========== 阶梯计费类型 ==========
|
||||
|
||||
/** 缓存时长定价配置 */
|
||||
export interface CacheTTLPricing {
|
||||
ttl_minutes: number
|
||||
cache_creation_price_per_1m: number
|
||||
}
|
||||
|
||||
/** 单个价格阶梯配置 */
|
||||
export interface PricingTier {
|
||||
up_to: number | null // null 表示无上限(最后一个阶梯)
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: CacheTTLPricing[]
|
||||
}
|
||||
|
||||
/** 阶梯计费配置 */
|
||||
export interface TieredPricingConfig {
|
||||
tiers: PricingTier[]
|
||||
}
|
||||
|
||||
// ========== GlobalModel 类型 ==========
|
||||
|
||||
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[]
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// 统计数据
|
||||
provider_count?: number
|
||||
alias_count?: number
|
||||
usage_count?: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface GlobalModelWithStats extends GlobalModelResponse {
|
||||
total_models: number
|
||||
total_providers: number
|
||||
price_range: ModelPriceRange
|
||||
}
|
||||
|
||||
export interface GlobalModelListResponse {
|
||||
models: GlobalModelResponse[]
|
||||
total: number
|
||||
}
|
||||
23
frontend/src/api/global-models.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* GlobalModel API 客户端
|
||||
* 统一导出,简化导入路径
|
||||
*/
|
||||
|
||||
export * from './endpoints/global-models'
|
||||
export type {
|
||||
GlobalModelCreate,
|
||||
GlobalModelUpdate,
|
||||
GlobalModelResponse,
|
||||
GlobalModelWithStats,
|
||||
GlobalModelListResponse,
|
||||
} from './endpoints/types'
|
||||
|
||||
// 重新导出为更简洁的函数名
|
||||
export {
|
||||
getGlobalModels as listGlobalModels,
|
||||
getGlobalModel,
|
||||
createGlobalModel,
|
||||
updateGlobalModel,
|
||||
deleteGlobalModel,
|
||||
batchAssignToProviders,
|
||||
} from './endpoints/global-models'
|
||||
257
frontend/src/api/me.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import apiClient from './client'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
|
||||
export interface Profile {
|
||||
id: string // UUID
|
||||
email: string
|
||||
username: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
quota_usd: number | null
|
||||
used_usd: number
|
||||
total_usd?: number // 累积消费总额
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_login_at?: string
|
||||
preferences?: UserPreferences
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
avatar_url?: string
|
||||
bio?: string
|
||||
default_provider_id?: string // UUID
|
||||
default_provider?: any
|
||||
theme: string
|
||||
language: string
|
||||
timezone?: string
|
||||
notifications?: {
|
||||
email?: boolean
|
||||
usage_alerts?: boolean
|
||||
announcements?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 提供商配置接口
|
||||
export interface ProviderConfig {
|
||||
provider_id: string
|
||||
priority: number // 优先级(越高越优先)
|
||||
weight: number // 负载均衡权重
|
||||
enabled: boolean // 是否启用
|
||||
}
|
||||
|
||||
// 使用记录接口
|
||||
export interface UsageRecordDetail {
|
||||
id: string
|
||||
provider: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
cost: number // 官方费率
|
||||
actual_cost?: number // 倍率消耗(仅管理员可见)
|
||||
rate_multiplier?: number // 成本倍率(仅管理员可见)
|
||||
response_time_ms?: number
|
||||
is_stream: boolean
|
||||
created_at: string
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
status_code: number
|
||||
error_message?: string
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
price_per_request?: number // 按次计费价格
|
||||
}
|
||||
|
||||
// 模型统计接口
|
||||
export interface ModelSummary {
|
||||
model: string
|
||||
requests: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
total_cost_usd: number
|
||||
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
||||
}
|
||||
|
||||
// 使用统计响应接口
|
||||
export interface UsageResponse {
|
||||
total_requests: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
total_tokens: number
|
||||
total_cost: number // 官方费率
|
||||
total_actual_cost?: number // 倍率消耗(仅管理员可见)
|
||||
avg_response_time: number
|
||||
quota_usd: number | null
|
||||
used_usd: number
|
||||
summary_by_model: ModelSummary[]
|
||||
records: UsageRecordDetail[]
|
||||
activity_heatmap?: ActivityHeatmap | null
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string // UUID
|
||||
name: string
|
||||
key?: string
|
||||
key_display: string
|
||||
is_active: boolean
|
||||
last_used_at?: string
|
||||
created_at: string
|
||||
total_requests?: number
|
||||
total_cost_usd?: number
|
||||
allowed_providers?: ProviderConfig[]
|
||||
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
||||
}
|
||||
|
||||
// 不再需要 ProviderBinding 接口
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
old_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export const meApi = {
|
||||
// 获取个人信息
|
||||
async getProfile(): Promise<Profile> {
|
||||
const response = await apiClient.get<Profile>('/api/users/me')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新个人信息
|
||||
async updateProfile(data: {
|
||||
email?: string
|
||||
username?: string
|
||||
}): Promise<{ message: string }> {
|
||||
const response = await apiClient.put('/api/users/me', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 修改密码
|
||||
async changePassword(data: ChangePasswordRequest): Promise<{ message: string }> {
|
||||
const response = await apiClient.patch('/api/users/me/password', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// API密钥管理
|
||||
async getApiKeys(): Promise<ApiKey[]> {
|
||||
const response = await apiClient.get<ApiKey[]>('/api/users/me/api-keys')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createApiKey(name: string): Promise<ApiKey> {
|
||||
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', { name })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getApiKeyDetail(keyId: string, includeKey: boolean = false): Promise<ApiKey & { key?: string }> {
|
||||
const response = await apiClient.get<ApiKey & { key?: string }>(
|
||||
`/api/users/me/api-keys/${keyId}`,
|
||||
{ params: { include_key: includeKey } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getFullApiKey(keyId: string): Promise<{ key: string }> {
|
||||
const response = await apiClient.get<{ key: string }>(
|
||||
`/api/users/me/api-keys/${keyId}`,
|
||||
{ params: { include_key: true } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteApiKey(keyId: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete(`/api/users/me/api-keys/${keyId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async toggleApiKey(keyId: string): Promise<ApiKey> {
|
||||
const response = await apiClient.patch<ApiKey>(`/api/users/me/api-keys/${keyId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 使用统计
|
||||
async getUsage(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}): Promise<UsageResponse> {
|
||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取活跃请求状态(用于轮询更新)
|
||||
async getActiveRequests(ids?: string): Promise<{
|
||||
requests: Array<{
|
||||
id: string
|
||||
status: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cost: number
|
||||
response_time_ms: number | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids ? { ids } : {}
|
||||
const response = await apiClient.get('/api/users/me/usage/active', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取可用的提供商
|
||||
async getAvailableProviders(): Promise<any[]> {
|
||||
const response = await apiClient.get('/api/users/me/providers')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取端点状态(不包含敏感信息)
|
||||
async getEndpointStatus(): Promise<any[]> {
|
||||
const response = await apiClient.get('/api/users/me/endpoint-status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 偏好设置
|
||||
async getPreferences(): Promise<UserPreferences> {
|
||||
const response = await apiClient.get('/api/users/me/preferences')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updatePreferences(data: Partial<UserPreferences>): Promise<{ message: string }> {
|
||||
const response = await apiClient.put('/api/users/me/preferences', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 提供商绑定管理相关方法已移除,改为直接从可用提供商中选择
|
||||
|
||||
// API密钥提供商关联
|
||||
async updateApiKeyProviders(keyId: string, data: {
|
||||
allowed_providers?: ProviderConfig[]
|
||||
}): Promise<{ message: string }> {
|
||||
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// API密钥能力配置
|
||||
async updateApiKeyCapabilities(keyId: string, data: {
|
||||
force_capabilities?: Record<string, boolean> | null
|
||||
}): Promise<{ message: string; force_capabilities?: Record<string, boolean> | null }> {
|
||||
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/capabilities`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 模型能力配置
|
||||
async getModelCapabilitySettings(): Promise<{
|
||||
model_capability_settings: Record<string, Record<string, boolean>>
|
||||
}> {
|
||||
const response = await apiClient.get('/api/users/me/model-capabilities')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateModelCapabilitySettings(data: {
|
||||
model_capability_settings: Record<string, Record<string, boolean>> | null
|
||||
}): Promise<{
|
||||
message: string
|
||||
model_capability_settings: Record<string, Record<string, boolean>> | null
|
||||
}> {
|
||||
const response = await apiClient.put('/api/users/me/model-capabilities', data)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
55
frontend/src/api/provider-strategy.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 提供商策略管理 API 客户端
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
const API_BASE = '/api/admin/provider-strategy';
|
||||
|
||||
export interface ProviderBillingConfig {
|
||||
billing_type: 'monthly_quota' | 'pay_as_you_go' | 'free_tier';
|
||||
monthly_quota_usd?: number;
|
||||
quota_reset_day?: number;
|
||||
quota_last_reset_at?: string; // 当前周期开始时间
|
||||
quota_expires_at?: string;
|
||||
rpm_limit?: number | null;
|
||||
cache_ttl_minutes?: number; // 0表示不支持缓存,>0表示支持缓存并设置TTL(分钟)
|
||||
provider_priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新提供商计费配置
|
||||
*/
|
||||
export async function updateProviderBilling(
|
||||
providerId: string,
|
||||
config: ProviderBillingConfig
|
||||
) {
|
||||
const response = await apiClient.put(`${API_BASE}/providers/${providerId}/billing`, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提供商使用统计
|
||||
*/
|
||||
export async function getProviderStats(providerId: string, hours: number = 24) {
|
||||
const response = await apiClient.get(`${API_BASE}/providers/${providerId}/stats`, {
|
||||
params: { hours }
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置提供商月卡额度
|
||||
*/
|
||||
export async function resetProviderQuota(providerId: string) {
|
||||
const response = await apiClient.delete(`${API_BASE}/providers/${providerId}/quota`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的负载均衡策略
|
||||
*/
|
||||
export async function listAvailableStrategies() {
|
||||
const response = await apiClient.get(`${API_BASE}/strategies`);
|
||||
return response.data;
|
||||
}
|
||||
44
frontend/src/api/public-models.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Public Models API - 普通用户可访问的模型列表
|
||||
*/
|
||||
|
||||
import client from './client'
|
||||
import type { TieredPricingConfig } from './endpoints/types'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface PublicGlobalModelListResponse {
|
||||
models: PublicGlobalModel[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开的 GlobalModel 列表(普通用户可访问)
|
||||
*/
|
||||
export async function getPublicGlobalModels(params?: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
is_active?: boolean
|
||||
search?: string
|
||||
}): Promise<PublicGlobalModelListResponse> {
|
||||
const response = await client.get('/api/public/global-models', { params })
|
||||
return response.data
|
||||
}
|
||||
69
frontend/src/api/requestTrace.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface CandidateRecord {
|
||||
id: string
|
||||
request_id: string
|
||||
candidate_index: number
|
||||
retry_index: number
|
||||
provider_id?: string
|
||||
provider_name?: string
|
||||
provider_website?: string // Provider 官网
|
||||
endpoint_id?: string
|
||||
endpoint_name?: string // 端点显示名称(api_format)
|
||||
key_id?: string
|
||||
key_name?: string // 密钥名称
|
||||
key_preview?: string // 密钥脱敏预览(如 sk-***abc)
|
||||
key_capabilities?: Record<string, boolean> | null // Key 支持的能力
|
||||
required_capabilities?: Record<string, boolean> | null // 请求实际需要的能力标签
|
||||
status: 'pending' | 'streaming' | 'success' | 'failed' | 'skipped'
|
||||
skip_reason?: string
|
||||
is_cached: boolean
|
||||
// 执行结果字段
|
||||
status_code?: number
|
||||
error_type?: string
|
||||
error_message?: string
|
||||
latency_ms?: number
|
||||
concurrent_requests?: number
|
||||
extra_data?: Record<string, any>
|
||||
created_at: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
}
|
||||
|
||||
export interface RequestTrace {
|
||||
request_id: string
|
||||
total_candidates: number
|
||||
final_status: 'success' | 'failed' | 'streaming' | 'pending'
|
||||
total_latency_ms: number
|
||||
candidates: CandidateRecord[]
|
||||
}
|
||||
|
||||
export interface ProviderStats {
|
||||
total_attempts: number
|
||||
success_count: number
|
||||
failed_count: number
|
||||
skipped_count: number
|
||||
pending_count: number
|
||||
available_count: number
|
||||
failure_rate: number
|
||||
}
|
||||
|
||||
export const requestTraceApi = {
|
||||
/**
|
||||
* 获取特定请求的完整追踪信息
|
||||
*/
|
||||
async getRequestTrace(requestId: string): Promise<RequestTrace> {
|
||||
const response = await apiClient.get<RequestTrace>(`/api/admin/monitoring/trace/${requestId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取某个 Provider 的失败率统计
|
||||
*/
|
||||
async getProviderStats(providerId: string, limit: number = 100): Promise<ProviderStats> {
|
||||
const response = await apiClient.get<ProviderStats>(`/api/admin/monitoring/trace/stats/provider/${providerId}`, {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
83
frontend/src/api/security.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* IP 安全管理 API
|
||||
*/
|
||||
import apiClient from './client'
|
||||
|
||||
export interface IPBlacklistEntry {
|
||||
ip_address: string
|
||||
reason: string
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
export interface IPWhitelistEntry {
|
||||
ip_address: string
|
||||
}
|
||||
|
||||
export interface BlacklistStats {
|
||||
available: boolean
|
||||
total: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WhitelistResponse {
|
||||
whitelist: string[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 黑名单管理
|
||||
*/
|
||||
export const blacklistApi = {
|
||||
/**
|
||||
* 添加 IP 到黑名单
|
||||
*/
|
||||
async add(data: IPBlacklistEntry) {
|
||||
const response = await apiClient.post('/api/admin/security/ip/blacklist', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 从黑名单移除 IP
|
||||
*/
|
||||
async remove(ip_address: string) {
|
||||
const response = await apiClient.delete(`/api/admin/security/ip/blacklist/${encodeURIComponent(ip_address)}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取黑名单统计
|
||||
*/
|
||||
async getStats(): Promise<BlacklistStats> {
|
||||
const response = await apiClient.get('/api/admin/security/ip/blacklist/stats')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 白名单管理
|
||||
*/
|
||||
export const whitelistApi = {
|
||||
/**
|
||||
* 添加 IP 到白名单
|
||||
*/
|
||||
async add(data: IPWhitelistEntry) {
|
||||
const response = await apiClient.post('/api/admin/security/ip/whitelist', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 从白名单移除 IP
|
||||
*/
|
||||
async remove(ip_address: string) {
|
||||
const response = await apiClient.delete(`/api/admin/security/ip/whitelist/${encodeURIComponent(ip_address)}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取白名单列表
|
||||
*/
|
||||
async getList(): Promise<WhitelistResponse> {
|
||||
const response = await apiClient.get('/api/admin/security/ip/whitelist')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
202
frontend/src/api/usage.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest } from '@/utils/cache'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
|
||||
export interface UsageRecord {
|
||||
id: string // UUID
|
||||
user_id: string // UUID
|
||||
username?: string
|
||||
provider_id?: string // UUID
|
||||
provider_name?: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
total_tokens: number
|
||||
cost?: number
|
||||
response_time?: number
|
||||
created_at: string
|
||||
has_fallback?: boolean // 🆕 是否发生了 fallback
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_actual_cost?: number
|
||||
avg_response_time: number
|
||||
today?: {
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}
|
||||
activity_heatmap?: ActivityHeatmap | null
|
||||
}
|
||||
|
||||
export interface UsageByModel {
|
||||
model: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
avg_response_time?: number
|
||||
}
|
||||
|
||||
export interface UsageByUser {
|
||||
user_id: string // UUID
|
||||
email: string
|
||||
username: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
}
|
||||
|
||||
export interface UsageByProvider {
|
||||
provider_id: string
|
||||
provider: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
avg_response_time_ms: number
|
||||
success_rate: number
|
||||
error_count: number
|
||||
}
|
||||
|
||||
export interface UsageByApiFormat {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
avg_response_time_ms: number
|
||||
}
|
||||
|
||||
export interface UsageFilters {
|
||||
user_id?: string // UUID
|
||||
provider_id?: string // UUID
|
||||
model?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
async getUsageRecords(filters?: UsageFilters): Promise<{
|
||||
records: UsageRecord[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/usage', { params: filters })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
|
||||
// 为统计数据添加30秒缓存
|
||||
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}`
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', { params: filters })
|
||||
return response.data
|
||||
},
|
||||
30000 // 30秒缓存
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get usage aggregation by dimension (RESTful API)
|
||||
* @param groupBy Aggregation dimension: 'model', 'user', 'provider', or 'api_format'
|
||||
* @param filters Optional filters
|
||||
*/
|
||||
async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
|
||||
groupBy: 'model' | 'user' | 'provider' | 'api_format',
|
||||
filters?: UsageFilters & { limit?: number }
|
||||
): Promise<T> {
|
||||
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}`
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', {
|
||||
params: { group_by: groupBy, ...filters }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
30000 // 30秒缓存
|
||||
)
|
||||
},
|
||||
|
||||
// Shorthand methods using getUsageAggregation
|
||||
async getUsageByModel(filters?: UsageFilters & { limit?: number }): Promise<UsageByModel[]> {
|
||||
return this.getUsageAggregation<UsageByModel[]>('model', filters)
|
||||
},
|
||||
|
||||
async getUsageByUser(filters?: UsageFilters & { limit?: number }): Promise<UsageByUser[]> {
|
||||
return this.getUsageAggregation<UsageByUser[]>('user', filters)
|
||||
},
|
||||
|
||||
async getUsageByProvider(filters?: UsageFilters & { limit?: number }): Promise<UsageByProvider[]> {
|
||||
return this.getUsageAggregation<UsageByProvider[]>('provider', filters)
|
||||
},
|
||||
|
||||
async getUsageByApiFormat(filters?: UsageFilters & { limit?: number }): Promise<UsageByApiFormat[]> {
|
||||
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters)
|
||||
},
|
||||
|
||||
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
|
||||
records: UsageRecord[]
|
||||
stats: UsageStats
|
||||
}> {
|
||||
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
|
||||
const response = await apiClient.get('/api/usage/export', {
|
||||
params: { ...filters, format },
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getAllUsageRecords(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
user_id?: string // UUID
|
||||
username?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
status?: string // 'stream' | 'standard' | 'error'
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
records: any[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/admin/usage/records', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
|
||||
* @param ids 可选,逗号分隔的请求 ID 列表
|
||||
*/
|
||||
async getActiveRequests(ids?: string[]): Promise<{
|
||||
requests: Array<{
|
||||
id: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed'
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cost: number
|
||||
response_time_ms: number | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids?.length ? { ids: ids.join(',') } : {}
|
||||
const response = await apiClient.get('/api/admin/usage/active', { params })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
106
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface User {
|
||||
id: string // UUID
|
||||
username: string
|
||||
email: string
|
||||
role: 'admin' | 'user'
|
||||
is_active: boolean
|
||||
quota_usd: number | null
|
||||
used_usd: number
|
||||
total_usd: number
|
||||
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_endpoints: string[] | null // 允许使用的端点 ID 列表
|
||||
allowed_models: string[] | null // 允许使用的模型名称列表
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
role?: 'admin' | 'user'
|
||||
quota_usd?: number | null
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
email?: string
|
||||
is_active?: boolean
|
||||
role?: 'admin' | 'user'
|
||||
quota_usd?: number | null
|
||||
password?: string
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string // UUID
|
||||
key?: string // 完整的 key,只在创建时返回
|
||||
key_display?: string // 脱敏后的密钥显示
|
||||
name?: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
expires_at?: string // 过期时间
|
||||
is_active: boolean
|
||||
is_standalone: boolean // 是否为独立余额Key
|
||||
balance_used_usd?: number // 已使用余额(仅独立Key)
|
||||
current_balance_usd?: number | null // 当前余额(独立Key预付费模式,null表示无限制)
|
||||
rate_limit?: number // 速率限制(请求/分钟)
|
||||
total_requests?: number // 总请求数
|
||||
total_cost_usd?: number // 总费用
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const response = await apiClient.get<User[]>('/api/admin/users')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getUser(userId: string): Promise<User> {
|
||||
const response = await apiClient.get<User>(`/api/admin/users/${userId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createUser(user: CreateUserRequest): Promise<User> {
|
||||
const response = await apiClient.post<User>('/api/admin/users', user)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateUser(userId: string, updates: UpdateUserRequest): Promise<User> {
|
||||
const response = await apiClient.put<User>(`/api/admin/users/${userId}`, updates)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/admin/users/${userId}`)
|
||||
},
|
||||
|
||||
async getUserApiKeys(userId: string): Promise<ApiKey[]> {
|
||||
const response = await apiClient.get<{ api_keys: ApiKey[] }>(`/api/admin/users/${userId}/api-keys`)
|
||||
return response.data.api_keys
|
||||
},
|
||||
|
||||
async createApiKey(userId: string, name?: string): Promise<ApiKey & { key: string }> {
|
||||
const response = await apiClient.post<ApiKey & { key: string }>(`/api/admin/users/${userId}/api-keys`, { name })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteApiKey(userId: string, keyId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/admin/users/${userId}/api-keys/${keyId}`)
|
||||
},
|
||||
|
||||
async resetUserQuota(userId: string): Promise<void> {
|
||||
await apiClient.patch(`/api/admin/users/${userId}/quota`)
|
||||
},
|
||||
|
||||
// 管理员统计
|
||||
async getUsageStats(): Promise<any> {
|
||||
const response = await apiClient.get('/api/admin/usage/stats')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
412
frontend/src/components/AetherLineByLineLogo.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div class="line-by-line-logo" :style="{ width: `${size}px`, height: `${size}px` }">
|
||||
<svg
|
||||
:viewBox="viewBox"
|
||||
class="logo-svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<!-- Metallic gradient -->
|
||||
<linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" :stop-color="metallicColors.dark" />
|
||||
<stop offset="25%" :stop-color="metallicColors.base" />
|
||||
<stop offset="50%" :stop-color="metallicColors.light" />
|
||||
<stop offset="75%" :stop-color="metallicColors.base" />
|
||||
<stop offset="100%" :stop-color="metallicColors.dark" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Layer 0: Ghost Tracks (Always visible, faint) -->
|
||||
<g class="ghost-layer">
|
||||
<path
|
||||
v-for="(path, index) in linePaths"
|
||||
:key="`ghost-${index}`"
|
||||
:d="path"
|
||||
class="ghost-path"
|
||||
fill="none"
|
||||
:stroke="currentColors.primary"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Layer 1: Fill (fade in/out) -->
|
||||
<path
|
||||
class="fill-path"
|
||||
:d="fullPath"
|
||||
:fill="`url(#${gradientId})`"
|
||||
fill-rule="evenodd"
|
||||
:style="fillStyle"
|
||||
/>
|
||||
|
||||
<!-- Layer 2: Animated lines -->
|
||||
<g class="lines-layer">
|
||||
<path
|
||||
v-for="(path, index) in linePaths"
|
||||
:key="`line-${index}`"
|
||||
:ref="(el) => setPathRef(el as SVGPathElement, index)"
|
||||
:d="path"
|
||||
class="line-path"
|
||||
fill="none"
|
||||
:stroke="currentColors.primary"
|
||||
:stroke-width="strokeWidth"
|
||||
:style="getLineStyle(index)"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
import { AETHER_SVG_VIEWBOX, AETHER_LINE_PATHS, AETHER_FULL_PATH } from '@/constants/logoPaths'
|
||||
|
||||
// Animation phases
|
||||
type AnimationPhase = 'idle' | 'drawOutline' | 'fillFadeIn' | 'hold' | 'fillFadeOut' | 'eraseOutline'
|
||||
|
||||
// Color scheme type
|
||||
interface ColorScheme {
|
||||
primary: string
|
||||
secondary: string
|
||||
}
|
||||
|
||||
// Constants
|
||||
const LINE_COUNT = AETHER_LINE_PATHS.length
|
||||
const DEFAULT_PATH_LENGTH = 3000
|
||||
|
||||
// Light mode color schemes
|
||||
const LIGHT_MODE_SCHEMES: ColorScheme[] = [
|
||||
{ primary: '#9a5a42', secondary: '#c4866a' },
|
||||
{ primary: '#8b4557', secondary: '#b87a8a' },
|
||||
{ primary: '#996b2e', secondary: '#c49a5c' },
|
||||
{ primary: '#7a5c3a', secondary: '#a8896a' },
|
||||
{ primary: '#6b4d82', secondary: '#9a7eb5' },
|
||||
{ primary: '#2d6a7a', secondary: '#5a9aaa' },
|
||||
{ primary: '#4a6b3a', secondary: '#7a9a6a' },
|
||||
{ primary: '#8a5a5a', secondary: '#b88a8a' },
|
||||
{ primary: '#5a6a7a', secondary: '#8a9aaa' },
|
||||
{ primary: '#6a5a4a', secondary: '#9a8a7a' },
|
||||
{ primary: '#7a4a5a', secondary: '#aa7a8a' },
|
||||
{ primary: '#4a5a6a', secondary: '#7a8a9a' },
|
||||
]
|
||||
|
||||
// Dark mode color schemes
|
||||
const DARK_MODE_SCHEMES: ColorScheme[] = [
|
||||
{ primary: '#f59e0b', secondary: '#fcd34d' },
|
||||
{ primary: '#ec4899', secondary: '#f9a8d4' },
|
||||
{ primary: '#22d3ee', secondary: '#a5f3fc' },
|
||||
{ primary: '#a855f7', secondary: '#d8b4fe' },
|
||||
{ primary: '#4ade80', secondary: '#bbf7d0' },
|
||||
{ primary: '#f472b6', secondary: '#fbcfe8' },
|
||||
{ primary: '#38bdf8', secondary: '#bae6fd' },
|
||||
{ primary: '#fb923c', secondary: '#fed7aa' },
|
||||
{ primary: '#a78bfa', secondary: '#ddd6fe' },
|
||||
{ primary: '#2dd4bf', secondary: '#99f6e4' },
|
||||
{ primary: '#facc15', secondary: '#fef08a' },
|
||||
{ primary: '#e879f9', secondary: '#f5d0fe' },
|
||||
]
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
lineDelay?: number
|
||||
strokeDuration?: number
|
||||
fillDuration?: number
|
||||
autoStart?: boolean
|
||||
loop?: boolean
|
||||
loopPause?: number
|
||||
outlineColor?: string
|
||||
gradientColor?: string
|
||||
strokeWidth?: number
|
||||
cycleColors?: boolean
|
||||
isDark?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 400,
|
||||
lineDelay: 60,
|
||||
strokeDuration: 1200,
|
||||
fillDuration: 800,
|
||||
autoStart: true,
|
||||
loop: true,
|
||||
loopPause: 600,
|
||||
outlineColor: '#cc785c',
|
||||
gradientColor: '#e8a882',
|
||||
strokeWidth: 2.5,
|
||||
cycleColors: false,
|
||||
isDark: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'animationComplete'): void
|
||||
(e: 'phaseChange', phase: AnimationPhase): void
|
||||
(e: 'colorChange', colors: ColorScheme): void
|
||||
}>()
|
||||
|
||||
// Unique ID for gradient
|
||||
const gradientId = `aether-gradient-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const viewBox = AETHER_SVG_VIEWBOX
|
||||
const linePaths = AETHER_LINE_PATHS
|
||||
const fullPath = AETHER_FULL_PATH
|
||||
|
||||
// Path refs and lengths
|
||||
const pathRefs = ref<(SVGPathElement | null)[]>(new Array(LINE_COUNT).fill(null))
|
||||
const pathLengths = ref<number[]>(new Array(LINE_COUNT).fill(DEFAULT_PATH_LENGTH))
|
||||
|
||||
// Animation states
|
||||
const lineDrawn = ref<boolean[]>(new Array(LINE_COUNT).fill(false))
|
||||
const isFilled = ref(false)
|
||||
const currentPhase = ref<AnimationPhase>('idle')
|
||||
const isAnimating = ref(false)
|
||||
|
||||
// Timer cleanup
|
||||
let animationAborted = false
|
||||
let startTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let hasStartedOnce = false
|
||||
|
||||
// Color cycling state
|
||||
const colorIndex = ref(0)
|
||||
|
||||
// Computed
|
||||
const activeSchemes = computed(() => props.isDark ? DARK_MODE_SCHEMES : LIGHT_MODE_SCHEMES)
|
||||
|
||||
const currentColors = computed<ColorScheme>(() => {
|
||||
if (props.cycleColors) {
|
||||
return activeSchemes.value[colorIndex.value % activeSchemes.value.length]
|
||||
}
|
||||
return { primary: props.outlineColor, secondary: props.gradientColor }
|
||||
})
|
||||
|
||||
const metallicColors = computed(() => ({
|
||||
dark: adjustColor(currentColors.value.primary, -20),
|
||||
base: currentColors.value.primary,
|
||||
light: currentColors.value.secondary,
|
||||
highlight: adjustColor(currentColors.value.secondary, 30)
|
||||
}))
|
||||
|
||||
// Fill style with fade transition
|
||||
const fillStyle = computed(() => ({
|
||||
opacity: isFilled.value ? 0.85 : 0,
|
||||
transition: `opacity ${props.fillDuration}ms ease-in-out`
|
||||
}))
|
||||
|
||||
// Helper functions
|
||||
function adjustColor(hex: string, amount: number): string {
|
||||
const num = parseInt(hex.replace('#', ''), 16)
|
||||
const r = Math.min(255, Math.max(0, (num >> 16) + amount))
|
||||
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + amount))
|
||||
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + amount))
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
const setPathRef = (el: SVGPathElement | null, index: number) => {
|
||||
pathRefs.value[index] = el
|
||||
}
|
||||
|
||||
const calculatePathLengths = () => {
|
||||
pathRefs.value.forEach((path, index) => {
|
||||
if (path) {
|
||||
try {
|
||||
pathLengths.value[index] = path.getTotalLength()
|
||||
} catch {
|
||||
pathLengths.value[index] = DEFAULT_PATH_LENGTH
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Line style with stroke drawing animation
|
||||
const getLineStyle = (index: number) => {
|
||||
const pathLength = pathLengths.value[index]
|
||||
const isDrawn = lineDrawn.value[index]
|
||||
const phase = currentPhase.value
|
||||
|
||||
// Only enable transition during actual draw/erase phases
|
||||
let transition = 'none'
|
||||
if (phase === 'drawOutline' || phase === 'eraseOutline') {
|
||||
transition = `stroke-dashoffset ${props.strokeDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`
|
||||
}
|
||||
|
||||
return {
|
||||
strokeDasharray: pathLength,
|
||||
strokeDashoffset: isDrawn ? 0 : pathLength,
|
||||
transition
|
||||
}
|
||||
}
|
||||
|
||||
// Abortable wait
|
||||
const wait = (ms: number) => new Promise<void>((resolve, reject) => {
|
||||
if (animationAborted) {
|
||||
reject(new Error('Animation aborted'))
|
||||
return
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (animationAborted) {
|
||||
reject(new Error('Animation aborted'))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}, ms)
|
||||
|
||||
if (animationAborted) {
|
||||
clearTimeout(timeoutId)
|
||||
reject(new Error('Animation aborted'))
|
||||
}
|
||||
})
|
||||
|
||||
const nextColor = () => {
|
||||
colorIndex.value = (colorIndex.value + 1) % activeSchemes.value.length
|
||||
emit('colorChange', currentColors.value)
|
||||
}
|
||||
|
||||
// Animation instance counter to prevent multiple concurrent animations
|
||||
let animationInstanceId = 0
|
||||
|
||||
// Main animation sequence
|
||||
const startAnimation = async () => {
|
||||
if (isAnimating.value) return
|
||||
|
||||
const currentInstanceId = ++animationInstanceId
|
||||
isAnimating.value = true
|
||||
animationAborted = false
|
||||
|
||||
try {
|
||||
// Reset states
|
||||
lineDrawn.value = new Array(LINE_COUNT).fill(false)
|
||||
isFilled.value = false
|
||||
currentPhase.value = 'idle'
|
||||
|
||||
await nextTick()
|
||||
calculatePathLengths()
|
||||
await nextTick()
|
||||
|
||||
// Phase 1: Draw outlines (line by line)
|
||||
currentPhase.value = 'drawOutline'
|
||||
emit('phaseChange', 'drawOutline')
|
||||
|
||||
for (let i = 0; i < LINE_COUNT; i++) {
|
||||
lineDrawn.value[i] = true
|
||||
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
|
||||
}
|
||||
await wait(props.strokeDuration)
|
||||
|
||||
// Phase 2: Fill fade in
|
||||
currentPhase.value = 'fillFadeIn'
|
||||
emit('phaseChange', 'fillFadeIn')
|
||||
isFilled.value = true
|
||||
await wait(props.fillDuration)
|
||||
|
||||
// Hold
|
||||
currentPhase.value = 'hold'
|
||||
await wait(props.loopPause / 2)
|
||||
|
||||
// Phase 3: Fill fade out
|
||||
currentPhase.value = 'fillFadeOut'
|
||||
emit('phaseChange', 'fillFadeOut')
|
||||
isFilled.value = false
|
||||
await wait(props.fillDuration)
|
||||
|
||||
// Phase 4: Erase outlines (line by line)
|
||||
currentPhase.value = 'eraseOutline'
|
||||
emit('phaseChange', 'eraseOutline')
|
||||
|
||||
for (let i = 0; i < LINE_COUNT; i++) {
|
||||
lineDrawn.value[i] = false
|
||||
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
|
||||
}
|
||||
await wait(props.strokeDuration)
|
||||
|
||||
currentPhase.value = 'idle'
|
||||
isAnimating.value = false
|
||||
emit('animationComplete')
|
||||
|
||||
// Check if this animation instance is still valid before looping
|
||||
if (props.loop && !animationAborted && currentInstanceId === animationInstanceId) {
|
||||
if (props.cycleColors) nextColor()
|
||||
await wait(props.loopPause / 2)
|
||||
// Double check before recursing
|
||||
if (!animationAborted && currentInstanceId === animationInstanceId) {
|
||||
startAnimation()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
isAnimating.value = false
|
||||
currentPhase.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
animationAborted = true
|
||||
lineDrawn.value = new Array(LINE_COUNT).fill(false)
|
||||
isFilled.value = false
|
||||
currentPhase.value = 'idle'
|
||||
isAnimating.value = false
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
animationAborted = true
|
||||
}
|
||||
|
||||
watch(() => props.isDark, () => {
|
||||
colorIndex.value = 0
|
||||
})
|
||||
|
||||
defineExpose({ startAnimation, reset, stop, isAnimating, currentPhase, nextColor, colorIndex })
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
calculatePathLengths()
|
||||
if (props.autoStart && !hasStartedOnce) {
|
||||
hasStartedOnce = true
|
||||
startTimeoutId = setTimeout(startAnimation, 300)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
animationAborted = true
|
||||
if (startTimeoutId) {
|
||||
clearTimeout(startTimeoutId)
|
||||
startTimeoutId = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.autoStart, (newVal) => {
|
||||
if (newVal && !isAnimating.value && !hasStartedOnce) {
|
||||
hasStartedOnce = true
|
||||
startAnimation()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-by-line-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.line-path {
|
||||
will-change: stroke-dashoffset;
|
||||
}
|
||||
|
||||
.fill-path {
|
||||
will-change: opacity;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ghost-path {
|
||||
opacity: 0.06;
|
||||
}
|
||||
</style>
|
||||
400
frontend/src/components/CodeHighlight.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<pre><code :class="`language-${language}`" v-html="highlightedCode"></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import ini from 'highlight.js/lib/languages/ini'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
|
||||
// 注册需要的语言
|
||||
hljs.registerLanguage('bash', bash)
|
||||
hljs.registerLanguage('sh', bash)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('toml', ini)
|
||||
hljs.registerLanguage('ini', ini)
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
|
||||
const props = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
dense?: boolean
|
||||
}>()
|
||||
|
||||
const wrapperClass = computed(() =>
|
||||
['code-highlight', props.dense ? 'code-highlight--dense' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
// 自定义 bash 高亮增强
|
||||
function enhanceBashHighlight(html: string, code: string): string {
|
||||
// 如果 highlight.js 已经识别了 token,直接返回
|
||||
if (html.includes('hljs-')) {
|
||||
return html
|
||||
}
|
||||
|
||||
// 手动添加高亮
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
// 使用占位符保护URL
|
||||
const urlPlaceholders: string[] = []
|
||||
let result = escaped.replace(/(https?:\/\/[^\s]+)/g, (match) => {
|
||||
const index = urlPlaceholders.length
|
||||
urlPlaceholders.push(match)
|
||||
return `__URL_PLACEHOLDER_${index}__`
|
||||
})
|
||||
|
||||
// 高亮命令关键字
|
||||
result = result
|
||||
.replace(/\b(curl|npm|npx|git|bash|sh|powershell|iex|irm|wget|apt|yum|brew|pip|python|node|docker|kubectl)\b/g, '<span class="hljs-built_in">$1</span>')
|
||||
.replace(/(^|\s)(-[a-zA-Z]+)/gm, '$1<span class="hljs-meta">$2</span>')
|
||||
.replace(/(\|)/g, '<span class="hljs-keyword">$1</span>')
|
||||
|
||||
// 恢复URL并添加高亮
|
||||
result = result.replace(/__URL_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return `<span class="hljs-string">${urlPlaceholders[parseInt(index)]}</span>`
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 自定义 dotenv 高亮
|
||||
function highlightDotenv(code: string): string {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
return escaped
|
||||
// 注释行
|
||||
.replace(/^(#.*)$/gm, '<span class="hljs-comment">$1</span>')
|
||||
// 环境变量 KEY=VALUE
|
||||
.replace(/^([A-Z_][A-Z0-9_]*)(=)(.*)$/gm, (_, key, eq, value) => {
|
||||
return `<span class="hljs-attr">${key}</span>${eq}<span class="hljs-string">${value}</span>`
|
||||
})
|
||||
}
|
||||
|
||||
const highlightedCode = computed(() => {
|
||||
const lang = props.language.trim().toLowerCase()
|
||||
const code = props.code ?? ''
|
||||
|
||||
let result: string
|
||||
try {
|
||||
if (lang === 'bash' || lang === 'sh') {
|
||||
const highlighted = hljs.highlight(code, { language: 'bash' }).value
|
||||
result = enhanceBashHighlight(highlighted, code)
|
||||
} else if (lang === 'dotenv' || lang === 'env') {
|
||||
result = highlightDotenv(code)
|
||||
} else {
|
||||
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
|
||||
result = hljs.highlight(code, { language }).value
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Highlight error:', e)
|
||||
result = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// Highlight placeholders that need user modification (e.g., your-api-key, latest-model-name)
|
||||
result = highlightPlaceholders(result)
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Highlight placeholder values that users need to modify
|
||||
function highlightPlaceholders(html: string): string {
|
||||
// Match common placeholder patterns (already HTML-escaped)
|
||||
const placeholderPatterns = [
|
||||
/your-api-key/gi,
|
||||
/latest-model-name/gi,
|
||||
/your-[a-z-]+/gi,
|
||||
]
|
||||
|
||||
for (const pattern of placeholderPatterns) {
|
||||
html = html.replace(pattern, (match) => {
|
||||
// Avoid double-wrapping if already in a span
|
||||
return `<span class="hljs-placeholder">${match}</span>`
|
||||
})
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-highlight {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.code-highlight pre {
|
||||
margin: 0;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-code-background);
|
||||
font-family: var(--font-mono, 'Cascadia Code', monospace);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-code-text);
|
||||
overflow-x: auto;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.code-highlight code {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.code-highlight--dense pre {
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.9375rem;
|
||||
border-radius: 0.75rem;
|
||||
line-height: 1.5;
|
||||
/* Dense mode: transparent background for embedding in panels */
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Highlight.js 浅色主题 */
|
||||
.code-highlight :deep(.hljs-string) {
|
||||
color: #24292e;
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-attr),
|
||||
.code-highlight :deep(.hljs-attribute) {
|
||||
color: #c96442;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-keyword),
|
||||
.code-highlight :deep(.hljs-selector-tag),
|
||||
.code-highlight :deep(.hljs-built_in) {
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-comment) {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-function),
|
||||
.code-highlight :deep(.hljs-title) {
|
||||
color: #6f42c1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-number) {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-literal),
|
||||
.code-highlight :deep(.language-json .hljs-literal),
|
||||
.code-highlight :deep(.language-json .hljs-literal .hljs-keyword) {
|
||||
color: #24292e;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-variable),
|
||||
.code-highlight :deep(.hljs-property) {
|
||||
color: #cc785c;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-punctuation) {
|
||||
color: #24292e;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Placeholder values that need user modification */
|
||||
.code-highlight :deep(.hljs-placeholder) {
|
||||
color: #d73a49;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Bash 命令样式 - 浅色模式 */
|
||||
.code-highlight :deep(.hljs-built_in),
|
||||
.code-highlight :deep(.hljs-name),
|
||||
.code-highlight :deep(.language-bash .hljs-built_in),
|
||||
.code-highlight :deep(.language-bash .hljs-name) {
|
||||
color: #d73a49;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-meta),
|
||||
.code-highlight :deep(.language-bash .hljs-meta) {
|
||||
color: #d73a49;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.hljs-params),
|
||||
.code-highlight :deep(.language-bash .hljs-params) {
|
||||
color: #0366d6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.language-bash .hljs-keyword),
|
||||
.code-highlight :deep(.language-bash .hljs-literal) {
|
||||
color: #0366d6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.language-bash) {
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
.code-highlight :deep(.language-bash .hljs-string) {
|
||||
color: #24292e;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.code-highlight :deep(pre code.language-bash) {
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
.code-highlight :deep(pre code.language-bash .hljs-subst) {
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
/* Highlight.js 深色主题 */
|
||||
.dark .code-highlight :deep(.hljs-string),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-string) {
|
||||
color: #f1ead8;
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-attr),
|
||||
.dark .code-highlight :deep(.hljs-attribute),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-attr),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-attribute) {
|
||||
color: #d4a27f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-keyword),
|
||||
.dark .code-highlight :deep(.hljs-selector-tag),
|
||||
.dark .code-highlight :deep(.hljs-built_in),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-keyword),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-selector-tag),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-built_in) {
|
||||
color: #569cd6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-comment),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-comment) {
|
||||
color: #6a9955;
|
||||
font-style: italic;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-function),
|
||||
.dark .code-highlight :deep(.hljs-title),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-function),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-title) {
|
||||
color: #dcdcaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-number),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-number) {
|
||||
color: #b5cea8;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-literal),
|
||||
.dark .code-highlight :deep(.language-json .hljs-literal),
|
||||
.dark .code-highlight :deep(.language-json .hljs-literal .hljs-keyword),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-literal),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-json .hljs-literal),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-json .hljs-literal .hljs-keyword) {
|
||||
color: #e1e4e8;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-variable),
|
||||
.dark .code-highlight :deep(.hljs-property),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-variable),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-property) {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-punctuation),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-punctuation) {
|
||||
color: #d4d4d4;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Placeholder values - dark mode */
|
||||
.dark .code-highlight :deep(.hljs-placeholder),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-placeholder) {
|
||||
color: #f97583;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Bash 深色主题 */
|
||||
.dark .code-highlight :deep(.hljs-built_in),
|
||||
.dark .code-highlight :deep(.hljs-name),
|
||||
.dark .code-highlight :deep(.language-bash .hljs-built_in),
|
||||
.dark .code-highlight :deep(.language-bash .hljs-name),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-built_in),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-name),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-built_in),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-name) {
|
||||
color: #e67764;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-meta),
|
||||
.dark .code-highlight :deep(.language-bash .hljs-meta),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-meta),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-meta) {
|
||||
color: #e67764;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.hljs-params),
|
||||
.dark .code-highlight :deep(.language-bash .hljs-params),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.hljs-params),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-params) {
|
||||
color: #6fa9e6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.language-bash .hljs-keyword),
|
||||
.dark .code-highlight :deep(.language-bash .hljs-literal),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-keyword),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-literal) {
|
||||
color: #6fa9e6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.language-bash),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-bash) {
|
||||
color: #e1e4e8;
|
||||
}
|
||||
|
||||
.dark .code-highlight :deep(.language-bash .hljs-string),
|
||||
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-string) {
|
||||
color: #e1e4e8;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
26
frontend/src/components/ConfirmContainer.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<AlertDialog
|
||||
:model-value="state.isOpen"
|
||||
:title="state.title || '确认操作'"
|
||||
:description="state.message"
|
||||
:confirm-text="state.confirmText || '确认'"
|
||||
:cancel-text="state.cancelText || '取消'"
|
||||
:type="state.variant || 'question'"
|
||||
@update:model-value="handleClose"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AlertDialog from './common/AlertDialog.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const { state, handleConfirm, handleCancel } = useConfirm()
|
||||
|
||||
function handleClose(value: boolean) {
|
||||
if (!value) {
|
||||
handleCancel()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
403
frontend/src/components/GeminiStarCluster.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="!isFullyHidden"
|
||||
class="gemini-star-cluster absolute inset-0 overflow-hidden pointer-events-none"
|
||||
:class="{ 'scattering': isScattering, 'fading-out': isFadingOut }"
|
||||
>
|
||||
<!-- SVG Defs for the Gemini multi-color gradient -->
|
||||
<svg class="absolute w-0 h-0 overflow-hidden" aria-hidden="true">
|
||||
<defs>
|
||||
<!-- Main Gemini gradient (blue base with color overlays) -->
|
||||
<linearGradient id="gemini-base" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1A73E8" />
|
||||
<stop offset="50%" stop-color="#4285F4" />
|
||||
<stop offset="100%" stop-color="#669DF6" />
|
||||
</linearGradient>
|
||||
<!-- Red accent overlay - from top -->
|
||||
<linearGradient id="gemini-red-overlay" x1="50%" y1="0%" x2="50%" y2="50%">
|
||||
<stop offset="0%" stop-color="#EA4335" />
|
||||
<stop offset="100%" stop-color="#EA4335" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<!-- Yellow accent overlay - from left -->
|
||||
<linearGradient id="gemini-yellow-overlay" x1="0%" y1="50%" x2="50%" y2="50%">
|
||||
<stop offset="0%" stop-color="#FBBC04" />
|
||||
<stop offset="100%" stop-color="#FBBC04" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<!-- Green accent overlay - from bottom -->
|
||||
<linearGradient id="gemini-green-overlay" x1="50%" y1="100%" x2="50%" y2="50%">
|
||||
<stop offset="0%" stop-color="#34A853" />
|
||||
<stop offset="100%" stop-color="#34A853" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<!-- Glow filter -->
|
||||
<filter id="star-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feFlood flood-color="#4285F4" flood-opacity="0.3" />
|
||||
<feComposite in2="blur" operator="in" />
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- Layer 1: Far background stars (smallest, lowest z-index) -->
|
||||
<div class="stars-layer far-layer">
|
||||
<div
|
||||
v-for="star in farStars"
|
||||
:key="`far-${star.id}`"
|
||||
class="star-wrapper"
|
||||
:class="{ 'star-visible': star.visible && hasScattered }"
|
||||
:style="getStarStyle(star)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="star-svg">
|
||||
<path :d="starPath" fill="url(#gemini-base)" />
|
||||
<path :d="starPath" fill="url(#gemini-red-overlay)" />
|
||||
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
|
||||
<path :d="starPath" fill="url(#gemini-green-overlay)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layer 2: Mid-distance stars -->
|
||||
<div class="stars-layer mid-layer">
|
||||
<div
|
||||
v-for="star in midStars"
|
||||
:key="`mid-${star.id}`"
|
||||
class="star-wrapper"
|
||||
:class="{ 'star-visible': star.visible && hasScattered }"
|
||||
:style="getStarStyle(star)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="star-svg" style="filter: url(#star-glow)">
|
||||
<path :d="starPath" fill="url(#gemini-base)" />
|
||||
<path :d="starPath" fill="url(#gemini-red-overlay)" />
|
||||
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
|
||||
<path :d="starPath" fill="url(#gemini-green-overlay)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layer 3: Near stars (largest, highest z-index - in front, not occluded by small stars) -->
|
||||
<div class="stars-layer near-layer">
|
||||
<div
|
||||
v-for="star in nearStars"
|
||||
:key="`near-${star.id}`"
|
||||
class="star-wrapper"
|
||||
:class="{ 'star-visible': star.visible && hasScattered }"
|
||||
:style="getStarStyle(star)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="star-svg" style="filter: url(#star-glow)">
|
||||
<path :d="starPath" fill="url(#gemini-base)" />
|
||||
<path :d="starPath" fill="url(#gemini-red-overlay)" />
|
||||
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
|
||||
<path :d="starPath" fill="url(#gemini-green-overlay)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
|
||||
|
||||
// Props for transition control
|
||||
const props = withDefaults(defineProps<{
|
||||
isVisible?: boolean
|
||||
}>(), {
|
||||
isVisible: true
|
||||
})
|
||||
|
||||
// Gemini star SVG path (4-point star) - viewBox 0 0 24 24
|
||||
const starPath = 'M12 1.5c.2 3.4 1.4 6.4 3.8 8.8 2.4 2.4 5.4 3.6 8.8 3.8-3.4.2-6.4 1.4-8.8 3.8-2.4 2.4-3.6 5.4-3.8 8.8-.2-3.4-1.4-6.4-3.8-8.8-2.4-2.4-5.4-3.6-8.8-3.8 3.4-.2 6.4-1.4 8.8-3.8 2.4-2.4 3.6-5.4 3.8-8.8z'
|
||||
|
||||
interface Star {
|
||||
id: number
|
||||
x: number
|
||||
y: number
|
||||
targetX: number
|
||||
targetY: number
|
||||
size: number
|
||||
baseOpacity: number
|
||||
visible: boolean
|
||||
zIndex: number
|
||||
// Animation props
|
||||
twinkleDuration: number
|
||||
twinkleDelay: number
|
||||
}
|
||||
|
||||
const farStars = ref<Star[]>([])
|
||||
const midStars = ref<Star[]>([])
|
||||
const nearStars = ref<Star[]>([])
|
||||
const hasScattered = ref(false)
|
||||
const isScattering = ref(false)
|
||||
const isFadingOut = ref(false)
|
||||
const isFullyHidden = ref(false)
|
||||
|
||||
const CENTER_X = 50
|
||||
const CENTER_Y = 50
|
||||
|
||||
// Generate constrained position to keep stars within bounds
|
||||
const getConstrainedPosition = (size: number): { x: number; y: number } => {
|
||||
// Calculate padding based on star size (in percentage)
|
||||
// Assuming container is roughly 400-600px wide, use a safe estimate
|
||||
const paddingPercent = Math.max(3, (size / 5))
|
||||
const minPos = paddingPercent
|
||||
const maxPos = 100 - paddingPercent
|
||||
|
||||
return {
|
||||
x: minPos + Math.random() * (maxPos - minPos),
|
||||
y: minPos + Math.random() * (maxPos - minPos)
|
||||
}
|
||||
}
|
||||
|
||||
const createStar = (id: number, sizeRange: [number, number, number, number, number, number], opacityBase: number, opacityRange: number, visibleThreshold: number, zIndexBase: number): Star => {
|
||||
const sizeVariant = Math.random()
|
||||
let size: number
|
||||
if (sizeVariant < 0.4) {
|
||||
size = sizeRange[0] + Math.random() * sizeRange[1]
|
||||
} else if (sizeVariant < 0.7) {
|
||||
size = sizeRange[2] + Math.random() * sizeRange[3]
|
||||
} else {
|
||||
size = sizeRange[4] + Math.random() * sizeRange[5]
|
||||
}
|
||||
|
||||
const { x: targetX, y: targetY } = getConstrainedPosition(size)
|
||||
|
||||
return {
|
||||
id,
|
||||
x: CENTER_X,
|
||||
y: CENTER_Y,
|
||||
targetX,
|
||||
targetY,
|
||||
size,
|
||||
baseOpacity: opacityBase + Math.random() * opacityRange,
|
||||
visible: true,
|
||||
zIndex: zIndexBase + Math.round(size),
|
||||
twinkleDuration: 3 + Math.random() * 4, // 3-7s duration
|
||||
twinkleDelay: Math.random() * 5 // 0-5s delay
|
||||
}
|
||||
}
|
||||
|
||||
const createStarLayers = () => {
|
||||
const far: Star[] = []
|
||||
for (let i = 0; i < 30; i++) {
|
||||
far.push(createStar(i, [6, 4, 10, 6, 14, 6], 0.2, 0.25, 0.35, 0))
|
||||
}
|
||||
farStars.value = far
|
||||
|
||||
const mid: Star[] = []
|
||||
for (let i = 0; i < 18; i++) {
|
||||
mid.push(createStar(i, [18, 8, 24, 10, 32, 10], 0.35, 0.35, 0.45, 30))
|
||||
}
|
||||
midStars.value = mid
|
||||
|
||||
const near: Star[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
near.push(createStar(i, [40, 15, 55, 20, 70, 25], 0.5, 0.5, 0.55, 100))
|
||||
}
|
||||
nearStars.value = near
|
||||
}
|
||||
|
||||
// Removed: handleAnimationIteration was causing stars to "teleport" visibly
|
||||
// Stars now stay in place and only twinkle without changing position
|
||||
|
||||
// Scatter stars from center to their target positions
|
||||
const scatterStars = () => {
|
||||
isScattering.value = true
|
||||
hasScattered.value = true
|
||||
|
||||
const allStars = [...farStars.value, ...midStars.value, ...nearStars.value]
|
||||
allStars.forEach(star => {
|
||||
star.x = star.targetX
|
||||
star.y = star.targetY
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isScattering.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Reset stars to center
|
||||
const resetStarsToCenter = () => {
|
||||
hasScattered.value = false
|
||||
isScattering.value = false
|
||||
|
||||
const allStars = [...farStars.value, ...midStars.value, ...nearStars.value]
|
||||
allStars.forEach(star => {
|
||||
star.x = CENTER_X
|
||||
star.y = CENTER_Y
|
||||
const { x, y } = getConstrainedPosition(star.size)
|
||||
star.targetX = x
|
||||
star.targetY = y
|
||||
})
|
||||
}
|
||||
|
||||
const getStarStyle = (star: Star): CSSProperties => {
|
||||
return {
|
||||
left: `${star.x}%`,
|
||||
top: `${star.y}%`,
|
||||
width: `${star.size}px`,
|
||||
height: `${star.size}px`,
|
||||
zIndex: star.zIndex,
|
||||
'--base-opacity': star.baseOpacity,
|
||||
'--twinkle-duration': `${star.twinkleDuration}s`,
|
||||
'--twinkle-delay': `${star.twinkleDelay}s`
|
||||
} as CSSProperties
|
||||
}
|
||||
|
||||
// Watch for visibility changes
|
||||
watch(() => props.isVisible, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
// Entering: recreate stars if needed, reset to center then scatter
|
||||
isFadingOut.value = false
|
||||
isFullyHidden.value = false
|
||||
// Recreate stars if they were cleared
|
||||
if (farStars.value.length === 0) {
|
||||
createStarLayers()
|
||||
} else {
|
||||
resetStarsToCenter()
|
||||
}
|
||||
setTimeout(() => {
|
||||
scatterStars()
|
||||
}, 50)
|
||||
} else if (!newVal && oldVal) {
|
||||
// Leaving: immediately stop animation and hide to release GPU resources
|
||||
hasScattered.value = false
|
||||
isScattering.value = false // Force stop scattering transition immediately
|
||||
isFadingOut.value = true
|
||||
|
||||
// Wait for fade-out transition (150ms) to complete before cleanup
|
||||
// Use a single timeout matching the CSS transition duration
|
||||
setTimeout(() => {
|
||||
if (!props.isVisible) {
|
||||
isFullyHidden.value = true
|
||||
isFadingOut.value = false
|
||||
// Clear star arrays to fully release memory
|
||||
farStars.value = []
|
||||
midStars.value = []
|
||||
nearStars.value = []
|
||||
}
|
||||
}, 180) // Slightly longer than CSS transition (150ms) to ensure smooth fade
|
||||
}
|
||||
}, { flush: 'post' }) // Change to post flush to ensure DOM is updated before our cleanup logic runs
|
||||
|
||||
onMounted(() => {
|
||||
createStarLayers()
|
||||
if (props.isVisible) {
|
||||
isFullyHidden.value = false
|
||||
setTimeout(() => {
|
||||
scatterStars()
|
||||
}, 100)
|
||||
} else {
|
||||
// Start hidden if not visible
|
||||
isFullyHidden.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// No explicit cleanup needed for CSS animations
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gemini-star-cluster {
|
||||
perspective: 800px;
|
||||
}
|
||||
|
||||
.stars-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.far-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mid-layer {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.near-layer {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.star-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.star-wrapper {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transform: scale(0.3) translate(-50%, -50%);
|
||||
transition:
|
||||
opacity 0.8s ease-out,
|
||||
transform 0.8s ease-out;
|
||||
/* Avoid persistent will-change to reduce GPU memory usage */
|
||||
}
|
||||
|
||||
.star-wrapper.star-visible {
|
||||
opacity: 0; /* Default to invisible, animation handles opacity */
|
||||
transform: scale(1) translate(-50%, -50%);
|
||||
animation: twinkle var(--twinkle-duration) ease-in-out infinite;
|
||||
animation-delay: var(--twinkle-delay);
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) translate(-50%, -50%);
|
||||
}
|
||||
50% {
|
||||
opacity: var(--base-opacity);
|
||||
transform: scale(1) translate(-50%, -50%);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scatter animation - stars fly outward from center (2s duration) */
|
||||
.scattering .star-wrapper {
|
||||
transition:
|
||||
opacity 0.8s ease-out,
|
||||
transform 0.8s ease-out,
|
||||
left 2s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
top 2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
/* Only use will-change during active scatter animation */
|
||||
will-change: opacity, transform, left, top;
|
||||
}
|
||||
|
||||
/* Fade out animation - quick fade and disable child transitions */
|
||||
.fading-out {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
pointer-events: none;
|
||||
/* Force GPU layer removal */
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
.fading-out .star-wrapper {
|
||||
transition: none !important;
|
||||
will-change: auto !important;
|
||||
animation: none !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* Depth blur effect */
|
||||
.far-layer .star-wrapper {
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
|
||||
.mid-layer .star-wrapper {
|
||||
filter: blur(0.2px);
|
||||
}
|
||||
|
||||
.near-layer .star-wrapper {
|
||||
filter: none;
|
||||
}
|
||||
</style>
|
||||
40
frontend/src/components/HeaderLogo.vue
Normal file
310
frontend/src/components/PlatformSelect.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="platform-select"
|
||||
:class="[`platform-select--${sizeClass}` , { 'platform-select--open': isOpen }]"
|
||||
tabindex="0"
|
||||
@click="handleRootClick"
|
||||
@keydown.enter.prevent="toggleDropdown"
|
||||
@keydown.space.prevent="toggleDropdown"
|
||||
@keydown.escape.stop="closeDropdown"
|
||||
>
|
||||
<div class="platform-select__current">
|
||||
<component :is="currentOption.icon" class="platform-select__icon" />
|
||||
<div class="platform-select__text">
|
||||
<p class="platform-select__label">{{ currentOption.label }}</p>
|
||||
<p class="platform-select__hint">{{ currentOption.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown class="platform-select__chevron" />
|
||||
|
||||
<transition name="platform-select-fade">
|
||||
<ul v-if="isOpen" class="platform-select__dropdown">
|
||||
<li
|
||||
v-for="option in resolvedOptions"
|
||||
:key="option.value"
|
||||
class="platform-select__option"
|
||||
:class="{ 'platform-select__option--active': option.value === modelValue }"
|
||||
@click.stop="selectOption(option.value)"
|
||||
>
|
||||
<component :is="option.icon" class="platform-select__option-icon" />
|
||||
<div class="platform-select__option-copy">
|
||||
<p class="platform-select__option-label">{{ option.label }}</p>
|
||||
<p class="platform-select__option-hint">{{ option.hint }}</p>
|
||||
</div>
|
||||
<Check class="platform-select__option-check" v-if="option.value === modelValue" />
|
||||
</li>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Apple, Box, Monitor, Terminal } from 'lucide-vue-next'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface PlatformOption {
|
||||
value: string
|
||||
label: string
|
||||
hint: string
|
||||
icon: Component
|
||||
command: string
|
||||
}
|
||||
|
||||
// Default options for backward compatibility
|
||||
export const defaultPlatformOptions: PlatformOption[] = [
|
||||
{ value: 'mac', label: 'Mac / Linux', hint: 'Terminal', icon: Terminal, command: '' },
|
||||
{ value: 'windows', label: 'Windows', hint: 'PowerShell', icon: Monitor, command: '' }
|
||||
]
|
||||
|
||||
// Preset configuration for each tool
|
||||
export const platformPresets = {
|
||||
default: {
|
||||
options: defaultPlatformOptions,
|
||||
defaultValue: 'mac'
|
||||
},
|
||||
claude: {
|
||||
options: [
|
||||
{ value: 'mac', label: 'Mac / Linux', hint: 'Terminal', icon: Terminal, command: 'curl -fsSL https://claude.ai/install.sh | bash' },
|
||||
{ value: 'windows', label: 'Windows', hint: 'PowerShell', icon: Monitor, command: 'irm https://claude.ai/install.ps1 | iex' },
|
||||
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @anthropic-ai/claude-code' },
|
||||
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask claude-code' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'mac'
|
||||
},
|
||||
codex: {
|
||||
options: [
|
||||
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @openai/codex' },
|
||||
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask codex' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'nodejs'
|
||||
},
|
||||
gemini: {
|
||||
options: [
|
||||
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @google/gemini-cli' },
|
||||
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install gemini-cli' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'nodejs'
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get command by platform value
|
||||
export function getCommand(preset: keyof typeof platformPresets, value: string): string {
|
||||
const config = platformPresets[preset]
|
||||
return config.options.find((opt: PlatformOption) => opt.value === value)?.command ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
size?: 'md' | 'lg'
|
||||
options?: PlatformOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const sizeClass = computed(() => props.size ?? 'md')
|
||||
|
||||
const resolvedOptions = computed(() => props.options ?? defaultPlatformOptions)
|
||||
|
||||
const currentOption = computed(() => resolvedOptions.value.find((option: PlatformOption) => option.value === props.modelValue) ?? resolvedOptions.value[0])
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function selectOption(value: string) {
|
||||
if (value !== props.modelValue) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
function handleRootClick(event: MouseEvent) {
|
||||
const dropdown = rootEl.value?.querySelector('.platform-select__dropdown')
|
||||
if (dropdown?.contains(event.target as Node)) {
|
||||
return
|
||||
}
|
||||
toggleDropdown()
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!rootEl.value) {
|
||||
return
|
||||
}
|
||||
if (!rootEl.value.contains(event.target as Node)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-select {
|
||||
position: relative;
|
||||
width: 11rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.9rem;
|
||||
background-color: var(--color-background);
|
||||
padding: 0.55rem 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.dark .platform-select {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.platform-select--lg {
|
||||
width: 13rem;
|
||||
}
|
||||
|
||||
.platform-select:focus-visible,
|
||||
.platform-select--open {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.2);
|
||||
}
|
||||
|
||||
.platform-select__current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.platform-select__icon {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.platform-select__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.platform-select__label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-select__hint {
|
||||
font-size: 0.7rem;
|
||||
color: #91918d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dark .platform-select__hint {
|
||||
color: #a8a29e;
|
||||
}
|
||||
|
||||
.platform-select__chevron {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
color: var(--color-border-soft);
|
||||
}
|
||||
|
||||
.platform-select__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.35rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-background);
|
||||
box-shadow: 0 25px 55px rgba(0, 0, 0, 0.25);
|
||||
z-index: 30;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.platform-select__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 0.75rem;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.platform-select__option:hover {
|
||||
background: rgba(204, 120, 92, 0.1);
|
||||
}
|
||||
|
||||
.platform-select__option--active {
|
||||
background: rgba(204, 120, 92, 0.18);
|
||||
}
|
||||
|
||||
.platform-select__option-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.platform-select__option-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.platform-select__option-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-select__option-hint {
|
||||
font-size: 0.7rem;
|
||||
color: #91918d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dark .platform-select__option-hint {
|
||||
color: #a8a29e;
|
||||
}
|
||||
|
||||
.platform-select__option-check {
|
||||
margin-left: auto;
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.platform-select-fade-enter-active,
|
||||
.platform-select-fade-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.platform-select-fade-enter-from,
|
||||
.platform-select-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
</style>
|
||||
1239
frontend/src/components/RippleLogo.vue
Normal file
70
frontend/src/components/ToastContainer.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2">
|
||||
<TransitionGroup
|
||||
name="toast"
|
||||
tag="div"
|
||||
class="flex flex-col items-center gap-2"
|
||||
>
|
||||
<ToastWithProgress
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:toast="toast"
|
||||
@remove="removeToast(toast.id)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToastWithProgress from './ToastWithProgress.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { toasts, removeToast } = useToast()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 进入动画 - 从上方弹入 */
|
||||
.toast-enter-active {
|
||||
transition: all 0.4s cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast-enter-to {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 弹出动画 - 向上消失 */
|
||||
.toast-leave-active {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.toast-leave-from {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 移动动画 */
|
||||
.toast-move {
|
||||
transition: all 0.4s cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 640px) {
|
||||
div.fixed {
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
185
frontend/src/components/ToastWithProgress.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div
|
||||
role="alert"
|
||||
class="flex items-start gap-4 px-6 py-3 rounded-lg border shadow-sm max-w-md"
|
||||
:class="variantClasses"
|
||||
>
|
||||
<!-- 图标带圆形进度环 -->
|
||||
<div class="relative shrink-0 w-8 h-8">
|
||||
<!-- 进度环背景 -->
|
||||
<svg
|
||||
v-if="toast.duration && toast.duration > 0"
|
||||
class="absolute inset-0 w-8 h-8 -rotate-90"
|
||||
>
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="opacity-15"
|
||||
/>
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
:stroke-dasharray="circumference"
|
||||
:stroke-dashoffset="strokeDashoffset"
|
||||
stroke-linecap="round"
|
||||
class="transition-[stroke-dashoffset] duration-75"
|
||||
:class="progressColorClass"
|
||||
/>
|
||||
</svg>
|
||||
<!-- 图标 -->
|
||||
<div class="absolute inset-0 flex items-center justify-center" :class="iconClasses">
|
||||
<component :is="icon" class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p v-if="toast.title" class="text-sm font-medium" :class="titleClasses">
|
||||
{{ toast.title }}
|
||||
</p>
|
||||
<p v-if="toast.message" class="text-sm" :class="messageClasses">
|
||||
{{ toast.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="$emit('remove')"
|
||||
class="shrink-0 p-1 rounded transition-colors opacity-40 hover:opacity-100"
|
||||
:class="closeClasses"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { CheckCircle2, XCircle, AlertTriangle, Info, X } from 'lucide-vue-next'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
title?: string
|
||||
message?: string
|
||||
variant?: 'success' | 'error' | 'warning' | 'info'
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
toast: Toast
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const progress = ref(100)
|
||||
let startTime = 0
|
||||
let rafId: number | null = null
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 圆形进度环参数
|
||||
const circumference = 2 * Math.PI * 14 // r=14
|
||||
|
||||
const strokeDashoffset = computed(() => {
|
||||
return circumference * (1 - progress.value / 100)
|
||||
})
|
||||
|
||||
const updateProgress = () => {
|
||||
if (!props.toast.duration || props.toast.duration <= 0) return
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
const remaining = Math.max(0, 100 - (elapsed / props.toast.duration) * 100)
|
||||
|
||||
progress.value = remaining
|
||||
|
||||
if (remaining <= 0) {
|
||||
emit('remove')
|
||||
} else {
|
||||
rafId = requestAnimationFrame(updateProgress)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.toast.duration && props.toast.duration > 0) {
|
||||
startTime = Date.now()
|
||||
rafId = requestAnimationFrame(updateProgress)
|
||||
// 保底 timeout,确保即使在后台也能移除
|
||||
timeoutId = setTimeout(() => {
|
||||
emit('remove')
|
||||
}, props.toast.duration + 100)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
})
|
||||
|
||||
const icons = {
|
||||
success: CheckCircle2,
|
||||
error: XCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info
|
||||
}
|
||||
|
||||
const icon = computed(() => icons[props.toast.variant || 'info'])
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
const variant = props.toast.variant || 'info'
|
||||
const classes: Record<string, string> = {
|
||||
success: 'border-[#5F8D4E]/30 bg-white dark:bg-[var(--slate-dark)]',
|
||||
error: 'border-[var(--error)]/30 bg-white dark:bg-[var(--slate-dark)]',
|
||||
warning: 'border-[var(--book-cloth)]/30 bg-white dark:bg-[var(--slate-dark)]',
|
||||
info: 'border-[var(--slate-medium)]/20 bg-white dark:bg-[var(--slate-dark)]'
|
||||
}
|
||||
return classes[variant]
|
||||
})
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
const variant = props.toast.variant || 'info'
|
||||
const classes: Record<string, string> = {
|
||||
success: 'text-[#5F8D4E]',
|
||||
error: 'text-[var(--error)]',
|
||||
warning: 'text-[var(--book-cloth)]',
|
||||
info: 'text-[var(--slate-medium)] dark:text-[var(--cloud-medium)]'
|
||||
}
|
||||
return classes[variant]
|
||||
})
|
||||
|
||||
const progressColorClass = computed(() => {
|
||||
const variant = props.toast.variant || 'info'
|
||||
const classes: Record<string, string> = {
|
||||
success: 'stroke-[#5F8D4E]',
|
||||
error: 'stroke-[var(--error)]',
|
||||
warning: 'stroke-[var(--book-cloth)]',
|
||||
info: 'stroke-[var(--slate-medium)]'
|
||||
}
|
||||
return classes[variant]
|
||||
})
|
||||
|
||||
const titleClasses = computed(() => {
|
||||
return 'text-[var(--color-text)]'
|
||||
})
|
||||
|
||||
const messageClasses = computed(() => {
|
||||
return 'text-[var(--slate-medium)] dark:text-[var(--cloud-medium)]'
|
||||
})
|
||||
|
||||
const closeClasses = computed(() => {
|
||||
return 'text-[var(--slate-medium)] hover:bg-[var(--color-border-soft)] dark:text-[var(--cloud-medium)]'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
148
frontend/src/components/charts/BarChart.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<canvas ref="chartRef"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
BarController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
type ChartData,
|
||||
type ChartOptions
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
BarController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
)
|
||||
|
||||
interface Props {
|
||||
data: ChartData<'bar'>
|
||||
options?: ChartOptions<'bar'>
|
||||
height?: number
|
||||
stacked?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 300,
|
||||
stacked: true
|
||||
})
|
||||
|
||||
const chartRef = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS<'bar'> | null = null
|
||||
|
||||
const defaultOptions: ChartOptions<'bar'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(107, 114, 128)'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(107, 114, 128)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: 'rgb(107, 114, 128)',
|
||||
usePointStyle: true,
|
||||
padding: 16
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgb(31, 41, 55)',
|
||||
titleColor: 'rgb(243, 244, 246)',
|
||||
bodyColor: 'rgb(243, 244, 246)',
|
||||
borderColor: 'rgb(75, 85, 99)',
|
||||
borderWidth: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
const stackedOptions = props.stacked ? {
|
||||
scales: {
|
||||
x: { ...defaultOptions.scales?.x, stacked: true },
|
||||
y: { ...defaultOptions.scales?.y, stacked: true }
|
||||
}
|
||||
} : {
|
||||
scales: {
|
||||
x: { ...defaultOptions.scales?.x, stacked: false },
|
||||
y: { ...defaultOptions.scales?.y, stacked: false }
|
||||
}
|
||||
}
|
||||
|
||||
chart = new ChartJS(chartRef.value, {
|
||||
type: 'bar',
|
||||
data: props.data,
|
||||
options: {
|
||||
...defaultOptions,
|
||||
...stackedOptions,
|
||||
...props.options
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chart) {
|
||||
chart.data = props.data
|
||||
chart.update('none')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
createChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.data, updateChart, { deep: true })
|
||||
watch(() => props.options, () => {
|
||||
if (chart) {
|
||||
chart.options = {
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
chart.update()
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
128
frontend/src/components/charts/LineChart.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<canvas ref="chartRef"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
type ChartData,
|
||||
type ChartOptions
|
||||
} from 'chart.js'
|
||||
|
||||
// 注册 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
)
|
||||
|
||||
interface Props {
|
||||
data: ChartData<'line'>
|
||||
options?: ChartOptions<'line'>
|
||||
height?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 300
|
||||
})
|
||||
|
||||
const chartRef = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS<'line'> | null = null
|
||||
|
||||
const defaultOptions: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)' // gray-400 with opacity
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(107, 114, 128)' // gray-500
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)' // gray-400 with opacity
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(107, 114, 128)' // gray-500
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: 'rgb(107, 114, 128)' // gray-500
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgb(31, 41, 55)', // gray-800
|
||||
titleColor: 'rgb(243, 244, 246)', // gray-100
|
||||
bodyColor: 'rgb(243, 244, 246)', // gray-100
|
||||
borderColor: 'rgb(75, 85, 99)', // gray-600
|
||||
borderWidth: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chart = new ChartJS(chartRef.value, {
|
||||
type: 'line',
|
||||
data: props.data,
|
||||
options: {
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chart) {
|
||||
chart.data = props.data
|
||||
chart.update('none') // 禁用动画以提高性能
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
createChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据变化
|
||||
watch(() => props.data, updateChart, { deep: true })
|
||||
watch(() => props.options, () => {
|
||||
if (chart) {
|
||||
chart.options = {
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
chart.update()
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
165
frontend/src/components/common/AlertDialog.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<Dialog :modelValue="modelValue" @update:modelValue="handleClose" :zIndex="80">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<component :is="icon" class="h-5 w-5 flex-shrink-0" :class="iconColorClass" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">{{ title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<!-- 描述 -->
|
||||
<div class="space-y-3">
|
||||
<p v-for="(line, index) in descriptionLines" :key="index" :class="getLineClass(index)">
|
||||
{{ line }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 自定义内容插槽 -->
|
||||
<slot></slot>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<!-- 取消按钮 -->
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleCancel"
|
||||
:disabled="loading"
|
||||
class="h-10 px-5"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
|
||||
<!-- 确认按钮 -->
|
||||
<Button
|
||||
:variant="confirmVariant"
|
||||
@click="handleConfirm"
|
||||
:disabled="loading"
|
||||
class="h-10 px-5"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin h-4 w-4 mr-2" />
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { AlertTriangle, AlertCircle, Info, Trash2, HelpCircle, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
export type AlertType = 'danger' | 'destructive' | 'warning' | 'info' | 'question'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
title: string
|
||||
description: string
|
||||
type?: AlertType
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'warning',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 解析描述文本为多行
|
||||
const descriptionLines = computed(() => {
|
||||
return props.description.split('\n').filter(line => line.trim())
|
||||
})
|
||||
|
||||
// 根据行索引获取样式(中间行高亮)
|
||||
function getLineClass(index: number): string {
|
||||
const total = descriptionLines.value.length
|
||||
if (total <= 1) {
|
||||
return 'text-sm text-muted-foreground'
|
||||
}
|
||||
// 中间行(不是第一行也不是最后一行)使用高亮样式
|
||||
if (index > 0 && index < total - 1) {
|
||||
return 'text-sm font-mono font-medium text-foreground bg-muted/50 px-3 py-2 rounded-md'
|
||||
}
|
||||
return 'text-sm text-muted-foreground'
|
||||
}
|
||||
|
||||
// 根据类型获取图标
|
||||
const icon = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'danger':
|
||||
case 'destructive':
|
||||
return Trash2
|
||||
case 'warning':
|
||||
return AlertTriangle
|
||||
case 'info':
|
||||
return Info
|
||||
case 'question':
|
||||
return HelpCircle
|
||||
default:
|
||||
return AlertCircle
|
||||
}
|
||||
})
|
||||
|
||||
// 根据类型获取图标颜色样式
|
||||
const iconColorClass = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'danger':
|
||||
case 'destructive':
|
||||
return 'text-rose-600 dark:text-rose-400'
|
||||
case 'warning':
|
||||
return 'text-amber-600 dark:text-amber-400'
|
||||
case 'info':
|
||||
return 'text-primary'
|
||||
case 'question':
|
||||
return 'text-gray-600 dark:text-muted-foreground'
|
||||
default:
|
||||
return 'text-primary'
|
||||
}
|
||||
})
|
||||
|
||||
// 根据类型获取确认按钮样式
|
||||
const confirmVariant = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'danger':
|
||||
case 'destructive':
|
||||
return 'destructive' as const
|
||||
case 'warning':
|
||||
case 'info':
|
||||
case 'question':
|
||||
default:
|
||||
return 'default' as const
|
||||
}
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function handleClose(value: boolean) {
|
||||
if (!value && !props.loading) {
|
||||
emit('update:modelValue', value)
|
||||
emit('cancel')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
285
frontend/src/components/common/EmptyState.vue
Normal file
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div :class="containerClasses">
|
||||
<!-- 图标 -->
|
||||
<div :class="iconContainerClasses">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
:class="iconClasses"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="defaultIcon"
|
||||
:class="iconClasses"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h3 v-if="title" :class="titleClasses">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<!-- 描述 -->
|
||||
<p v-if="description" :class="descriptionClasses">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- 自定义内容插槽 -->
|
||||
<div v-if="$slots.default" class="mt-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="$slots.actions || actionText" class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<slot name="actions">
|
||||
<Button
|
||||
v-if="actionText"
|
||||
@click="handleAction"
|
||||
:variant="actionVariant"
|
||||
:size="actionSize"
|
||||
>
|
||||
<component v-if="actionIcon" :is="actionIcon" class="mr-2 h-4 w-4" />
|
||||
{{ actionText }}
|
||||
</Button>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- 次要操作 -->
|
||||
<div v-if="$slots.secondary" class="mt-3">
|
||||
<slot name="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import {
|
||||
FileQuestion,
|
||||
Search,
|
||||
Inbox,
|
||||
AlertCircle,
|
||||
PackageOpen,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Filter
|
||||
} from 'lucide-vue-next'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
type EmptyStateType = 'default' | 'search' | 'filter' | 'error' | 'empty' | 'notFound'
|
||||
type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'link' | 'destructive'
|
||||
type ButtonSize = 'sm' | 'default' | 'lg' | 'icon'
|
||||
|
||||
interface Props {
|
||||
/** 空状态类型 */
|
||||
type?: EmptyStateType
|
||||
/** 自定义图标组件 */
|
||||
icon?: Component
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 描述文本 */
|
||||
description?: string
|
||||
/** 操作按钮文本 */
|
||||
actionText?: string
|
||||
/** 操作按钮图标 */
|
||||
actionIcon?: Component
|
||||
/** 操作按钮变体 */
|
||||
actionVariant?: ButtonVariant
|
||||
/** 操作按钮大小 */
|
||||
actionSize?: ButtonSize
|
||||
/** 大小 */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
/** 对齐方式 */
|
||||
align?: 'left' | 'center' | 'right'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'default',
|
||||
actionVariant: 'default',
|
||||
actionSize: 'default',
|
||||
size: 'md',
|
||||
align: 'center'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 根据类型获取默认配置
|
||||
const typeConfig = computed(() => {
|
||||
const configs = {
|
||||
default: {
|
||||
icon: Inbox,
|
||||
title: '暂无数据',
|
||||
description: '当前没有可显示的内容'
|
||||
},
|
||||
search: {
|
||||
icon: Search,
|
||||
title: '未找到结果',
|
||||
description: '尝试使用不同的关键词搜索'
|
||||
},
|
||||
filter: {
|
||||
icon: Filter,
|
||||
title: '无匹配结果',
|
||||
description: '没有符合当前筛选条件的数据'
|
||||
},
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
title: '加载失败',
|
||||
description: '数据加载过程中出现错误'
|
||||
},
|
||||
empty: {
|
||||
icon: PackageOpen,
|
||||
title: '这里空空如也',
|
||||
description: '还没有任何内容'
|
||||
},
|
||||
notFound: {
|
||||
icon: FileQuestion,
|
||||
title: '未找到',
|
||||
description: '请求的资源不存在'
|
||||
}
|
||||
}
|
||||
|
||||
return configs[props.type]
|
||||
})
|
||||
|
||||
// 默认图标
|
||||
const defaultIcon = computed(() => typeConfig.value.icon)
|
||||
|
||||
// 容器样式
|
||||
const containerClasses = computed(() => {
|
||||
const classes = ['empty-state']
|
||||
|
||||
// 大小
|
||||
if (props.size === 'sm') {
|
||||
classes.push('empty-state-sm', 'py-6')
|
||||
} else if (props.size === 'lg') {
|
||||
classes.push('empty-state-lg', 'py-16')
|
||||
} else {
|
||||
classes.push('empty-state-md', 'py-12')
|
||||
}
|
||||
|
||||
// 对齐
|
||||
if (props.align === 'left') {
|
||||
classes.push('text-left')
|
||||
} else if (props.align === 'right') {
|
||||
classes.push('text-right')
|
||||
} else {
|
||||
classes.push('text-center')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
// 图标容器样式
|
||||
const iconContainerClasses = computed(() => {
|
||||
const classes = [
|
||||
'empty-state-icon-container',
|
||||
'rounded-full',
|
||||
'inline-flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'mb-4'
|
||||
]
|
||||
|
||||
// 大小和颜色
|
||||
if (props.type === 'error') {
|
||||
classes.push('bg-red-100', 'dark:bg-red-900/30')
|
||||
} else if (props.type === 'search' || props.type === 'filter') {
|
||||
classes.push('bg-blue-100', 'dark:bg-blue-900/30')
|
||||
} else {
|
||||
classes.push('bg-muted')
|
||||
}
|
||||
|
||||
// 尺寸
|
||||
if (props.size === 'sm') {
|
||||
classes.push('w-12', 'h-12')
|
||||
} else if (props.size === 'lg') {
|
||||
classes.push('w-20', 'h-20')
|
||||
} else {
|
||||
classes.push('w-16', 'h-16')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
// 图标样式
|
||||
const iconClasses = computed(() => {
|
||||
const classes = []
|
||||
|
||||
// 颜色
|
||||
if (props.type === 'error') {
|
||||
classes.push('text-red-600', 'dark:text-red-400')
|
||||
} else if (props.type === 'search' || props.type === 'filter') {
|
||||
classes.push('text-blue-600', 'dark:text-blue-400')
|
||||
} else {
|
||||
classes.push('text-muted-foreground')
|
||||
}
|
||||
|
||||
// 尺寸
|
||||
if (props.size === 'sm') {
|
||||
classes.push('w-6', 'h-6')
|
||||
} else if (props.size === 'lg') {
|
||||
classes.push('w-10', 'h-10')
|
||||
} else {
|
||||
classes.push('w-8', 'h-8')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
// 标题样式
|
||||
const titleClasses = computed(() => {
|
||||
const classes = ['font-semibold', 'text-foreground', 'mb-2']
|
||||
|
||||
if (props.size === 'sm') {
|
||||
classes.push('text-base')
|
||||
} else if (props.size === 'lg') {
|
||||
classes.push('text-2xl')
|
||||
} else {
|
||||
classes.push('text-lg')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
// 描述样式
|
||||
const descriptionClasses = computed(() => {
|
||||
const classes = ['text-muted-foreground', 'max-w-md']
|
||||
|
||||
if (props.align === 'center') {
|
||||
classes.push('mx-auto')
|
||||
}
|
||||
|
||||
if (props.size === 'sm') {
|
||||
classes.push('text-xs')
|
||||
} else if (props.size === 'lg') {
|
||||
classes.push('text-base')
|
||||
} else {
|
||||
classes.push('text-sm')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
// 处理操作
|
||||
function handleAction() {
|
||||
emit('action')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center;
|
||||
}
|
||||
|
||||
.empty-state-icon-container {
|
||||
@apply transition-transform duration-200;
|
||||
}
|
||||
|
||||
.empty-state:hover .empty-state-icon-container {
|
||||
@apply scale-105;
|
||||
}
|
||||
</style>
|
||||
69
frontend/src/components/common/LoadingState.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div :class="containerClasses">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Skeleton v-if="variant === 'skeleton'" :class="skeletonClasses" />
|
||||
|
||||
<div v-else-if="variant === 'spinner'" class="relative">
|
||||
<div class="h-12 w-12 animate-spin rounded-full border-4 border-muted border-t-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="variant === 'pulse'" class="flex gap-2">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="h-3 w-3 animate-pulse rounded-full bg-primary"
|
||||
:style="{ animationDelay: `${i * 150}ms` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div v-if="message" class="text-sm text-muted-foreground">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Skeleton from '@/components/ui/skeleton.vue'
|
||||
|
||||
interface Props {
|
||||
variant?: 'skeleton' | 'spinner' | 'pulse'
|
||||
message?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
fullHeight?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'spinner',
|
||||
size: 'md',
|
||||
fullHeight: false,
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const classes = ['flex items-center justify-center']
|
||||
|
||||
if (props.fullHeight) {
|
||||
classes.push('min-h-[400px]')
|
||||
} else {
|
||||
const sizeMap = {
|
||||
sm: 'py-8',
|
||||
md: 'py-12',
|
||||
lg: 'py-16',
|
||||
}
|
||||
classes.push(sizeMap[props.size])
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
const skeletonClasses = computed(() => {
|
||||
const sizeMap = {
|
||||
sm: 'h-24 w-full',
|
||||
md: 'h-48 w-full',
|
||||
lg: 'h-64 w-full',
|
||||
}
|
||||
|
||||
return sizeMap[props.size]
|
||||
})
|
||||
</script>
|
||||
9
frontend/src/components/common/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Common Components
|
||||
* 常用的自定义业务组件
|
||||
*/
|
||||
|
||||
// 状态和反馈组件
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as AlertDialog } from './AlertDialog.vue'
|
||||
export { default as LoadingState } from './LoadingState.vue'
|
||||
5
frontend/src/components/icons/AetherLogo.vue
Normal file
55
frontend/src/components/layout/AppShell.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="app-shell" :class="{ 'pt-24': showNotice }">
|
||||
<div
|
||||
v-if="showNotice"
|
||||
class="fixed top-6 left-0 right-0 z-50 flex justify-center px-4"
|
||||
>
|
||||
<slot name="notice" />
|
||||
</div>
|
||||
|
||||
<div class="app-shell__backdrop">
|
||||
<div class="app-shell__gradient app-shell__gradient--primary"></div>
|
||||
<div class="app-shell__gradient app-shell__gradient--accent"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-shell__body">
|
||||
<aside
|
||||
v-if="$slots.sidebar"
|
||||
class="app-shell__sidebar"
|
||||
:class="sidebarClass"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</aside>
|
||||
|
||||
<div class="app-shell__content" :class="contentClass">
|
||||
<slot name="header" />
|
||||
<main :class="mainClass">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
showNotice?: boolean
|
||||
contentClass?: string
|
||||
mainClass?: string
|
||||
sidebarClass?: string
|
||||
}>(), {
|
||||
showNotice: false,
|
||||
contentClass: '',
|
||||
mainClass: '',
|
||||
sidebarClass: '',
|
||||
})
|
||||
|
||||
const showNotice = computed(() => props.showNotice)
|
||||
|
||||
// contentClass and mainClass are now just the props, base classes are in template
|
||||
const contentClass = computed(() => props.contentClass)
|
||||
const mainClass = computed(() => ['app-shell__main', props.mainClass].filter(Boolean).join(' '))
|
||||
const sidebarClass = computed(() => props.sidebarClass)
|
||||
</script>
|
||||
103
frontend/src/components/layout/CardSection.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<Card :class="cardClasses">
|
||||
<div v-if="title || description || $slots.header" :class="headerClasses">
|
||||
<slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 v-if="title" class="text-lg font-medium leading-6 text-foreground">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div :class="contentClasses">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" :class="footerClasses">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: 'default' | 'elevated' | 'glass'
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'default',
|
||||
padding: 'md',
|
||||
})
|
||||
|
||||
const cardClasses = computed(() => {
|
||||
const classes = []
|
||||
|
||||
if (props.variant === 'elevated') {
|
||||
classes.push('shadow-md')
|
||||
} else if (props.variant === 'glass') {
|
||||
classes.push('surface-glass')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
const headerClasses = computed(() => {
|
||||
const paddingMap = {
|
||||
none: '',
|
||||
sm: 'px-3 py-3',
|
||||
md: 'px-4 py-5 sm:p-6',
|
||||
lg: 'px-6 py-6 sm:p-8',
|
||||
}
|
||||
|
||||
const classes = [paddingMap[props.padding]]
|
||||
|
||||
if (props.padding !== 'none') {
|
||||
classes.push('border-b border-border')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
const contentClasses = computed(() => {
|
||||
const paddingMap = {
|
||||
none: '',
|
||||
sm: 'px-3 py-3',
|
||||
md: 'px-4 py-5 sm:p-6',
|
||||
lg: 'px-6 py-6 sm:p-8',
|
||||
}
|
||||
|
||||
return paddingMap[props.padding]
|
||||
})
|
||||
|
||||
const footerClasses = computed(() => {
|
||||
const paddingMap = {
|
||||
none: '',
|
||||
sm: 'px-3 py-3',
|
||||
md: 'px-4 py-5 sm:p-6',
|
||||
lg: 'px-6 py-6 sm:p-8',
|
||||
}
|
||||
|
||||
const classes = [paddingMap[props.padding]]
|
||||
|
||||
if (props.padding !== 'none') {
|
||||
classes.push('border-t border-border')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
</script>
|
||||
123
frontend/src/components/layout/MobileNav.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="lg:hidden">
|
||||
<div class="sticky top-0 z-40 space-y-4 pb-4">
|
||||
<!-- Logo头部 - 移动端优化 -->
|
||||
<div class="flex items-center gap-3 rounded-2xl bg-card/90 px-4 py-3 shadow-lg shadow-primary/20 ring-1 ring-border backdrop-blur">
|
||||
<div class="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-2xl bg-background shadow-md shadow-primary/20 flex-shrink-0">
|
||||
<img src="/aether_adaptive.svg" alt="Logo" class="h-8 w-8 sm:h-10 sm:w-10" />
|
||||
</div>
|
||||
<!-- 文字部分 - 小屏隐藏 -->
|
||||
<div class="hidden sm:block">
|
||||
<p class="text-sm font-semibold text-foreground">
|
||||
Aether
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
AI 控制中心
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-2xl bg-card/80 px-4 py-3 shadow-lg shadow-primary/20 ring-1 ring-border backdrop-blur transition hover:ring-primary/30"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary flex-shrink-0">
|
||||
<Menu class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col text-left min-w-0">
|
||||
<span class="text-sm font-semibold text-foreground truncate">
|
||||
快速导航
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground truncate">
|
||||
{{ activeItem ? `当前:${activeItem.name}` : '选择功能页面' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform duration-200 flex-shrink-0"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2 scale-95"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 -translate-y-1 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="space-y-3 rounded-3xl bg-card/95 p-4 shadow-2xl ring-1 ring-border backdrop-blur-xl"
|
||||
>
|
||||
<SidebarNav
|
||||
:items="props.items"
|
||||
:is-active="isLinkActive"
|
||||
:active-path="props.activePath"
|
||||
list-class="space-y-2"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronDown, Menu } from 'lucide-vue-next'
|
||||
import SidebarNav from '@/components/layout/SidebarNav.vue'
|
||||
|
||||
export interface NavigationItem {
|
||||
name: string
|
||||
href: string
|
||||
icon: Component
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface NavigationGroup {
|
||||
title?: string
|
||||
items: NavigationItem[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: NavigationGroup[]
|
||||
activePath?: string
|
||||
isActive?: (href: string) => boolean
|
||||
isDark?: boolean
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const activeItem = computed(() => {
|
||||
for (const group of props.items) {
|
||||
const found = group.items.find(item => isLinkActive(item.href))
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function isLinkActive(href: string) {
|
||||
if (props.isActive) {
|
||||
return props.isActive(href)
|
||||
}
|
||||
if (props.activePath) {
|
||||
return props.activePath === href || props.activePath.startsWith(`${href}/`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function handleNavigate() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
watch(() => props.activePath, () => {
|
||||
isOpen.value = false
|
||||
})
|
||||
</script>
|
||||
47
frontend/src/components/layout/PageContainer.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div :class="containerClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxWidth: '2xl',
|
||||
padding: 'md',
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const classes = ['w-full mx-auto']
|
||||
|
||||
// Max width
|
||||
const maxWidthMap = {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
'2xl': 'max-w-screen-2xl',
|
||||
full: 'max-w-full',
|
||||
}
|
||||
classes.push(maxWidthMap[props.maxWidth])
|
||||
|
||||
// Padding
|
||||
const paddingMap = {
|
||||
none: '',
|
||||
sm: 'px-4 py-4',
|
||||
md: 'px-4 py-6 sm:px-6 lg:px-8',
|
||||
lg: 'px-6 py-8 sm:px-8 lg:px-12',
|
||||
}
|
||||
if (props.padding !== 'none') {
|
||||
classes.push(paddingMap[props.padding])
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
</script>
|
||||
38
frontend/src/components/layout/PageHeader.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="icon">
|
||||
<div v-if="icon" class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<component :is="icon" class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-foreground sm:text-3xl">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
54
frontend/src/components/layout/Section.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<section :class="sectionClasses">
|
||||
<div v-if="title || description || $slots.header" class="mb-6">
|
||||
<slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 v-if="title" class="text-lg font-medium text-foreground">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
description?: string
|
||||
spacing?: 'none' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
spacing: 'md',
|
||||
})
|
||||
|
||||
const sectionClasses = computed(() => {
|
||||
const classes = []
|
||||
|
||||
const spacingMap = {
|
||||
none: '',
|
||||
sm: 'mb-4',
|
||||
md: 'mb-6',
|
||||
lg: 'mb-8',
|
||||
}
|
||||
|
||||
if (props.spacing !== 'none') {
|
||||
classes.push(spacingMap[props.spacing])
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
</script>
|
||||