Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

62
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,30 @@
Aether 非商业开源许可证
版权所有 (c) 2025 Aether 贡献者
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
1. 仅限非商业用途
本软件不得用于商业目的。商业目的包括但不限于:
- 出售本软件或任何衍生作品
- 使用本软件提供付费服务
- 将本软件用于商业产品或服务
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
2. 署名要求
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
3. 分发要求
本软件或衍生作品的任何分发必须使用相同的许可条款。
4. 禁止再许可
您不得以不同的条款将本软件再许可给他人。
5. 商业许可
如需商业使用,请联系版权持有人以获取单独的商业许可。
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
索赔、损害或其他责任承担责任,无论是在合同诉讼、侵权诉讼或其他诉讼中,
还是因本软件或本软件的使用或其他交易而产生的责任。

140
README.md Normal file
View 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 工具。
### 页面预览
| 首页 | 仪表盘 |
|:---:|:---:|
| ![首页](docs/screenshots/home.png) | ![仪表盘](docs/screenshots/dashboard.png) |
| 健康监控 | 用户管理 |
|:---:|:---:|
| ![健康监控](docs/screenshots/health.png) | ![用户管理](docs/screenshots/users.png) |
| 提供商管理 | 使用记录 |
|:---:|:---:|
| ![提供商管理](docs/screenshots/providers.png) | ![使用记录](docs/screenshots/usage.png) |
| 模型详情 | 关联提供商 |
|:---:|:---:|
| ![模型详情](docs/screenshots/model-detail.png) | ![关联提供商](docs/screenshots/model-providers.png) |
| 链路追踪 | 系统设置 |
|:---:|:---:|
| ![链路追踪](docs/screenshots/tracing.png) | ![系统设置](docs/screenshots/settings.png) |
## 部署
### 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
View 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
View 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
View 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"}

View 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")

View 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
View 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
View 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
View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

BIN
docs/screenshots/health.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

BIN
docs/screenshots/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

BIN
docs/screenshots/usage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

BIN
docs/screenshots/users.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

8
frontend/.eslintignore Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

16
frontend/components.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

58
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

View 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
View 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
View 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
}
}

View 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
View 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
View 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
View 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
View 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()

View 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
}
}

View 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
}

View 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}`)
}

View 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
}

View 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
}

View 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
}

View 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'

View 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
}

View 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
}

View 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
}

View 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_nameProvider 侧的模型名)
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
}

View 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
View 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
}
}

View 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;
}

View 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
}

View 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
}
}

View 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
View 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
View 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
}
}

View 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>

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
// 使用占位符保护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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
// 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>

View 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>

View 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>

File diff suppressed because one or more lines are too long

View 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>

File diff suppressed because one or more lines are too long

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

File diff suppressed because one or more lines are too long

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show More