haha
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Frontend build
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
18
111
Normal file
18
111
Normal file
@@ -0,0 +1,18 @@
|
||||
cd backend
|
||||
python app.py
|
||||
|
||||
|
||||
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
389
README.md
Normal file
389
README.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# Claude Code 蓝星 - 完整网站项目
|
||||
|
||||
这是一个基于 Vue 3 + Flask 的完整网站项目,包含用户注册登录、令牌管理、数据看板、使用日志、充值等功能。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/ # Flask 后端
|
||||
│ ├── app.py # 应用入口
|
||||
│ ├── config.py # 配置文件
|
||||
│ ├── requirements.txt # Python 依赖
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── user.py # 用户模型
|
||||
│ │ ├── token.py # 令牌模型
|
||||
│ │ ├── usage_log.py # 使用日志模型
|
||||
│ │ └── ...
|
||||
│ └── routes/ # API 路由
|
||||
│ ├── auth.py # 认证相关
|
||||
│ ├── tokens.py # 令牌管理
|
||||
│ ├── dashboard.py # 数据看板
|
||||
│ └── ...
|
||||
├── frontend/ # Vue 3 前端
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 接口
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── stores/ # Pinia 状态管理
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ └── ...
|
||||
│ ├── package.json
|
||||
│ └── vite.config.js
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 前端功能
|
||||
- ✅ 首页展示(服务介绍、分组选择、功能特色)
|
||||
- ✅ 用户注册/登录
|
||||
- ✅ 模型广场(模型浏览、筛选、搜索)
|
||||
- ✅ 控制台数据看板
|
||||
- ✅ 令牌管理(增删改查、批量操作)
|
||||
- ✅ 使用日志查询
|
||||
- ✅ 账户充值(支付宝/微信、兑换码)
|
||||
- ✅ 邀请奖励系统
|
||||
- ✅ 个人设置
|
||||
|
||||
### 后端功能
|
||||
- ✅ JWT 认证系统
|
||||
- ✅ 用户注册/登录 API
|
||||
- ✅ 模型管理 API(列表、筛选、搜索)
|
||||
- ✅ 令牌管理 API
|
||||
- ✅ 数据统计 API
|
||||
- ✅ 使用日志 API
|
||||
- ✅ 充值订单 API
|
||||
- ✅ 邀请奖励 API
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
- Vue 3 (Composition API)
|
||||
- Vue Router 4
|
||||
- Pinia (状态管理)
|
||||
- Element Plus (UI 组件库)
|
||||
- Axios (HTTP 客户端)
|
||||
- Vite (构建工具)
|
||||
|
||||
### 后端
|
||||
- Flask
|
||||
- Flask-SQLAlchemy (ORM)
|
||||
- Flask-JWT-Extended (JWT 认证)
|
||||
- Flask-CORS (跨域支持)
|
||||
- bcrypt (密码加密)
|
||||
- SQLite/MySQL (数据库)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 后端设置
|
||||
|
||||
1. 进入后端目录:
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. 创建虚拟环境(推荐):
|
||||
```bash
|
||||
python -m venv venv
|
||||
# Windows
|
||||
venv\Scripts\activate
|
||||
# Linux/Mac
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
3. 安装依赖:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. 配置环境变量(可选):
|
||||
创建 `.env` 文件:
|
||||
```
|
||||
SECRET_KEY=your-secret-key
|
||||
JWT_SECRET_KEY=your-jwt-secret-key
|
||||
DATABASE_URL=sqlite:///claude_code.db
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
```
|
||||
|
||||
5. 运行后端服务:
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
后端服务将在 `http://localhost:5000` 启动。
|
||||
|
||||
### 前端设置
|
||||
|
||||
1. 进入前端目录:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. 启动开发服务器:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端服务将在 `http://localhost:5173` 启动,并且已经配置为允许所有IP访问。
|
||||
|
||||
**从其他设备访问:**
|
||||
- 确保前端和后端服务器在同一网络下
|
||||
- 使用服务器IP地址访问:`http://<服务器IP>:5173`
|
||||
- 例如:`http://192.168.1.100:5173`
|
||||
- 后端API已配置为允许跨域访问
|
||||
|
||||
## API 文档
|
||||
|
||||
### 认证相关
|
||||
|
||||
#### 用户注册
|
||||
```
|
||||
POST /api/auth/register
|
||||
Body: {
|
||||
"username": "string",
|
||||
"password": "string",
|
||||
"confirm_password": "string",
|
||||
"invite_code": "string" (可选)
|
||||
}
|
||||
```
|
||||
|
||||
#### 用户登录
|
||||
```
|
||||
POST /api/auth/login
|
||||
Body: {
|
||||
"username": "string",
|
||||
"password": "string"
|
||||
}
|
||||
```
|
||||
|
||||
#### 获取当前用户
|
||||
```
|
||||
GET /api/auth/me
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
```
|
||||
|
||||
### 令牌管理
|
||||
|
||||
#### 获取令牌列表
|
||||
```
|
||||
GET /api/tokens?keyword=xxx&key=xxx
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
```
|
||||
|
||||
#### 创建令牌
|
||||
```
|
||||
POST /api/tokens
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
Body: {
|
||||
"name": "string",
|
||||
"group": "string",
|
||||
"remaining_quota": number (可选),
|
||||
"total_quota": number (可选),
|
||||
"ip_restriction": "string" (可选)
|
||||
}
|
||||
```
|
||||
|
||||
#### 更新令牌
|
||||
```
|
||||
PUT /api/tokens/{id}
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
Body: {
|
||||
"name": "string",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
#### 删除令牌
|
||||
```
|
||||
DELETE /api/tokens/{id}
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
```
|
||||
|
||||
### 数据看板
|
||||
|
||||
#### 获取统计数据
|
||||
```
|
||||
GET /api/dashboard/stats
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
```
|
||||
|
||||
### 使用日志
|
||||
|
||||
#### 获取使用日志
|
||||
```
|
||||
GET /api/logs/usage?page=1&per_page=20&start_date=xxx&end_date=xxx&token_name=xxx&model_name=xxx&group=xxx
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
```
|
||||
|
||||
### 模型广场
|
||||
|
||||
#### 获取模型列表
|
||||
```
|
||||
GET /api/models?page=1&per_page=20&provider=xxx&tags=xxx&token_group=xxx&billing_type=xxx&endpoint_type=xxx&search=xxx&show_recharge_price=true&show_multiplier=true
|
||||
```
|
||||
|
||||
#### 获取单个模型详情
|
||||
```
|
||||
GET /api/models/{id}
|
||||
```
|
||||
|
||||
#### 获取筛选选项
|
||||
```
|
||||
GET /api/models/filters
|
||||
```
|
||||
|
||||
### 充值相关
|
||||
|
||||
#### 获取充值信息
|
||||
```
|
||||
GET /api/recharge/info
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
```
|
||||
|
||||
#### 创建充值订单
|
||||
```
|
||||
POST /api/recharge/create
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
Body: {
|
||||
"amount": number,
|
||||
"payment_method": "alipay" | "wechat"
|
||||
}
|
||||
```
|
||||
|
||||
#### 兑换码充值
|
||||
```
|
||||
POST /api/recharge/exchange
|
||||
Headers: {
|
||||
"Authorization": "Bearer {token}"
|
||||
}
|
||||
Body: {
|
||||
"code": "string"
|
||||
}
|
||||
```
|
||||
|
||||
## 数据库模型
|
||||
|
||||
### User (用户)
|
||||
- id: 主键
|
||||
- username: 用户名(唯一)
|
||||
- email: 邮箱(可选)
|
||||
- password_hash: 密码哈希
|
||||
- balance: 账户余额
|
||||
- total_consumption: 历史消耗
|
||||
- request_count: 请求次数
|
||||
- user_group: 用户分组
|
||||
- invite_code: 邀请码
|
||||
- invited_by: 邀请人ID
|
||||
|
||||
### Token (令牌)
|
||||
- id: 主键
|
||||
- user_id: 用户ID
|
||||
- name: 令牌名称
|
||||
- key: API密钥
|
||||
- status: 状态(enabled/disabled)
|
||||
- remaining_quota: 剩余额度
|
||||
- total_quota: 总额度
|
||||
- group: 分组
|
||||
- available_models: 可用模型
|
||||
- ip_restriction: IP限制
|
||||
- expires_at: 过期时间
|
||||
|
||||
### UsageLog (使用日志)
|
||||
- id: 主键
|
||||
- user_id: 用户ID
|
||||
- token_id: 令牌ID
|
||||
- log_type: 日志类型
|
||||
- model: 模型名称
|
||||
- input_tokens: 输入Token数
|
||||
- output_tokens: 输出Token数
|
||||
- cost: 花费
|
||||
- created_at: 创建时间
|
||||
|
||||
### Model (模型)
|
||||
- id: 主键
|
||||
- name: 模型名称(唯一)
|
||||
- provider: 供应商(Anthropic、OpenAI等)
|
||||
- description: 模型描述
|
||||
- input_price: 输入价格(每100万tokens)
|
||||
- output_price: 输出价格(每100万tokens)
|
||||
- billing_type: 计费类型(pay_as_you_go按量计费、per_request按次计费)
|
||||
- endpoint_type: 端点类型(anthropic、openai)
|
||||
- tags: 标签(JSON数组)
|
||||
- available_groups: 可用分组(JSON数组)
|
||||
- multiplier: 倍率
|
||||
- is_active: 是否激活
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 初始化模型数据
|
||||
|
||||
运行以下命令初始化示例模型数据:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python init_models.py
|
||||
```
|
||||
|
||||
这将创建6个示例模型供测试使用。
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
项目使用 SQLite 作为默认数据库,首次运行会自动创建表结构。
|
||||
|
||||
如需使用 MySQL,修改 `config.py` 中的 `SQLALCHEMY_DATABASE_URI`。
|
||||
|
||||
### 生产环境部署
|
||||
|
||||
1. 修改后端配置:
|
||||
- 设置强密码的 `SECRET_KEY` 和 `JWT_SECRET_KEY`
|
||||
- 配置生产数据库
|
||||
- 设置正确的 `CORS_ORIGINS`
|
||||
|
||||
2. 构建前端:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. 部署:
|
||||
- 后端:使用 Gunicorn 或 uWSGI
|
||||
- 前端:将 `dist` 目录部署到 Nginx 或其他静态文件服务器
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 默认使用 SQLite 数据库,生产环境建议使用 MySQL 或 PostgreSQL
|
||||
2. JWT token 默认 24 小时过期,可在 `config.py` 中调整
|
||||
3. 充值功能需要集成真实的支付接口(支付宝、微信等)
|
||||
4. 邀请奖励的返佣比例需要在业务逻辑中实现
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请联系管理员微信: cursor2028
|
||||
94
backend/app.py
Normal file
94
backend/app.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from flask import Flask, jsonify, request
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import JWTManager, get_jwt_identity, verify_jwt_in_request
|
||||
from flask_jwt_extended.exceptions import JWTDecodeError, NoAuthorizationError, InvalidHeaderError
|
||||
from config import Config
|
||||
from models import db
|
||||
from routes.auth import auth_bp
|
||||
from routes.tokens import tokens_bp
|
||||
from routes.dashboard import dashboard_bp
|
||||
from routes.logs import logs_bp
|
||||
from routes.recharge import recharge_bp
|
||||
from routes.user import user_bp
|
||||
from routes.models import models_bp
|
||||
import logging
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
# 请求前钩子:记录请求信息(用于调试)
|
||||
@app.before_request
|
||||
def log_request():
|
||||
if request.path.startswith('/api/') and request.path != '/api/auth/login' and request.path != '/api/auth/register':
|
||||
auth_header = request.headers.get('Authorization', 'None')
|
||||
logger.info(f"[{request.method}] {request.path}")
|
||||
logger.info(f"Authorization Header: {auth_header[:80] if auth_header != 'None' else 'Missing'}")
|
||||
|
||||
# 初始化扩展
|
||||
db.init_app(app)
|
||||
# CORS配置:允许所有来源,并支持credentials
|
||||
CORS(app,
|
||||
resources={r"/api/*": {
|
||||
"origins": "*",
|
||||
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
"allow_headers": ["Content-Type", "Authorization"],
|
||||
"supports_credentials": True
|
||||
}},
|
||||
supports_credentials=True)
|
||||
jwt = JWTManager(app)
|
||||
|
||||
# JWT错误处理器
|
||||
@jwt.expired_token_loader
|
||||
def expired_token_callback(jwt_header, jwt_payload):
|
||||
logger.warning(f"Token expired: {jwt_payload}")
|
||||
return jsonify({'error': 'Token已过期,请重新登录'}), 401
|
||||
|
||||
@jwt.invalid_token_loader
|
||||
def invalid_token_callback(error):
|
||||
auth_header = request.headers.get('Authorization', 'None')
|
||||
logger.error(f"Invalid token error: {str(error)}")
|
||||
logger.error(f"Authorization header: {auth_header[:100] if auth_header != 'None' else 'Missing'}")
|
||||
return jsonify({
|
||||
'error': '无效的Token',
|
||||
'detail': str(error),
|
||||
'hint': '请检查Authorization头格式是否正确(应为: Bearer <token>)'
|
||||
}), 422
|
||||
|
||||
@jwt.unauthorized_loader
|
||||
def missing_token_callback(error):
|
||||
logger.warning(f"Missing token: {str(error)}")
|
||||
auth_header = request.headers.get('Authorization', 'None')
|
||||
logger.warning(f"Authorization header: {auth_header[:100] if auth_header != 'None' else 'Missing'}")
|
||||
return jsonify({
|
||||
'error': '缺少认证Token,请先登录',
|
||||
'detail': str(error)
|
||||
}), 401
|
||||
|
||||
@jwt.needs_fresh_token_loader
|
||||
def token_not_fresh_callback(jwt_header, jwt_payload):
|
||||
logger.warning(f"Token not fresh: {jwt_payload}")
|
||||
return jsonify({'error': 'Token需要刷新'}), 401
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
||||
app.register_blueprint(tokens_bp, url_prefix='/api/tokens')
|
||||
app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard')
|
||||
app.register_blueprint(logs_bp, url_prefix='/api/logs')
|
||||
app.register_blueprint(recharge_bp, url_prefix='/api/recharge')
|
||||
app.register_blueprint(user_bp, url_prefix='/api/user')
|
||||
app.register_blueprint(models_bp, url_prefix='/api/models')
|
||||
|
||||
# 创建数据库表
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
22
backend/config.py
Normal file
22
backend/config.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///claude_code.db'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or 'jwt-secret-key-change-in-production'
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=24)
|
||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
# Flask-JWT-Extended默认配置,通常不需要显式设置
|
||||
# JWT_HEADER_NAME = 'Authorization' # 默认值
|
||||
# JWT_HEADER_TYPE = 'Bearer' # 默认值
|
||||
JWT_TOKEN_LOCATION = ['headers'] # 从请求头获取token
|
||||
# CORS配置:如果设置了CORS_ORIGINS环境变量则使用,否则允许所有来源(开发环境)
|
||||
cors_origins_env = os.environ.get('CORS_ORIGINS')
|
||||
if cors_origins_env:
|
||||
CORS_ORIGINS = cors_origins_env.split(',')
|
||||
else:
|
||||
# 开发环境:允许所有来源
|
||||
CORS_ORIGINS = ['*']
|
||||
4
backend/env.example.txt
Normal file
4
backend/env.example.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
SECRET_KEY=your-secret-key-here
|
||||
JWT_SECRET_KEY=your-jwt-secret-key-here
|
||||
DATABASE_URL=sqlite:///claude_code.db
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
114
backend/init_models.py
Normal file
114
backend/init_models.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
初始化模型数据脚本
|
||||
运行此脚本可以添加一些示例模型数据
|
||||
"""
|
||||
from app import create_app
|
||||
from models import db
|
||||
from models.model import Model
|
||||
import json
|
||||
|
||||
def init_models():
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
# 检查是否已有数据
|
||||
if Model.query.count() > 0:
|
||||
print("模型数据已存在,跳过初始化")
|
||||
return
|
||||
|
||||
# 示例模型数据
|
||||
models_data = [
|
||||
{
|
||||
'name': 'claude-haiku-4-5-20251001',
|
||||
'provider': 'Anthropic',
|
||||
'description': 'Claude Haiku 4.5 是由 anthropic 提供的人工智能模型。',
|
||||
'input_price': 0.3000,
|
||||
'output_price': 1.5000,
|
||||
'billing_type': 'pay_as_you_go',
|
||||
'endpoint_type': 'anthropic',
|
||||
'tags': ['200k', '工具'],
|
||||
'available_groups': ['Claude Code 官方编程模型', 'Claude Code 企业专线'],
|
||||
'multiplier': 1.00
|
||||
},
|
||||
{
|
||||
'name': 'claude-sonnet-4-5-20250929',
|
||||
'provider': 'Anthropic',
|
||||
'description': 'Claude Sonnet 4.5 是由 anthropic 提供的人工智能模型。',
|
||||
'input_price': 3.0000,
|
||||
'output_price': 15.0000,
|
||||
'billing_type': 'pay_as_you_go',
|
||||
'endpoint_type': 'anthropic',
|
||||
'tags': ['200k', '推理', '工具', '文件'],
|
||||
'available_groups': ['Claude Code 官方编程模型', 'Claude Code 企业专线'],
|
||||
'multiplier': 1.00
|
||||
},
|
||||
{
|
||||
'name': 'claude-opus-4-5-20251101-thinking',
|
||||
'provider': 'Anthropic',
|
||||
'description': 'Claude Opus 4.5 with thinking 是由 anthropic 提供的人工智能模型。',
|
||||
'input_price': 15.0000,
|
||||
'output_price': 75.0000,
|
||||
'billing_type': 'pay_as_you_go',
|
||||
'endpoint_type': 'anthropic',
|
||||
'tags': ['200k', '推理', '工具', '文件'],
|
||||
'available_groups': ['Claude Code 企业专线'],
|
||||
'multiplier': 1.00
|
||||
},
|
||||
{
|
||||
'name': 'claude-3-5-sonnet-20241022',
|
||||
'provider': 'Anthropic',
|
||||
'description': 'Claude 3.5 Sonnet 是由 anthropic 提供的人工智能模型。',
|
||||
'input_price': 3.0000,
|
||||
'output_price': 15.0000,
|
||||
'billing_type': 'pay_as_you_go',
|
||||
'endpoint_type': 'anthropic',
|
||||
'tags': ['200k', '多模态', '工具'],
|
||||
'available_groups': ['Claude Code 官方编程模型', 'Claude Code 企业专线'],
|
||||
'multiplier': 1.00
|
||||
},
|
||||
{
|
||||
'name': 'claude-3-opus-20240229',
|
||||
'provider': 'Anthropic',
|
||||
'description': 'Claude 3 Opus 是由 anthropic 提供的人工智能模型。',
|
||||
'input_price': 15.0000,
|
||||
'output_price': 75.0000,
|
||||
'billing_type': 'pay_as_you_go',
|
||||
'endpoint_type': 'anthropic',
|
||||
'tags': ['200k', '推理', '工具'],
|
||||
'available_groups': ['Claude Code 企业专线'],
|
||||
'multiplier': 1.00
|
||||
},
|
||||
{
|
||||
'name': 'claude-3-5-haiku-20241022',
|
||||
'provider': 'Anthropic',
|
||||
'description': 'Claude 3.5 Haiku 是由 anthropic 提供的人工智能模型。',
|
||||
'input_price': 0.3000,
|
||||
'output_price': 1.5000,
|
||||
'billing_type': 'pay_as_you_go',
|
||||
'endpoint_type': 'anthropic',
|
||||
'tags': ['200k', '工具'],
|
||||
'available_groups': ['Claude Code 官方编程模型'],
|
||||
'multiplier': 1.00
|
||||
}
|
||||
]
|
||||
|
||||
for model_data in models_data:
|
||||
model = Model(
|
||||
name=model_data['name'],
|
||||
provider=model_data['provider'],
|
||||
description=model_data['description'],
|
||||
input_price=model_data['input_price'],
|
||||
output_price=model_data['output_price'],
|
||||
billing_type=model_data['billing_type'],
|
||||
endpoint_type=model_data['endpoint_type'],
|
||||
tags=json.dumps(model_data['tags'], ensure_ascii=False),
|
||||
available_groups=json.dumps(model_data['available_groups'], ensure_ascii=False),
|
||||
multiplier=model_data['multiplier'],
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(model)
|
||||
|
||||
db.session.commit()
|
||||
print(f"成功初始化 {len(models_data)} 个模型")
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_models()
|
||||
14
backend/middleware.py
Normal file
14
backend/middleware.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
中间件:用于调试和日志记录
|
||||
"""
|
||||
from flask import request
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def log_request_info():
|
||||
"""记录请求信息(用于调试)"""
|
||||
if request.path.startswith('/api/'):
|
||||
auth_header = request.headers.get('Authorization', 'None')
|
||||
logger.info(f"Request: {request.method} {request.path}")
|
||||
logger.info(f"Authorization: {auth_header[:50] if auth_header != 'None' else 'None'}")
|
||||
10
backend/models/__init__.py
Normal file
10
backend/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
from .user import User
|
||||
from .token import Token
|
||||
from .usage_log import UsageLog
|
||||
from .recharge_record import RechargeRecord
|
||||
from .invite_reward import InviteReward
|
||||
from .model import Model
|
||||
23
backend/models/invite_reward.py
Normal file
23
backend/models/invite_reward.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from models import db
|
||||
from datetime import datetime
|
||||
|
||||
class InviteReward(db.Model):
|
||||
__tablename__ = 'invite_rewards'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
inviter_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
invitee_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
reward_amount = db.Column(db.Numeric(10, 4), nullable=False) # 奖励金额(美元)
|
||||
status = db.Column(db.String(20), default='pending') # pending, used, transferred
|
||||
related_recharge_id = db.Column(db.Integer, db.ForeignKey('recharge_records.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
transferred_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'reward_amount': float(self.reward_amount),
|
||||
'status': self.status,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
}
|
||||
64
backend/models/model.py
Normal file
64
backend/models/model.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from models import db
|
||||
from datetime import datetime
|
||||
|
||||
class Model(db.Model):
|
||||
__tablename__ = 'models'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
|
||||
provider = db.Column(db.String(100), nullable=False, index=True) # Anthropic, OpenAI等
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
input_price = db.Column(db.Numeric(10, 6), nullable=False) # 输入价格 per 1M tokens
|
||||
output_price = db.Column(db.Numeric(10, 6), nullable=False) # 输出价格 per 1M tokens
|
||||
billing_type = db.Column(db.String(50), default='pay_as_you_go') # pay_as_you_go, per_request
|
||||
endpoint_type = db.Column(db.String(50), default='anthropic') # anthropic, openai
|
||||
tags = db.Column(db.Text, nullable=True) # JSON字符串,存储标签数组
|
||||
available_groups = db.Column(db.Text, nullable=True) # JSON字符串,存储可用分组
|
||||
multiplier = db.Column(db.Numeric(5, 2), default=1.00) # 倍率
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def to_dict(self, show_recharge_price=False, show_multiplier=False):
|
||||
"""转换为字典"""
|
||||
import json
|
||||
|
||||
tags = []
|
||||
if self.tags:
|
||||
try:
|
||||
tags = json.loads(self.tags)
|
||||
except:
|
||||
tags = []
|
||||
|
||||
available_groups = []
|
||||
if self.available_groups:
|
||||
try:
|
||||
available_groups = json.loads(self.available_groups)
|
||||
except:
|
||||
available_groups = []
|
||||
|
||||
result = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'provider': self.provider,
|
||||
'description': self.description,
|
||||
'input_price': float(self.input_price),
|
||||
'output_price': float(self.output_price),
|
||||
'billing_type': self.billing_type,
|
||||
'billing_type_display': '按量计费' if self.billing_type == 'pay_as_you_go' else '按次计费',
|
||||
'endpoint_type': self.endpoint_type,
|
||||
'tags': tags,
|
||||
'available_groups': available_groups,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
if show_recharge_price:
|
||||
# 显示充值价格(假设充值比例0.65)
|
||||
result['recharge_input_price'] = float(self.input_price) * 0.65
|
||||
result['recharge_output_price'] = float(self.output_price) * 0.65
|
||||
|
||||
if show_multiplier:
|
||||
result['multiplier'] = float(self.multiplier)
|
||||
|
||||
return result
|
||||
28
backend/models/recharge_record.py
Normal file
28
backend/models/recharge_record.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from models import db
|
||||
from datetime import datetime
|
||||
|
||||
class RechargeRecord(db.Model):
|
||||
__tablename__ = 'recharge_records'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
amount = db.Column(db.Numeric(10, 2), nullable=False) # 美元金额
|
||||
actual_amount = db.Column(db.Numeric(10, 2), nullable=False) # 实际支付金额(人民币)
|
||||
payment_method = db.Column(db.String(50), nullable=False) # alipay, wechat等
|
||||
payment_status = db.Column(db.String(20), default='pending') # pending, completed, failed
|
||||
exchange_code = db.Column(db.String(50), nullable=True) # 兑换码
|
||||
transaction_id = db.Column(db.String(100), nullable=True) # 第三方交易ID
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'amount': float(self.amount),
|
||||
'actual_amount': float(self.actual_amount),
|
||||
'payment_method': self.payment_method,
|
||||
'payment_status': self.payment_status,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
'completed_at': self.completed_at.strftime('%Y-%m-%d %H:%M:%S') if self.completed_at else None,
|
||||
}
|
||||
66
backend/models/token.py
Normal file
66
backend/models/token.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from models import db
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
import string
|
||||
|
||||
class Token(db.Model):
|
||||
__tablename__ = 'tokens'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
key = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
status = db.Column(db.String(20), default='enabled') # enabled, disabled
|
||||
remaining_quota = db.Column(db.Numeric(15, 2), nullable=True) # None表示无限额度
|
||||
total_quota = db.Column(db.Numeric(15, 2), nullable=True)
|
||||
group = db.Column(db.String(100), default='Claude Code 官方编程模型')
|
||||
available_models = db.Column(db.Text, nullable=True) # JSON字符串或None表示无限制
|
||||
ip_restriction = db.Column(db.String(500), nullable=True) # IP白名单,逗号分隔
|
||||
expires_at = db.Column(db.DateTime, nullable=True) # None表示永不过期
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 关系
|
||||
usage_logs = db.relationship('UsageLog', backref='token', lazy=True)
|
||||
|
||||
@staticmethod
|
||||
def generate_key():
|
||||
"""生成API密钥"""
|
||||
return 'sk-' + ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(40))
|
||||
|
||||
def to_dict(self, show_key=False):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
'name': self.name,
|
||||
'key': self.key if show_key else self.masked_key,
|
||||
'status': self.status,
|
||||
'remaining_quota': float(self.remaining_quota) if self.remaining_quota else None,
|
||||
'total_quota': float(self.total_quota) if self.total_quota else None,
|
||||
'quota_display': self.quota_display,
|
||||
'group': self.group,
|
||||
'available_models': self.available_models,
|
||||
'ip_restriction': self.ip_restriction,
|
||||
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
|
||||
'expires_display': '永不过期' if not self.expires_at else self.expires_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
}
|
||||
|
||||
@property
|
||||
def masked_key(self):
|
||||
"""掩码显示的密钥"""
|
||||
if len(self.key) > 10:
|
||||
return self.key[:5] + '*' * 10 + self.key[-5:]
|
||||
return '*' * len(self.key)
|
||||
|
||||
@property
|
||||
def quota_display(self):
|
||||
"""额度显示文本"""
|
||||
if self.remaining_quota is None and self.total_quota is None:
|
||||
return '无限额度'
|
||||
if self.remaining_quota is None:
|
||||
return f'无限额度 / {float(self.total_quota):.2f}'
|
||||
if self.total_quota is None:
|
||||
return f'{float(self.remaining_quota):.2f} / 无限额度'
|
||||
return f'{float(self.remaining_quota):.2f} / {float(self.total_quota):.2f}'
|
||||
49
backend/models/usage_log.py
Normal file
49
backend/models/usage_log.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from models import db
|
||||
from datetime import datetime
|
||||
|
||||
class UsageLog(db.Model):
|
||||
__tablename__ = 'usage_logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
token_id = db.Column(db.Integer, db.ForeignKey('tokens.id'), nullable=False)
|
||||
token_name = db.Column(db.String(100), nullable=True)
|
||||
group = db.Column(db.String(100), nullable=True)
|
||||
log_type = db.Column(db.String(50), nullable=False) # chat, image, task等
|
||||
model = db.Column(db.String(100), nullable=False)
|
||||
duration = db.Column(db.Float, nullable=True) # 用时(秒)
|
||||
first_token_time = db.Column(db.Float, nullable=True) # 首字时间(秒)
|
||||
input_tokens = db.Column(db.Integer, default=0)
|
||||
output_tokens = db.Column(db.Integer, default=0)
|
||||
cost = db.Column(db.Numeric(10, 4), default=0.0000)
|
||||
ip_address = db.Column(db.String(50), nullable=True)
|
||||
details = db.Column(db.Text, nullable=True) # JSON字符串,存储详细信息
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'token_name': self.token_name,
|
||||
'group': self.group,
|
||||
'log_type': self.log_type,
|
||||
'model': self.model,
|
||||
'duration': self.duration,
|
||||
'first_token_time': self.first_token_time,
|
||||
'time_display': self.time_display,
|
||||
'input_tokens': self.input_tokens,
|
||||
'output_tokens': self.output_tokens,
|
||||
'cost': float(self.cost),
|
||||
'ip_address': self.ip_address,
|
||||
'details': self.details,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
}
|
||||
|
||||
@property
|
||||
def time_display(self):
|
||||
"""时间显示文本"""
|
||||
if self.duration is not None:
|
||||
return f'{self.duration:.2f}s'
|
||||
if self.first_token_time is not None:
|
||||
return f'{self.first_token_time:.2f}s'
|
||||
return '-'
|
||||
46
backend/models/user.py
Normal file
46
backend/models/user.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from models import db
|
||||
from datetime import datetime
|
||||
import bcrypt
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
email = db.Column(db.String(120), unique=True, nullable=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
balance = db.Column(db.Numeric(10, 2), default=0.00)
|
||||
total_consumption = db.Column(db.Numeric(10, 2), default=0.00)
|
||||
request_count = db.Column(db.Integer, default=0)
|
||||
user_group = db.Column(db.String(50), default='default')
|
||||
invite_code = db.Column(db.String(20), unique=True, nullable=True)
|
||||
invited_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 关系
|
||||
tokens = db.relationship('Token', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
usage_logs = db.relationship('UsageLog', backref='user', lazy=True)
|
||||
recharge_records = db.relationship('RechargeRecord', backref='user', lazy=True)
|
||||
|
||||
def set_password(self, password):
|
||||
"""设置密码"""
|
||||
self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
def check_password(self, password):
|
||||
"""验证密码"""
|
||||
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'email': self.email,
|
||||
'balance': float(self.balance),
|
||||
'total_consumption': float(self.total_consumption),
|
||||
'request_count': self.request_count,
|
||||
'user_group': self.user_group,
|
||||
'invite_code': self.invite_code,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
Flask==3.0.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-CORS==4.0.0
|
||||
Flask-JWT-Extended==4.6.0
|
||||
Flask-Migrate==4.0.5
|
||||
Werkzeug==3.0.1
|
||||
python-dotenv==1.0.0
|
||||
bcrypt==4.1.2
|
||||
PyMySQL==1.1.0
|
||||
cryptography==41.0.7
|
||||
1
backend/routes/__init__.py
Normal file
1
backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routes package
|
||||
109
backend/routes/auth.py
Normal file
109
backend/routes/auth.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import create_access_token, create_refresh_token, jwt_required, get_jwt_identity
|
||||
from models import db
|
||||
from models.user import User
|
||||
import secrets
|
||||
import string
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/register', methods=['POST'])
|
||||
def register():
|
||||
"""用户注册"""
|
||||
data = request.get_json()
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
confirm_password = data.get('confirm_password')
|
||||
invite_code = data.get('invite_code')
|
||||
|
||||
# 验证输入
|
||||
if not username or not password:
|
||||
return jsonify({'error': '用户名和密码不能为空'}), 400
|
||||
|
||||
if len(password) < 8 or len(password) > 20:
|
||||
return jsonify({'error': '密码长度必须在8-20位之间'}), 400
|
||||
|
||||
if password != confirm_password:
|
||||
return jsonify({'error': '两次输入的密码不一致'}), 400
|
||||
|
||||
# 检查用户名是否已存在
|
||||
if User.query.filter_by(username=username).first():
|
||||
return jsonify({'error': '用户名已存在'}), 400
|
||||
|
||||
# 处理邀请码
|
||||
inviter = None
|
||||
if invite_code:
|
||||
inviter = User.query.filter_by(invite_code=invite_code).first()
|
||||
|
||||
# 创建用户
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
|
||||
# 生成邀请码
|
||||
user.invite_code = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(6))
|
||||
if inviter:
|
||||
user.invited_by = inviter.id
|
||||
|
||||
try:
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# 生成token
|
||||
access_token = create_access_token(identity=str(user.id))
|
||||
refresh_token = create_refresh_token(identity=str(user.id))
|
||||
|
||||
return jsonify({
|
||||
'message': '注册成功',
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'user': user.to_dict()
|
||||
}), 201
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': '注册失败: ' + str(e)}), 500
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""用户登录"""
|
||||
data = request.get_json()
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': '用户名和密码不能为空'}), 400
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if not user or not user.check_password(password):
|
||||
return jsonify({'error': '用户名或密码错误'}), 401
|
||||
|
||||
# 生成token
|
||||
access_token = create_access_token(identity=str(user.id))
|
||||
refresh_token = create_refresh_token(identity=str(user.id))
|
||||
|
||||
return jsonify({
|
||||
'message': '登录成功',
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'user': user.to_dict()
|
||||
}), 200
|
||||
|
||||
@auth_bp.route('/refresh', methods=['POST'])
|
||||
@jwt_required(refresh=True)
|
||||
def refresh():
|
||||
"""刷新token"""
|
||||
current_user_id = get_jwt_identity()
|
||||
new_token = create_access_token(identity=str(current_user_id))
|
||||
return jsonify({'access_token': new_token}), 200
|
||||
|
||||
@auth_bp.route('/me', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_current_user():
|
||||
"""获取当前用户信息"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
user = User.query.get(current_user_id)
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': '用户不存在'}), 404
|
||||
|
||||
return jsonify({'user': user.to_dict()}), 200
|
||||
128
backend/routes/dashboard.py
Normal file
128
backend/routes/dashboard.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from flask import Blueprint, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from models import db
|
||||
from models.user import User
|
||||
from models.token import Token
|
||||
from models.usage_log import UsageLog
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from sqlalchemy import func, and_
|
||||
|
||||
dashboard_bp = Blueprint('dashboard', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dashboard_bp.route('/stats', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_dashboard_stats():
|
||||
"""获取数据看板统计信息"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
user = User.query.get(current_user_id)
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': '用户不存在'}), 404
|
||||
|
||||
# 获取今日和7天的日期范围
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
seven_days_ago = today_start - timedelta(days=7)
|
||||
|
||||
# Token统计(今日)
|
||||
today_logs = UsageLog.query.filter(
|
||||
and_(
|
||||
UsageLog.user_id == current_user_id,
|
||||
UsageLog.created_at >= today_start
|
||||
)
|
||||
).all()
|
||||
|
||||
today_input = sum(log.input_tokens for log in today_logs)
|
||||
today_output = sum(log.output_tokens for log in today_logs)
|
||||
today_cache_create = 0 # 需要根据实际业务逻辑计算
|
||||
today_cache_read = 0 # 需要根据实际业务逻辑计算
|
||||
today_total = today_input + today_output
|
||||
|
||||
# Token统计(7天)
|
||||
week_logs = UsageLog.query.filter(
|
||||
and_(
|
||||
UsageLog.user_id == current_user_id,
|
||||
UsageLog.created_at >= seven_days_ago
|
||||
)
|
||||
).all()
|
||||
|
||||
week_input = sum(log.input_tokens for log in week_logs)
|
||||
week_output = sum(log.output_tokens for log in week_logs)
|
||||
week_total = week_input + week_output
|
||||
|
||||
# 性能指标
|
||||
if today_logs:
|
||||
total_duration = sum(log.duration or 0 for log in today_logs)
|
||||
total_requests = len(today_logs)
|
||||
avg_rpm = total_requests / (total_duration / 60) if total_duration > 0 else 0
|
||||
avg_tpm = today_total / (total_duration / 60) if total_duration > 0 else 0
|
||||
else:
|
||||
avg_rpm = 0
|
||||
avg_tpm = 0
|
||||
|
||||
# 模型消耗分布(示例数据,实际需要从数据库查询)
|
||||
model_distribution = []
|
||||
model_stats = db.session.query(
|
||||
UsageLog.model,
|
||||
func.sum(UsageLog.cost).label('total_cost')
|
||||
).filter(
|
||||
and_(
|
||||
UsageLog.user_id == current_user_id,
|
||||
UsageLog.created_at >= seven_days_ago
|
||||
)
|
||||
).group_by(UsageLog.model).all()
|
||||
|
||||
for model, cost in model_stats:
|
||||
model_distribution.append({
|
||||
'model': model,
|
||||
'cost': float(cost) if cost else 0
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'account': {
|
||||
'balance': float(user.balance),
|
||||
'total_consumption': float(user.total_consumption),
|
||||
'request_count': user.request_count
|
||||
},
|
||||
'usage': {
|
||||
'request_count': user.request_count,
|
||||
'stat_count': len(today_logs) # 统计次数
|
||||
},
|
||||
'token_stats': {
|
||||
'today': {
|
||||
'input': today_input,
|
||||
'output': today_output,
|
||||
'cache_create': today_cache_create,
|
||||
'cache_read': today_cache_read,
|
||||
'total': today_total
|
||||
},
|
||||
'week': {
|
||||
'input': week_input,
|
||||
'output': week_output,
|
||||
'total': week_total
|
||||
}
|
||||
},
|
||||
'performance': {
|
||||
'avg_rpm': round(avg_rpm, 3),
|
||||
'avg_tpm': round(avg_tpm, 3)
|
||||
},
|
||||
'model_distribution': model_distribution
|
||||
}), 200
|
||||
|
||||
@dashboard_bp.route('/server-info', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_server_info():
|
||||
"""获取服务器信息"""
|
||||
# 验证token(用于调试)
|
||||
try:
|
||||
current_user_id = int(get_jwt_identity())
|
||||
logger.info(f"Server info requested by user: {current_user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"JWT verification failed: {str(e)}")
|
||||
|
||||
return jsonify({
|
||||
'name': '蓝星中国服务器',
|
||||
'url': 'https://cc.honoursoft.cn',
|
||||
'description': 'cn2网络回国优化,支持海外,国内双向访问。'
|
||||
}), 200
|
||||
90
backend/routes/logs.py
Normal file
90
backend/routes/logs.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from models import db
|
||||
from models.usage_log import UsageLog
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import and_, or_, func
|
||||
|
||||
logs_bp = Blueprint('logs', __name__)
|
||||
|
||||
@logs_bp.route('/usage', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_usage_logs():
|
||||
"""获取使用日志"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
|
||||
# 获取查询参数
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
token_name = request.args.get('token_name', '')
|
||||
model_name = request.args.get('model_name', '')
|
||||
group = request.args.get('group', '')
|
||||
|
||||
# 构建查询
|
||||
query = UsageLog.query.filter_by(user_id=current_user_id)
|
||||
|
||||
# 日期范围过滤
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
query = query.filter(UsageLog.created_at >= start_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
query = query.filter(UsageLog.created_at <= end_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 其他过滤条件
|
||||
if token_name:
|
||||
query = query.filter(UsageLog.token_name.like(f'%{token_name}%'))
|
||||
|
||||
if model_name:
|
||||
query = query.filter(UsageLog.model.like(f'%{model_name}%'))
|
||||
|
||||
if group:
|
||||
query = query.filter(UsageLog.group == group)
|
||||
|
||||
# 统计信息
|
||||
stats_query = query
|
||||
total_cost = db.session.query(func.sum(UsageLog.cost)).filter(
|
||||
stats_query.whereclause
|
||||
).scalar() or 0
|
||||
|
||||
total_requests = stats_query.count()
|
||||
|
||||
# 计算RPM和TPM(基于查询结果)
|
||||
logs_for_stats = stats_query.all()
|
||||
if logs_for_stats:
|
||||
total_duration = sum(log.duration or 0 for log in logs_for_stats)
|
||||
total_tokens = sum(log.input_tokens + log.output_tokens for log in logs_for_stats)
|
||||
rpm = total_requests / (total_duration / 60) if total_duration > 0 else 0
|
||||
tpm = total_tokens / (total_duration / 60) if total_duration > 0 else 0
|
||||
else:
|
||||
rpm = 0
|
||||
tpm = 0
|
||||
|
||||
# 分页
|
||||
pagination = query.order_by(UsageLog.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'logs': [log.to_dict() for log in pagination.items],
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages
|
||||
},
|
||||
'stats': {
|
||||
'total_cost': float(total_cost),
|
||||
'rpm': round(rpm, 2),
|
||||
'tpm': round(tpm, 2)
|
||||
}
|
||||
}), 200
|
||||
147
backend/routes/models.py
Normal file
147
backend/routes/models.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from models import db
|
||||
from models.model import Model
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
models_bp = Blueprint('models', __name__)
|
||||
|
||||
@models_bp.route('', methods=['GET'])
|
||||
def get_models():
|
||||
"""获取模型列表(支持筛选、搜索、分页)"""
|
||||
# 获取查询参数
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
provider = request.args.get('provider', '')
|
||||
tags = request.args.getlist('tags') # 支持多个标签
|
||||
token_group = request.args.get('token_group', '')
|
||||
billing_type = request.args.get('billing_type', '')
|
||||
endpoint_type = request.args.get('endpoint_type', '')
|
||||
search_query = request.args.get('search', '')
|
||||
show_recharge_price = request.args.get('show_recharge_price', 'false').lower() == 'true'
|
||||
show_multiplier = request.args.get('show_multiplier', 'false').lower() == 'true'
|
||||
|
||||
# 构建查询
|
||||
query = Model.query.filter_by(is_active=True)
|
||||
|
||||
# 供应商筛选
|
||||
if provider and provider != 'all':
|
||||
query = query.filter(Model.provider == provider)
|
||||
|
||||
# 标签筛选
|
||||
if tags and 'all' not in tags:
|
||||
import json
|
||||
for tag in tags:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Model.tags.like(f'%"{tag}"%'),
|
||||
Model.tags.like(f'%{tag}%')
|
||||
)
|
||||
)
|
||||
|
||||
# 分组筛选
|
||||
if token_group and token_group != 'all':
|
||||
import json
|
||||
query = query.filter(
|
||||
or_(
|
||||
Model.available_groups.like(f'%"{token_group}"%'),
|
||||
Model.available_groups.like(f'%{token_group}%')
|
||||
)
|
||||
)
|
||||
|
||||
# 计费类型筛选
|
||||
if billing_type and billing_type != 'all':
|
||||
query = query.filter(Model.billing_type == billing_type)
|
||||
|
||||
# 端点类型筛选
|
||||
if endpoint_type and endpoint_type != 'all':
|
||||
query = query.filter(Model.endpoint_type == endpoint_type)
|
||||
|
||||
# 搜索
|
||||
if search_query:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Model.name.like(f'%{search_query}%'),
|
||||
Model.description.like(f'%{search_query}%')
|
||||
)
|
||||
)
|
||||
|
||||
# 分页
|
||||
pagination = query.order_by(Model.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
# 获取筛选选项(用于前端显示)
|
||||
all_providers = db.session.query(Model.provider).filter_by(is_active=True).distinct().all()
|
||||
providers = [p[0] for p in all_providers]
|
||||
|
||||
# 获取所有标签
|
||||
all_tags = set()
|
||||
all_models = Model.query.filter_by(is_active=True).all()
|
||||
for model in all_models:
|
||||
if model.tags:
|
||||
import json
|
||||
try:
|
||||
tags_list = json.loads(model.tags)
|
||||
all_tags.update(tags_list)
|
||||
except:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'models': [model.to_dict(show_recharge_price, show_multiplier) for model in pagination.items],
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages
|
||||
},
|
||||
'filters': {
|
||||
'providers': providers,
|
||||
'tags': sorted(list(all_tags)),
|
||||
'billing_types': ['pay_as_you_go', 'per_request'],
|
||||
'endpoint_types': ['anthropic', 'openai']
|
||||
}
|
||||
}), 200
|
||||
|
||||
@models_bp.route('/<int:model_id>', methods=['GET'])
|
||||
def get_model(model_id):
|
||||
"""获取单个模型详情"""
|
||||
model = Model.query.get_or_404(model_id)
|
||||
return jsonify({'model': model.to_dict(show_recharge_price=True, show_multiplier=True)}), 200
|
||||
|
||||
@models_bp.route('/filters', methods=['GET'])
|
||||
def get_filters():
|
||||
"""获取筛选选项"""
|
||||
# 获取所有分组(从Token表)
|
||||
from models.token import Token
|
||||
all_groups = db.session.query(Token.group).distinct().all()
|
||||
groups = [g[0] for g in all_groups if g[0]]
|
||||
|
||||
# 获取所有供应商
|
||||
all_providers = db.session.query(Model.provider).filter_by(is_active=True).distinct().all()
|
||||
providers = [p[0] for p in all_providers]
|
||||
|
||||
# 获取所有标签
|
||||
all_tags = set()
|
||||
all_models = Model.query.filter_by(is_active=True).all()
|
||||
for model in all_models:
|
||||
if model.tags:
|
||||
import json
|
||||
try:
|
||||
tags_list = json.loads(model.tags)
|
||||
all_tags.update(tags_list)
|
||||
except:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'providers': providers,
|
||||
'tags': sorted(list(all_tags)),
|
||||
'groups': groups,
|
||||
'billing_types': [
|
||||
{'value': 'pay_as_you_go', 'label': '按量计费'},
|
||||
{'value': 'per_request', 'label': '按次计费'}
|
||||
],
|
||||
'endpoint_types': [
|
||||
{'value': 'anthropic', 'label': 'anthropic'},
|
||||
{'value': 'openai', 'label': 'openai'}
|
||||
]
|
||||
}), 200
|
||||
178
backend/routes/recharge.py
Normal file
178
backend/routes/recharge.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from models import db
|
||||
from models.user import User
|
||||
from models.recharge_record import RechargeRecord
|
||||
from models.invite_reward import InviteReward
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
|
||||
recharge_bp = Blueprint('recharge', __name__)
|
||||
|
||||
# 充值比例
|
||||
RECHARGE_RATE = 0.65 # 0.65元 = 1美元
|
||||
|
||||
@recharge_bp.route('/info', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_recharge_info():
|
||||
"""获取充值信息"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
user = User.query.get(current_user_id)
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': '用户不存在'}), 404
|
||||
|
||||
# 计算邀请奖励
|
||||
pending_rewards = InviteReward.query.filter_by(
|
||||
inviter_id=current_user_id,
|
||||
status='pending'
|
||||
).all()
|
||||
|
||||
total_pending_reward = sum(float(reward.reward_amount) for reward in pending_rewards)
|
||||
total_reward = db.session.query(func.sum(InviteReward.reward_amount)).filter_by(
|
||||
inviter_id=current_user_id
|
||||
).scalar() or 0
|
||||
|
||||
invite_count = User.query.filter_by(invited_by=current_user_id).count()
|
||||
|
||||
return jsonify({
|
||||
'balance': float(user.balance),
|
||||
'total_consumption': float(user.total_consumption),
|
||||
'request_count': user.request_count,
|
||||
'recharge_rate': RECHARGE_RATE,
|
||||
'invite_reward': {
|
||||
'pending': float(total_pending_reward),
|
||||
'total': float(total_reward),
|
||||
'invite_count': invite_count,
|
||||
'invite_code': user.invite_code,
|
||||
'invite_url': f'https://cc.honoursoft.cn/register?aff={user.invite_code}'
|
||||
}
|
||||
}), 200
|
||||
|
||||
@recharge_bp.route('/create', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_recharge():
|
||||
"""创建充值订单"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
data = request.get_json()
|
||||
|
||||
amount = Decimal(str(data.get('amount', 0))) # 美元
|
||||
payment_method = data.get('payment_method', 'alipay')
|
||||
|
||||
if amount <= 0:
|
||||
return jsonify({'error': '充值金额必须大于0'}), 400
|
||||
|
||||
# 计算实际支付金额(人民币)
|
||||
actual_amount = amount * Decimal(str(RECHARGE_RATE))
|
||||
|
||||
# 创建充值记录
|
||||
recharge = RechargeRecord(
|
||||
user_id=current_user_id,
|
||||
amount=amount,
|
||||
actual_amount=actual_amount,
|
||||
payment_method=payment_method,
|
||||
payment_status='pending'
|
||||
)
|
||||
|
||||
try:
|
||||
db.session.add(recharge)
|
||||
db.session.commit()
|
||||
|
||||
# 这里应该调用第三方支付接口,生成支付链接
|
||||
# 示例:返回订单信息
|
||||
return jsonify({
|
||||
'message': '订单创建成功',
|
||||
'order_id': recharge.id,
|
||||
'amount': float(amount),
|
||||
'actual_amount': float(actual_amount),
|
||||
'payment_method': payment_method,
|
||||
'payment_url': f'/payment/{recharge.id}' # 实际应该返回真实的支付链接
|
||||
}), 201
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': '创建订单失败: ' + str(e)}), 500
|
||||
|
||||
@recharge_bp.route('/exchange', methods=['POST'])
|
||||
@jwt_required()
|
||||
def exchange_code():
|
||||
"""兑换码充值"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
data = request.get_json()
|
||||
code = data.get('code', '').strip()
|
||||
|
||||
if not code:
|
||||
return jsonify({'error': '兑换码不能为空'}), 400
|
||||
|
||||
# 这里应该实现兑换码验证逻辑
|
||||
# 示例:假设兑换码格式为 EXCHANGE-{amount}
|
||||
# 实际应该从数据库查询有效的兑换码
|
||||
|
||||
# 示例实现
|
||||
if code.startswith('EXCHANGE-'):
|
||||
try:
|
||||
amount = Decimal(code.replace('EXCHANGE-', ''))
|
||||
user = User.query.get(current_user_id)
|
||||
user.balance += amount
|
||||
|
||||
# 创建充值记录
|
||||
recharge = RechargeRecord(
|
||||
user_id=current_user_id,
|
||||
amount=amount,
|
||||
actual_amount=Decimal('0'),
|
||||
payment_method='exchange',
|
||||
payment_status='completed',
|
||||
exchange_code=code
|
||||
)
|
||||
|
||||
db.session.add(recharge)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'message': '兑换成功',
|
||||
'amount': float(amount)
|
||||
}), 200
|
||||
except:
|
||||
return jsonify({'error': '无效的兑换码'}), 400
|
||||
else:
|
||||
return jsonify({'error': '无效的兑换码'}), 400
|
||||
|
||||
@recharge_bp.route('/transfer-reward', methods=['POST'])
|
||||
@jwt_required()
|
||||
def transfer_reward():
|
||||
"""将邀请奖励转入余额"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
user = User.query.get(current_user_id)
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': '用户不存在'}), 404
|
||||
|
||||
# 获取待使用的奖励
|
||||
pending_rewards = InviteReward.query.filter_by(
|
||||
inviter_id=current_user_id,
|
||||
status='pending'
|
||||
).all()
|
||||
|
||||
if not pending_rewards:
|
||||
return jsonify({'error': '没有可转移的奖励'}), 400
|
||||
|
||||
total_reward = sum(float(reward.reward_amount) for reward in pending_rewards)
|
||||
|
||||
try:
|
||||
# 更新用户余额
|
||||
user.balance += Decimal(str(total_reward))
|
||||
|
||||
# 更新奖励状态
|
||||
for reward in pending_rewards:
|
||||
reward.status = 'transferred'
|
||||
reward.transferred_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'message': '转移成功',
|
||||
'amount': total_reward
|
||||
}), 200
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': '转移失败: ' + str(e)}), 500
|
||||
160
backend/routes/tokens.py
Normal file
160
backend/routes/tokens.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from models import db
|
||||
from models.token import Token
|
||||
from models.user import User
|
||||
|
||||
tokens_bp = Blueprint('tokens', __name__)
|
||||
|
||||
@tokens_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_tokens():
|
||||
"""获取用户的令牌列表"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
search_keyword = request.args.get('keyword', '')
|
||||
search_key = request.args.get('key', '')
|
||||
|
||||
query = Token.query.filter_by(user_id=current_user_id)
|
||||
|
||||
if search_keyword:
|
||||
query = query.filter(Token.name.like(f'%{search_keyword}%'))
|
||||
|
||||
if search_key:
|
||||
query = query.filter(Token.key.like(f'%{search_key}%'))
|
||||
|
||||
tokens = query.order_by(Token.created_at.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'tokens': [token.to_dict() for token in tokens]
|
||||
}), 200
|
||||
|
||||
@tokens_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_token():
|
||||
"""创建新令牌"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
data = request.get_json()
|
||||
|
||||
name = data.get('name', '新令牌')
|
||||
group = data.get('group', 'Claude Code 官方编程模型')
|
||||
remaining_quota = data.get('remaining_quota')
|
||||
total_quota = data.get('total_quota')
|
||||
available_models = data.get('available_models')
|
||||
ip_restriction = data.get('ip_restriction')
|
||||
expires_at = data.get('expires_at')
|
||||
|
||||
token = Token(
|
||||
user_id=current_user_id,
|
||||
name=name,
|
||||
key=Token.generate_key(),
|
||||
group=group,
|
||||
remaining_quota=remaining_quota,
|
||||
total_quota=total_quota,
|
||||
available_models=available_models,
|
||||
ip_restriction=ip_restriction,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
try:
|
||||
db.session.add(token)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'message': '令牌创建成功',
|
||||
'token': token.to_dict(show_key=True)
|
||||
}), 201
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': '创建失败: ' + str(e)}), 500
|
||||
|
||||
@tokens_bp.route('/<int:token_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_token(token_id):
|
||||
"""获取单个令牌详情"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
token = Token.query.filter_by(id=token_id, user_id=current_user_id).first()
|
||||
|
||||
if not token:
|
||||
return jsonify({'error': '令牌不存在'}), 404
|
||||
|
||||
return jsonify({'token': token.to_dict(show_key=True)}), 200
|
||||
|
||||
@tokens_bp.route('/<int:token_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_token(token_id):
|
||||
"""更新令牌"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
token = Token.query.filter_by(id=token_id, user_id=current_user_id).first()
|
||||
|
||||
if not token:
|
||||
return jsonify({'error': '令牌不存在'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
token.name = data.get('name', token.name)
|
||||
token.group = data.get('group', token.group)
|
||||
token.remaining_quota = data.get('remaining_quota', token.remaining_quota)
|
||||
token.total_quota = data.get('total_quota', token.total_quota)
|
||||
token.available_models = data.get('available_models', token.available_models)
|
||||
token.ip_restriction = data.get('ip_restriction', token.ip_restriction)
|
||||
token.expires_at = data.get('expires_at', token.expires_at)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'message': '更新成功',
|
||||
'token': token.to_dict(show_key=True)
|
||||
}), 200
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': '更新失败: ' + str(e)}), 500
|
||||
|
||||
@tokens_bp.route('/<int:token_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_token(token_id):
|
||||
"""删除令牌"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
token = Token.query.filter_by(id=token_id, user_id=current_user_id).first()
|
||||
|
||||
if not token:
|
||||
return jsonify({'error': '令牌不存在'}), 404
|
||||
|
||||
try:
|
||||
db.session.delete(token)
|
||||
db.session.commit()
|
||||
return jsonify({'message': '删除成功'}), 200
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': '删除失败: ' + str(e)}), 500
|
||||
|
||||
@tokens_bp.route('/batch', methods=['POST'])
|
||||
@jwt_required()
|
||||
def batch_operation():
|
||||
"""批量操作令牌"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
data = request.get_json()
|
||||
token_ids = data.get('token_ids', [])
|
||||
operation = data.get('operation') # copy, delete, enable, disable
|
||||
|
||||
tokens = Token.query.filter(
|
||||
Token.id.in_(token_ids),
|
||||
Token.user_id == current_user_id
|
||||
).all()
|
||||
|
||||
if not tokens:
|
||||
return jsonify({'error': '未找到有效的令牌'}), 404
|
||||
|
||||
try:
|
||||
if operation == 'delete':
|
||||
for token in tokens:
|
||||
db.session.delete(token)
|
||||
elif operation == 'enable':
|
||||
for token in tokens:
|
||||
token.status = 'enabled'
|
||||
elif operation == 'disable':
|
||||
for token in tokens:
|
||||
token.status = 'disabled'
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': '操作成功'}), 200
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': '操作失败: ' + str(e)}), 500
|
||||
79
backend/routes/user.py
Normal file
79
backend/routes/user.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from models import db
|
||||
from models.user import User
|
||||
from datetime import datetime
|
||||
|
||||
user_bp = Blueprint('user', __name__)
|
||||
|
||||
@user_bp.route('/profile', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_profile():
|
||||
"""获取用户资料"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
user = User.query.get(current_user_id)
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': '用户不存在'}), 404
|
||||
|
||||
return jsonify({'user': user.to_dict()}), 200
|
||||
|
||||
@user_bp.route('/profile', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_profile():
|
||||
"""更新用户资料"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
user = User.query.get(current_user_id)
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': '用户不存在'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
# 更新邮箱
|
||||
if 'email' in data:
|
||||
email = data['email']
|
||||
if email and User.query.filter_by(email=email).filter(User.id != current_user_id).first():
|
||||
return jsonify({'error': '邮箱已被使用'}), 400
|
||||
user.email = email
|
||||
|
||||
# 更新用户组
|
||||
if 'user_group' in data:
|
||||
user.user_group = data['user_group']
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'message': '更新成功',
|
||||
'user': user.to_dict()
|
||||
}), 200
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': '更新失败: ' + str(e)}), 500
|
||||
|
||||
@user_bp.route('/settings', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_settings():
|
||||
"""获取用户设置"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
# 这里可以从数据库读取用户设置,示例返回默认设置
|
||||
return jsonify({
|
||||
'notification': {
|
||||
'method': 'email', # email, webhook, bark, gotify
|
||||
'threshold': 500000,
|
||||
'email': ''
|
||||
},
|
||||
'price': {},
|
||||
'privacy': {},
|
||||
'sidebar': {}
|
||||
}), 200
|
||||
|
||||
@user_bp.route('/settings', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_settings():
|
||||
"""更新用户设置"""
|
||||
current_user_id = int(get_jwt_identity())
|
||||
data = request.get_json()
|
||||
|
||||
# 这里应该保存到数据库,示例只返回成功
|
||||
return jsonify({'message': '设置保存成功'}), 200
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude Code 蓝星</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1828
frontend/package-lock.json
generated
Normal file
1828
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "claude-code-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.2",
|
||||
"element-plus": "^2.4.4",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"echarts": "^5.4.3",
|
||||
"vue-echarts": "^6.6.1",
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
51
frontend/src/App.vue
Normal file
51
frontend/src/App.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<keep-alive :include="['Dashboard', 'Tokens', 'Logs', 'Recharge', 'Settings']">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* 路由切换动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 优化移动端滚动性能 */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* 优化移动端渲染性能 */
|
||||
@media (max-width: 768px) {
|
||||
* {
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
16
frontend/src/api/auth.js
Normal file
16
frontend/src/api/auth.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import api from './index'
|
||||
|
||||
export const authApi = {
|
||||
register(data) {
|
||||
return api.post('/auth/register', data)
|
||||
},
|
||||
login(data) {
|
||||
return api.post('/auth/login', data)
|
||||
},
|
||||
getCurrentUser() {
|
||||
return api.get('/auth/me')
|
||||
},
|
||||
refreshToken() {
|
||||
return api.post('/auth/refresh')
|
||||
}
|
||||
}
|
||||
10
frontend/src/api/dashboard.js
Normal file
10
frontend/src/api/dashboard.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import api from './index'
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats() {
|
||||
return api.get('/dashboard/stats')
|
||||
},
|
||||
getServerInfo() {
|
||||
return api.get('/dashboard/server-info')
|
||||
}
|
||||
}
|
||||
65
frontend/src/api/index.js
Normal file
65
frontend/src/api/index.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
// 确保token格式正确:Bearer <token>
|
||||
const token = authStore.token.trim()
|
||||
if (!token.startsWith('Bearer ')) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
} else {
|
||||
config.headers.Authorization = token
|
||||
}
|
||||
// 调试日志
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`)
|
||||
console.log(`[Token] ${token.substring(0, 20)}...`)
|
||||
}
|
||||
} else {
|
||||
console.warn('请求缺少Token:', config.url)
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 401: 未授权,token无效或过期
|
||||
if (error.response?.status === 401) {
|
||||
authStore.logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 422: Token验证失败
|
||||
if (error.response?.status === 422) {
|
||||
const errorMsg = error.response?.data?.error || 'Token验证失败,请重新登录'
|
||||
console.error('JWT验证失败:', errorMsg)
|
||||
// 清除无效的token
|
||||
authStore.logout()
|
||||
// 不自动跳转,让调用方决定如何处理
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
7
frontend/src/api/logs.js
Normal file
7
frontend/src/api/logs.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import api from './index'
|
||||
|
||||
export const logsApi = {
|
||||
getUsageLogs(params) {
|
||||
return api.get('/logs/usage', { params })
|
||||
}
|
||||
}
|
||||
13
frontend/src/api/models.js
Normal file
13
frontend/src/api/models.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import api from './index'
|
||||
|
||||
export const modelsApi = {
|
||||
getModels(params) {
|
||||
return api.get('/models', { params })
|
||||
},
|
||||
getModel(id) {
|
||||
return api.get(`/models/${id}`)
|
||||
},
|
||||
getFilters() {
|
||||
return api.get('/models/filters')
|
||||
}
|
||||
}
|
||||
16
frontend/src/api/recharge.js
Normal file
16
frontend/src/api/recharge.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import api from './index'
|
||||
|
||||
export const rechargeApi = {
|
||||
getRechargeInfo() {
|
||||
return api.get('/recharge/info')
|
||||
},
|
||||
createRecharge(data) {
|
||||
return api.post('/recharge/create', data)
|
||||
},
|
||||
exchangeCode(data) {
|
||||
return api.post('/recharge/exchange', data)
|
||||
},
|
||||
transferReward() {
|
||||
return api.post('/recharge/transfer-reward')
|
||||
}
|
||||
}
|
||||
22
frontend/src/api/tokens.js
Normal file
22
frontend/src/api/tokens.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import api from './index'
|
||||
|
||||
export const tokensApi = {
|
||||
getTokens(params) {
|
||||
return api.get('/tokens', { params })
|
||||
},
|
||||
createToken(data) {
|
||||
return api.post('/tokens', data)
|
||||
},
|
||||
getToken(id) {
|
||||
return api.get(`/tokens/${id}`)
|
||||
},
|
||||
updateToken(id, data) {
|
||||
return api.put(`/tokens/${id}`, data)
|
||||
},
|
||||
deleteToken(id) {
|
||||
return api.delete(`/tokens/${id}`)
|
||||
},
|
||||
batchOperation(data) {
|
||||
return api.post('/tokens/batch', data)
|
||||
}
|
||||
}
|
||||
16
frontend/src/api/user.js
Normal file
16
frontend/src/api/user.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import api from './index'
|
||||
|
||||
export const userApi = {
|
||||
getProfile() {
|
||||
return api.get('/user/profile')
|
||||
},
|
||||
updateProfile(data) {
|
||||
return api.put('/user/profile', data)
|
||||
},
|
||||
getSettings() {
|
||||
return api.get('/user/settings')
|
||||
},
|
||||
updateSettings(data) {
|
||||
return api.put('/user/settings', data)
|
||||
}
|
||||
}
|
||||
231
frontend/src/components/Header.vue
Normal file
231
frontend/src/components/Header.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-icon class="logo-icon"><Box /></el-icon>
|
||||
<span class="logo-text">Claude Code 蓝星</span>
|
||||
<el-button
|
||||
class="mobile-menu-btn"
|
||||
@click="showMobileMenu = !showMobileMenu"
|
||||
text
|
||||
:icon="Menu"
|
||||
/>
|
||||
</div>
|
||||
<div class="header-center" :class="{ 'mobile-hidden': isMobile }">
|
||||
<el-menu mode="horizontal" :default-active="activeMenu" router>
|
||||
<el-menu-item index="/">首页</el-menu-item>
|
||||
<el-menu-item index="/console" v-if="isAuthenticated">控制台</el-menu-item>
|
||||
<el-menu-item index="/models">模型广场</el-menu-item>
|
||||
<el-menu-item index="/install">①CC安装步骤</el-menu-item>
|
||||
<el-menu-item index="/config">②环境配置</el-menu-item>
|
||||
<el-menu-item index="/contact">联系我们</el-menu-item>
|
||||
<el-menu-item index="/community">社区</el-menu-item>
|
||||
<el-menu-item index="/console/recharge" @click="goToRecharge">月卡</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-icon class="header-icon" :class="{ 'mobile-hidden': isMobile }"><Bell /></el-icon>
|
||||
<el-icon class="header-icon" :class="{ 'mobile-hidden': isMobile }"><Monitor /></el-icon>
|
||||
<el-icon class="header-icon" :class="{ 'mobile-hidden': isMobile }"><Operation /></el-icon>
|
||||
<template v-if="!isAuthenticated">
|
||||
<el-button type="text" @click="$router.push('/login')" :class="{ 'mobile-hidden': isMobile }">登录</el-button>
|
||||
<el-button type="primary" @click="$router.push('/register')" size="small">注册</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-dropdown>
|
||||
<span class="user-info">
|
||||
<el-avatar :size="isMobile ? 28 : 32">{{ username.charAt(0).toUpperCase() }}</el-avatar>
|
||||
<span class="username" :class="{ 'mobile-hidden': isMobile }">{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="$router.push('/console/settings')">个人设置</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单抽屉 -->
|
||||
<el-drawer
|
||||
v-model="showMobileMenu"
|
||||
title="菜单"
|
||||
direction="rtl"
|
||||
:size="isMobile ? '80%' : '300px'"
|
||||
>
|
||||
<el-menu mode="vertical" :default-active="activeMenu" router @select="showMobileMenu = false">
|
||||
<el-menu-item index="/">首页</el-menu-item>
|
||||
<el-menu-item index="/console" v-if="isAuthenticated">控制台</el-menu-item>
|
||||
<el-menu-item index="/models">模型广场</el-menu-item>
|
||||
<el-menu-item index="/install">①CC安装步骤</el-menu-item>
|
||||
<el-menu-item index="/config">②环境配置</el-menu-item>
|
||||
<el-menu-item index="/contact">联系我们</el-menu-item>
|
||||
<el-menu-item index="/community">社区</el-menu-item>
|
||||
<el-menu-item index="/console/recharge" @click="goToRecharge">月卡</el-menu-item>
|
||||
</el-menu>
|
||||
</el-drawer>
|
||||
</el-header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Menu } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const username = computed(() => authStore.username)
|
||||
const activeMenu = computed(() => route.path)
|
||||
const showMobileMenu = ref(false)
|
||||
|
||||
// 检测移动端
|
||||
const isMobile = ref(window.innerWidth <= 768)
|
||||
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
if (!isMobile.value) {
|
||||
showMobileMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
const goToRecharge = () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
router.push('/console/recharge')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
showMobileMenu.value = false
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
showMobileMenu.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 24px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-center :deep(.el-menu) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-icon.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.username.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
433
frontend/src/layouts/ConsoleLayout.vue
Normal file
433
frontend/src/layouts/ConsoleLayout.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div class="console-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">
|
||||
<el-icon :size="24"><Box /></el-icon>
|
||||
<span>Claude Code 蓝星</span>
|
||||
</div>
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
:default-active="activeMenu"
|
||||
class="header-menu"
|
||||
>
|
||||
<el-menu-item index="home" @click="$router.push('/')">首页</el-menu-item>
|
||||
<el-menu-item index="console" @click="$router.push('/console')">控制台</el-menu-item>
|
||||
<el-menu-item index="models" @click="$router.push('/models')">模型广场</el-menu-item>
|
||||
<el-menu-item index="install">①CC安装步骤</el-menu-item>
|
||||
<el-menu-item index="config">②环境配置</el-menu-item>
|
||||
<el-menu-item index="contact">联系我们</el-menu-item>
|
||||
<el-menu-item index="community">社区</el-menu-item>
|
||||
<el-menu-item index="monthly" @click="$router.push('/console/recharge')">月卡</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-icon :size="20"><Bell /></el-icon>
|
||||
<el-icon :size="20"><Monitor /></el-icon>
|
||||
<el-icon :size="20"><Edit /></el-icon>
|
||||
<el-dropdown>
|
||||
<span class="user-info">
|
||||
<el-avatar :size="32">{{ username.charAt(0).toUpperCase() }}</el-avatar>
|
||||
<span>{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-container class="main-container">
|
||||
<!-- 移动端遮罩层 -->
|
||||
<div
|
||||
v-if="isMobile && showSidebar"
|
||||
class="sidebar-overlay"
|
||||
@click="showSidebar = false"
|
||||
></div>
|
||||
|
||||
<!-- 移动端侧边栏切换按钮 -->
|
||||
<el-button
|
||||
v-if="isMobile"
|
||||
class="mobile-sidebar-toggle"
|
||||
@click="showSidebar = !showSidebar"
|
||||
type="primary"
|
||||
:icon="showSidebar ? Fold : Expand"
|
||||
circle
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- 左侧边栏 -->
|
||||
<el-aside
|
||||
:width="isMobile ? '200px' : '200px'"
|
||||
class="sidebar"
|
||||
:class="{
|
||||
'mobile-sidebar': isMobile,
|
||||
'mobile-sidebar-open': showSidebar && isMobile,
|
||||
'mobile-sidebar-closed': !showSidebar && isMobile
|
||||
}"
|
||||
>
|
||||
<el-menu
|
||||
:default-active="activeSidebar"
|
||||
class="sidebar-menu"
|
||||
router
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<el-sub-menu index="console">
|
||||
<template #title>
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>控制台</span>
|
||||
</template>
|
||||
<el-menu-item index="/console">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
<span>数据看板</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/console/tokens">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>令牌管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/console/logs">
|
||||
<el-icon><Histogram /></el-icon>
|
||||
<span>使用日志</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/console/draw-logs">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>绘图日志</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/console/task-logs">
|
||||
<el-icon><Select /></el-icon>
|
||||
<span>任务日志</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="personal">
|
||||
<template #title>
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人中心</span>
|
||||
</template>
|
||||
<el-menu-item index="/console/recharge">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>充值额度</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/console/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>个人设置</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-main class="main-content">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Box, Bell, Monitor, Edit, ArrowDown, DataBoard, Key,
|
||||
Histogram, Picture, Select, User, Document, Setting, Fold, Expand
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = computed(() => authStore.username)
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path.startsWith('/console')) return 'console'
|
||||
return 'home'
|
||||
})
|
||||
const activeSidebar = computed(() => route.path)
|
||||
|
||||
// 移动端检测
|
||||
const isMobile = ref(window.innerWidth <= 768)
|
||||
const showSidebar = ref(false)
|
||||
|
||||
// 防抖处理resize事件
|
||||
let resizeTimer = null
|
||||
const handleResize = () => {
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer)
|
||||
}
|
||||
resizeTimer = setTimeout(() => {
|
||||
const wasMobile = isMobile.value
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
|
||||
// 如果从移动端切换到桌面端,关闭侧边栏
|
||||
if (wasMobile && !isMobile.value) {
|
||||
showSidebar.value = false
|
||||
}
|
||||
// 如果从桌面端切换到移动端,也关闭侧边栏
|
||||
if (!wasMobile && isMobile.value) {
|
||||
showSidebar.value = false
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化时检测一次
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
// 桌面端默认显示侧边栏,移动端默认隐藏
|
||||
if (!isMobile.value) {
|
||||
showSidebar.value = true
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer)
|
||||
}
|
||||
})
|
||||
|
||||
const handleMenuSelect = () => {
|
||||
// 移动端选择菜单项后自动关闭侧边栏
|
||||
// 使用nextTick确保路由跳转完成后再关闭
|
||||
if (isMobile.value) {
|
||||
setTimeout(() => {
|
||||
showSidebar.value = false
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
authStore.logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.console-layout {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--brand-2);
|
||||
}
|
||||
|
||||
.header-menu {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 247, 251, 0.98));
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item.is-active) {
|
||||
color: var(--brand-1);
|
||||
background: rgba(91, 140, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item),
|
||||
.sidebar-menu :deep(.el-sub-menu__title) {
|
||||
border-radius: 10px;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: radial-gradient(circle at 20% 0%, rgba(91, 140, 255, 0.08), transparent 35%),
|
||||
radial-gradient(circle at 80% 10%, rgba(122, 92, 255, 0.08), transparent 35%),
|
||||
var(--bg);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(180deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
animation: gridShift 18s linear infinite;
|
||||
}
|
||||
|
||||
.main-content > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes gridShift {
|
||||
0% {
|
||||
background-position: 0 0, 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 56px 56px, -56px 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 10px;
|
||||
z-index: 1001;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 998;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.mobile-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 60px;
|
||||
height: calc(100vh - 60px);
|
||||
z-index: 999;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.2);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.sidebar.mobile-sidebar.mobile-sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar.mobile-sidebar.mobile-sidebar-closed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-right .el-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 12px;
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar.mobile-sidebar {
|
||||
width: 200px !important;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
/* 确保移动端侧边栏按钮始终可见 */
|
||||
.mobile-sidebar-toggle {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
23
frontend/src/main.js
Normal file
23
frontend/src/main.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
app.mount('#app')
|
||||
118
frontend/src/router/index.js
Normal file
118
frontend/src/router/index.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Home.vue')
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue'),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/models',
|
||||
name: 'ModelSquare',
|
||||
component: () => import('@/views/ModelSquare.vue')
|
||||
},
|
||||
{
|
||||
path: '/console',
|
||||
component: () => import('@/layouts/ConsoleLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/console/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: 'tokens',
|
||||
name: 'Tokens',
|
||||
component: () => import('@/views/console/Tokens.vue')
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'Logs',
|
||||
component: () => import('@/views/console/Logs.vue')
|
||||
},
|
||||
{
|
||||
path: 'draw-logs',
|
||||
name: 'DrawLogs',
|
||||
component: () => import('@/views/console/DrawLogs.vue')
|
||||
},
|
||||
{
|
||||
path: 'task-logs',
|
||||
name: 'TaskLogs',
|
||||
component: () => import('@/views/console/TaskLogs.vue')
|
||||
},
|
||||
{
|
||||
path: 'recharge',
|
||||
name: 'Recharge',
|
||||
component: () => import('@/views/console/Recharge.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/console/Settings.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 缓存用户信息获取,避免重复请求
|
||||
let userFetchPromise = null
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
// 未登录,跳转到登录页
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果token存在但用户信息不存在,尝试获取用户信息
|
||||
// 使用缓存避免重复请求
|
||||
if (!authStore.user && authStore.token) {
|
||||
try {
|
||||
// 如果已经有正在进行的请求,等待它完成
|
||||
if (!userFetchPromise) {
|
||||
userFetchPromise = authStore.fetchUser()
|
||||
}
|
||||
await userFetchPromise
|
||||
userFetchPromise = null
|
||||
} catch (error) {
|
||||
// 获取用户信息失败,清除token并跳转登录
|
||||
console.error('获取用户信息失败:', error)
|
||||
userFetchPromise = null
|
||||
authStore.logout()
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.requiresGuest && authStore.isAuthenticated) {
|
||||
next('/console')
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
70
frontend/src/stores/auth.js
Normal file
70
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { authApi } from '@/api/auth'
|
||||
import router from '@/router'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('token') || '',
|
||||
refreshToken: localStorage.getItem('refreshToken') || '',
|
||||
user: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token,
|
||||
username: (state) => state.user?.username || ''
|
||||
},
|
||||
|
||||
actions: {
|
||||
async login(credentials) {
|
||||
try {
|
||||
const response = await authApi.login(credentials)
|
||||
// 确保token格式正确(不包含Bearer前缀)
|
||||
this.token = response.access_token?.replace(/^Bearer\s+/, '') || response.access_token
|
||||
this.refreshToken = response.refresh_token?.replace(/^Bearer\s+/, '') || response.refresh_token
|
||||
this.user = response.user
|
||||
localStorage.setItem('token', this.token)
|
||||
localStorage.setItem('refreshToken', this.refreshToken)
|
||||
console.log('登录成功,Token已保存:', this.token.substring(0, 20) + '...')
|
||||
return response
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async register(data) {
|
||||
try {
|
||||
const response = await authApi.register(data)
|
||||
// 确保token格式正确(不包含Bearer前缀)
|
||||
this.token = response.access_token?.replace(/^Bearer\s+/, '') || response.access_token
|
||||
this.refreshToken = response.refresh_token?.replace(/^Bearer\s+/, '') || response.refresh_token
|
||||
this.user = response.user
|
||||
localStorage.setItem('token', this.token)
|
||||
localStorage.setItem('refreshToken', this.refreshToken)
|
||||
console.log('注册成功,Token已保存:', this.token.substring(0, 20) + '...')
|
||||
return response
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async fetchUser() {
|
||||
try {
|
||||
const response = await authApi.getCurrentUser()
|
||||
this.user = response.user
|
||||
return response
|
||||
} catch (error) {
|
||||
this.logout()
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.refreshToken = ''
|
||||
this.user = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
})
|
||||
222
frontend/src/style.css
Normal file
222
frontend/src/style.css
Normal file
@@ -0,0 +1,222 @@
|
||||
:root {
|
||||
--primary-color: #409eff;
|
||||
--success-color: #67c23a;
|
||||
--warning-color: #e6a23c;
|
||||
--danger-color: #f56c6c;
|
||||
--info-color: #909399;
|
||||
--brand-1: #5b8cff;
|
||||
--brand-2: #7a5cff;
|
||||
--bg: #f5f7fb;
|
||||
--surface: #ffffff;
|
||||
--text-primary: #1f2a44;
|
||||
--text-secondary: #5b6b8a;
|
||||
--border-color: #e6ebf5;
|
||||
--shadow-sm: 0 6px 18px rgba(19, 35, 74, 0.08);
|
||||
--shadow-md: 0 14px 34px rgba(19, 35, 74, 0.12);
|
||||
--glow-primary: 0 0 0 rgba(91, 140, 255, 0.0), 0 0 18px rgba(91, 140, 255, 0.35);
|
||||
--glow-accent: 0 0 0 rgba(122, 92, 255, 0.0), 0 0 22px rgba(122, 92, 255, 0.35);
|
||||
--grid-line: rgba(91, 140, 255, 0.08);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 35%),
|
||||
repeating-linear-gradient(0deg, rgba(91, 140, 255, 0.04) 0 1px, transparent 1px 3px);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes neonPulse {
|
||||
0% {
|
||||
box-shadow: 0 8px 24px rgba(91, 140, 255, 0.25), var(--glow-primary);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 10px 30px rgba(122, 92, 255, 0.35), var(--glow-accent);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
.el-button {
|
||||
border-radius: 10px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: linear-gradient(135deg, var(--brand-1), var(--brand-2), #4ee3ff);
|
||||
background-size: 200% 200%;
|
||||
border: none;
|
||||
box-shadow: var(--glow-primary);
|
||||
animation: shimmer 6s ease infinite;
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
box-shadow: 0 8px 24px rgba(91, 140, 255, 0.35), var(--glow-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.el-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border-radius: 16px;
|
||||
border-color: var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
background: var(--surface);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md), 0 0 24px rgba(91, 140, 255, 0.18);
|
||||
}
|
||||
|
||||
.el-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
background: radial-gradient(circle at 20% 10%, rgba(91, 140, 255, 0.16), transparent 35%);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.el-input__wrapper:focus-within,
|
||||
.el-textarea__inner:focus-within {
|
||||
box-shadow: 0 0 0 1px rgba(91, 140, 255, 0.45), 0 0 18px rgba(91, 140, 255, 0.15);
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
background: linear-gradient(90deg, rgba(91, 140, 255, 0.12), rgba(122, 92, 255, 0.12));
|
||||
}
|
||||
|
||||
.el-table tr:hover > td.el-table__cell {
|
||||
background: rgba(91, 140, 255, 0.06);
|
||||
}
|
||||
|
||||
/* 细腻滚动条 */
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(91, 140, 255, 0.35);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: rgba(91, 140, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 全局响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
/* 表格在小屏幕上横向滚动 */
|
||||
.el-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 卡片在小屏幕上全宽 */
|
||||
.el-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 按钮组在小屏幕上换行 */
|
||||
.el-button-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 表单在小屏幕上垂直排列 */
|
||||
.el-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 对话框在小屏幕上全屏 */
|
||||
.el-dialog {
|
||||
width: 95% !important;
|
||||
margin: 5vh auto !important;
|
||||
}
|
||||
|
||||
/* 抽屉在小屏幕上全屏 */
|
||||
.el-drawer {
|
||||
width: 85% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* 超小屏幕进一步优化 */
|
||||
.el-dialog {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.el-drawer {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* 按钮在小屏幕上全宽 */
|
||||
.el-button {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保所有图片在小屏幕上响应式 */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 文本在小屏幕上更易读 */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
504
frontend/src/views/Home.vue
Normal file
504
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<!-- 顶部导航 -->
|
||||
<el-header class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<el-icon :size="28"><Box /></el-icon>
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
<el-menu mode="horizontal" class="nav-menu">
|
||||
<el-menu-item index="home">首页</el-menu-item>
|
||||
<el-menu-item index="console" @click="goToConsole">控制台</el-menu-item>
|
||||
<el-menu-item index="models" @click="$router.push('/models')">模型广场</el-menu-item>
|
||||
<el-menu-item index="install">①CC安装步骤</el-menu-item>
|
||||
<el-menu-item index="config">②环境配置</el-menu-item>
|
||||
<el-menu-item index="contact">联系我们</el-menu-item>
|
||||
<el-menu-item index="community">社区</el-menu-item>
|
||||
<el-menu-item index="monthly" @click="goToRecharge">月卡</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="header-actions">
|
||||
<el-icon :size="20"><Bell /></el-icon>
|
||||
<el-icon :size="20"><Monitor /></el-icon>
|
||||
<el-icon :size="20"><Edit /></el-icon>
|
||||
<el-button v-if="!isAuthenticated" @click="$router.push('/login')">登录</el-button>
|
||||
<el-button v-if="!isAuthenticated" type="primary" @click="$router.push('/register')">注册</el-button>
|
||||
<el-dropdown v-else>
|
||||
<span class="user-dropdown">
|
||||
<el-avatar :size="32">{{ username.charAt(0).toUpperCase() }}</el-avatar>
|
||||
<span>{{ username }}</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="$router.push('/console')">控制台</el-dropdown-item>
|
||||
<el-dropdown-item @click="logout">退出</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 英雄区域 -->
|
||||
<section class="hero">
|
||||
<div class="hero-badge">服务器已稳定运行 296 天 9 小时 15 分 58 秒</div>
|
||||
<h1 class="hero-title">蓝星至强<span class="arrows">>>></span></h1>
|
||||
<p class="hero-subtitle">7 × 24h 连通性保证</p>
|
||||
<p class="hero-desc">纯Claude API全模型,企业级供应商,源头供应,支持1亿+tpm,超纯净算力,官方超低折扣。</p>
|
||||
</section>
|
||||
|
||||
<!-- 分组选择 -->
|
||||
<section class="groups">
|
||||
<h2 class="section-title">分组选择</h2>
|
||||
<div class="group-cards">
|
||||
<el-card class="group-card recommended">
|
||||
<div class="card-badge">推荐</div>
|
||||
<div class="card-content">
|
||||
<div class="card-icon green"></div>
|
||||
<h3>Claude Code 官方编程模型</h3>
|
||||
<p>适合学生、产品经理、轻度和中度开发者,时间不是很紧迫,随心所欲开发那种。平时写一些小玩意,小创意之类的选择这个分组。</p>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="group-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon orange"></div>
|
||||
<h3>Claude Code 企业专线</h3>
|
||||
<p>适合职业码农工程师,开发大工程、复杂工程选这个,效率优先,7×24h连通性保证,高迪稳定。相应的收费也贵一点。</p>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="group-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon blue"></div>
|
||||
<h3>备份服务器</h3>
|
||||
<p>如果上面两个服务器都失效了,这是最后的选择。</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 功能特色 -->
|
||||
<section class="features">
|
||||
<div class="feature-cards">
|
||||
<el-card class="feature-card">
|
||||
<div class="feature-icon purple">
|
||||
<el-icon :size="40"><Star /></el-icon>
|
||||
</div>
|
||||
<h3>真CC模型-不掺假</h3>
|
||||
<ul class="feature-list">
|
||||
<li><el-icon><Check /></el-icon>支持c4.5/c4 Opus/thinking</li>
|
||||
<li><el-icon><Check /></el-icon>完全保留原生Sonnet和Opus能力和特性</li>
|
||||
<li><el-icon><Check /></el-icon>真正的Claude模型</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
<el-card class="feature-card">
|
||||
<div class="feature-icon pink">
|
||||
<el-icon :size="40"><Money /></el-icon>
|
||||
</div>
|
||||
<h3>计费透明,不收黑钱</h3>
|
||||
<ul class="feature-list">
|
||||
<li><el-icon><Check /></el-icon>实时显示输入/输出Token数量</li>
|
||||
<li><el-icon><Check /></el-icon>详细的消费明细和账单记录</li>
|
||||
<li><el-icon><Check /></el-icon>所有费用按官方定价标准</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
<el-card class="feature-card">
|
||||
<div class="feature-icon blue">
|
||||
<el-icon :size="40"><Lightning /></el-icon>
|
||||
</div>
|
||||
<h3>美区高并发架构无需魔法</h3>
|
||||
<ul class="feature-list">
|
||||
<li><el-icon><Check /></el-icon>美国西海岸数据中心直连</li>
|
||||
<li><el-icon><Check /></el-icon>平均响应延迟<200ms</li>
|
||||
<li><el-icon><Check /></el-icon>支持100000+并发请求</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 支持模型 -->
|
||||
<section class="models">
|
||||
<h2 class="section-title">支持世界最顶级的模型</h2>
|
||||
<div class="model-logos">
|
||||
<div class="model-logo">Anthropic</div>
|
||||
<div class="model-logo">OpenAI</div>
|
||||
<div class="model-logo">Claude</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Box, Bell, Monitor, Edit, Star, Check, Money, Lightning } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const username = computed(() => authStore.username)
|
||||
|
||||
const goToConsole = () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
router.push('/console')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const goToRecharge = () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
router.push('/console/recharge')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
authStore.logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at 20% 10%, rgba(91, 140, 255, 0.12), transparent 35%),
|
||||
radial-gradient(circle at 80% 0%, rgba(122, 92, 255, 0.12), transparent 35%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--brand-2);
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 80px 20px 90px;
|
||||
background: linear-gradient(135deg, #4f7cff 0%, #7a5cff 100%);
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.25), transparent 35%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-block;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 20px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 52px;
|
||||
margin: 18px 0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.arrows {
|
||||
color: #ffa500;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 24px;
|
||||
margin: 10px 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 16px;
|
||||
max-width: 820px;
|
||||
margin: 20px auto;
|
||||
opacity: 0.9;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.groups, .features, .models {
|
||||
max-width: 1200px;
|
||||
margin: 60px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
margin-bottom: 40px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.group-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.group-card.recommended .card-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: var(--success-color);
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-icon.green {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.card-icon.orange {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
.card-icon.blue {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.card-content h3 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.feature-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
text-align: center;
|
||||
padding: 32px 22px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.feature-icon.purple {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.feature-icon.pink {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.feature-icon.blue {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.model-logos {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.model-logo {
|
||||
padding: 18px 36px;
|
||||
background: var(--surface);
|
||||
border-radius: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
padding: 0 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions .el-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.groups, .features, .models {
|
||||
margin: 40px auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.group-cards {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feature-cards {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.model-logos {
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.model-logo {
|
||||
padding: 16px 24px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-content {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 30px 16px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.groups, .features, .models {
|
||||
margin: 30px auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
185
frontend/src/views/Login.vue
Normal file
185
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<div class="logo-section">
|
||||
<el-icon :size="48" color="#409eff"><Box /></el-icon>
|
||||
<h1>Claude Code 蓝星</h1>
|
||||
</div>
|
||||
|
||||
<el-card class="login-card">
|
||||
<h2 class="card-title">登录</h2>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
style="width: 100%"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="register-link">
|
||||
<span>已有账户? </span>
|
||||
<el-link type="primary" @click="$router.push('/register')">注册</el-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Box, User, Lock } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(form)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/console')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.error || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at top, rgba(91, 140, 255, 0.25), transparent 40%),
|
||||
radial-gradient(circle at 80% 20%, rgba(122, 92, 255, 0.25), transparent 35%),
|
||||
#0b1020;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
right: -120px;
|
||||
bottom: -140px;
|
||||
background: radial-gradient(circle, rgba(91, 140, 255, 0.35), transparent 60%);
|
||||
filter: blur(10px);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.logo-section h1 {
|
||||
margin-top: 16px;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(91, 140, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 18px 50px rgba(8, 14, 30, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 30px;
|
||||
color: #1f2a44;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #5b6b8a;
|
||||
}
|
||||
</style>
|
||||
898
frontend/src/views/ModelSquare.vue
Normal file
898
frontend/src/views/ModelSquare.vue
Normal file
@@ -0,0 +1,898 @@
|
||||
<template>
|
||||
<div class="model-square">
|
||||
<!-- 顶部导航栏 -->
|
||||
<el-header class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<el-icon :size="28"><Box /></el-icon>
|
||||
<span>Claude Code 蓝星</span>
|
||||
</div>
|
||||
<el-menu mode="horizontal" class="nav-menu" :default-active="activeMenu">
|
||||
<el-menu-item index="home" @click="$router.push('/')">首页</el-menu-item>
|
||||
<el-menu-item index="console" @click="goToConsole">控制台</el-menu-item>
|
||||
<el-menu-item index="models" @click="$router.push('/models')">模型广场</el-menu-item>
|
||||
<el-menu-item index="install">①CC安装步骤</el-menu-item>
|
||||
<el-menu-item index="config">②环境配置</el-menu-item>
|
||||
<el-menu-item index="contact">联系我们</el-menu-item>
|
||||
<el-menu-item index="community">社区</el-menu-item>
|
||||
<el-menu-item index="monthly" @click="goToRecharge">月卡</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="header-actions">
|
||||
<el-icon :size="20"><Bell /></el-icon>
|
||||
<el-icon :size="20"><Monitor /></el-icon>
|
||||
<el-icon :size="20"><Edit /></el-icon>
|
||||
<el-button v-if="!isAuthenticated" @click="$router.push('/login')">登录</el-button>
|
||||
<el-button v-if="!isAuthenticated" type="primary" @click="$router.push('/register')">注册</el-button>
|
||||
<el-dropdown v-else>
|
||||
<span class="user-dropdown">
|
||||
<el-avatar :size="32">{{ username.charAt(0).toUpperCase() }}</el-avatar>
|
||||
<span>{{ username }}</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="$router.push('/console')">控制台</el-dropdown-item>
|
||||
<el-dropdown-item @click="logout">退出</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 移动端筛选按钮 -->
|
||||
<el-button
|
||||
v-if="isMobile"
|
||||
class="mobile-filter-btn"
|
||||
@click="showFilterDrawer = true"
|
||||
type="primary"
|
||||
>
|
||||
<el-icon><Filter /></el-icon>
|
||||
筛选
|
||||
</el-button>
|
||||
|
||||
<!-- 筛选抽屉(移动端) -->
|
||||
<el-drawer
|
||||
v-model="showFilterDrawer"
|
||||
title="筛选"
|
||||
:size="isMobile ? '80%' : '300px'"
|
||||
direction="ltr"
|
||||
:with-header="true"
|
||||
>
|
||||
<div class="filter-drawer-content">
|
||||
<div class="filter-section">
|
||||
<h4>供应商</h4>
|
||||
<el-radio-group v-model="filters.provider" @change="handleFilterChange">
|
||||
<el-radio label="all">全部供应商</el-radio>
|
||||
<el-radio
|
||||
v-for="p in filterOptions.providers"
|
||||
:key="p"
|
||||
:label="p"
|
||||
>{{ p }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>标签</h4>
|
||||
<el-checkbox-group v-model="filters.tags" @change="handleTagsChange">
|
||||
<el-checkbox label="all">全部标签</el-checkbox>
|
||||
<el-checkbox
|
||||
v-for="tag in filterOptions.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
>{{ tag }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>可用令牌分组</h4>
|
||||
<el-radio-group v-model="filters.token_group" @change="handleFilterChange">
|
||||
<el-radio label="all">全部分组</el-radio>
|
||||
<el-radio
|
||||
v-for="group in filterOptions.groups"
|
||||
:key="group"
|
||||
:label="group"
|
||||
>{{ group }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>计费类型</h4>
|
||||
<el-radio-group v-model="filters.billing_type" @change="handleFilterChange">
|
||||
<el-radio label="all">全部类型</el-radio>
|
||||
<el-radio
|
||||
v-for="type in filterOptions.billing_types"
|
||||
:key="type.value"
|
||||
:label="type.value"
|
||||
>{{ type.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>端点类型</h4>
|
||||
<el-radio-group v-model="filters.endpoint_type" @change="handleFilterChange">
|
||||
<el-radio label="all">全部端点</el-radio>
|
||||
<el-radio
|
||||
v-for="type in filterOptions.endpoint_types"
|
||||
:key="type.value"
|
||||
:label="type.value"
|
||||
>{{ type.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters" style="width: 100%">重置</el-button>
|
||||
<el-button type="primary" @click="showFilterDrawer = false" style="width: 100%">确定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 左侧筛选面板(桌面端) -->
|
||||
<el-aside :width="isMobile ? '0' : '250px'" class="filter-sidebar" :class="{ 'mobile-hidden': isMobile }">
|
||||
<div class="filter-header">
|
||||
<h3>筛选</h3>
|
||||
<el-button text @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>供应商</h4>
|
||||
<el-radio-group v-model="filters.provider" @change="loadModels">
|
||||
<el-radio label="all">全部供应商</el-radio>
|
||||
<el-radio
|
||||
v-for="p in filterOptions.providers"
|
||||
:key="p"
|
||||
:label="p"
|
||||
>{{ p }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>标签</h4>
|
||||
<el-checkbox-group v-model="filters.tags" @change="handleTagsChange">
|
||||
<el-checkbox label="all">全部标签</el-checkbox>
|
||||
<el-checkbox
|
||||
v-for="tag in filterOptions.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
>{{ tag }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>可用令牌分组</h4>
|
||||
<el-radio-group v-model="filters.token_group" @change="loadModels">
|
||||
<el-radio label="all">全部分组</el-radio>
|
||||
<el-radio
|
||||
v-for="group in filterOptions.groups"
|
||||
:key="group"
|
||||
:label="group"
|
||||
>{{ group }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>计费类型</h4>
|
||||
<el-radio-group v-model="filters.billing_type" @change="loadModels">
|
||||
<el-radio label="all">全部类型</el-radio>
|
||||
<el-radio
|
||||
v-for="type in filterOptions.billing_types"
|
||||
:key="type.value"
|
||||
:label="type.value"
|
||||
>{{ type.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>端点类型</h4>
|
||||
<el-radio-group v-model="filters.endpoint_type" @change="loadModels">
|
||||
<el-radio label="all">全部端点</el-radio>
|
||||
<el-radio
|
||||
v-for="type in filterOptions.endpoint_types"
|
||||
:key="type.value"
|
||||
:label="type.value"
|
||||
>{{ type.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</el-aside>
|
||||
|
||||
<!-- 右侧主内容区 -->
|
||||
<el-main class="main-content">
|
||||
<div class="content-header">
|
||||
<div>
|
||||
<h2>{{ filterText }} 共{{ pagination.total }}个模型</h2>
|
||||
<p class="description">查看所有可用的AI模型供应商,包括众多知名供应商的模型。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="模糊搜索模型名称"
|
||||
:class="{ 'mobile-search': isMobile }"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<div class="toolbar-actions" :class="{ 'mobile-actions': isMobile }">
|
||||
<el-button @click="copySelected" size="small">复制</el-button>
|
||||
<div class="switch-group">
|
||||
<span v-if="!isMobile">充值价格显示</span>
|
||||
<el-switch v-model="showRechargePrice" @change="loadModels" size="small" />
|
||||
</div>
|
||||
<div class="switch-group">
|
||||
<span v-if="!isMobile">倍率</span>
|
||||
<el-switch v-model="showMultiplier" @change="loadModels" size="small" />
|
||||
</div>
|
||||
<el-button @click="viewMode = 'table'" size="small" v-if="!isMobile">表格视图</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型卡片网格 -->
|
||||
<div v-if="viewMode === 'grid'" class="models-grid">
|
||||
<el-card
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
class="model-card"
|
||||
shadow="hover"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="provider-logo">
|
||||
<el-icon :size="32" color="#ff6b35"><Star /></el-icon>
|
||||
</div>
|
||||
<div class="model-name">
|
||||
<span>{{ model.name }}</span>
|
||||
<el-icon class="copy-icon" @click="copyModelName(model.name)">
|
||||
<DocumentCopy />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="price-info">
|
||||
<span>输入 ${{ model.input_price.toFixed(4) }}/M</span>
|
||||
<span>输出 ${{ model.output_price.toFixed(4) }}/M</span>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
:type="model.billing_type === 'pay_as_you_go' ? 'primary' : ''"
|
||||
size="small"
|
||||
style="width: 100%; margin-top: 12px"
|
||||
>
|
||||
{{ model.billing_type_display }}
|
||||
</el-button>
|
||||
|
||||
<p v-if="model.description" class="model-description">
|
||||
{{ model.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="model.tags && model.tags.length > 0" class="model-tags">
|
||||
<el-tag
|
||||
v-for="tag in model.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
size="small"
|
||||
style="margin-right: 8px; margin-top: 8px"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<el-tag v-if="model.tags.length > 3" size="small" style="margin-top: 8px">
|
||||
+{{ model.tags.length - 3 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 表格视图 -->
|
||||
<el-table v-else :data="models" style="width: 100%">
|
||||
<el-table-column prop="name" label="模型名称" width="300">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<el-icon><Star /></el-icon>
|
||||
<span>{{ row.name }}</span>
|
||||
<el-icon class="copy-icon" @click="copyModelName(row.name)">
|
||||
<DocumentCopy />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="provider" label="供应商" width="120" />
|
||||
<el-table-column label="价格" width="200">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<div>输入: ${{ row.input_price.toFixed(4) }}/M</div>
|
||||
<div>输出: ${{ row.output_price.toFixed(4) }}/M</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="billing_type_display" label="计费类型" width="120" />
|
||||
<el-table-column prop="endpoint_type" label="端点类型" width="120" />
|
||||
<el-table-column label="标签">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-for="tag in row.tags"
|
||||
:key="tag"
|
||||
size="small"
|
||||
style="margin-right: 4px"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.per_page"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadModels"
|
||||
@current-change="loadModels"
|
||||
style="margin-top: 20px; justify-content: center"
|
||||
/>
|
||||
</el-main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { modelsApi } from '@/api/models'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Box, Bell, Monitor, Edit, Search, Star, DocumentCopy, Filter
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const username = computed(() => authStore.username)
|
||||
const activeMenu = ref('models')
|
||||
|
||||
const models = ref([])
|
||||
const loading = ref(false)
|
||||
const viewMode = ref('grid')
|
||||
const searchQuery = ref('')
|
||||
const showRechargePrice = ref(false)
|
||||
const showMultiplier = ref(false)
|
||||
const showFilterDrawer = ref(false)
|
||||
|
||||
// 检测移动端
|
||||
const isMobile = ref(window.innerWidth <= 768)
|
||||
|
||||
// 监听窗口大小变化
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
loadFilters()
|
||||
loadModels()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
provider: 'all',
|
||||
tags: ['all'],
|
||||
token_group: 'all',
|
||||
billing_type: 'all',
|
||||
endpoint_type: 'all'
|
||||
})
|
||||
|
||||
const filterOptions = reactive({
|
||||
providers: [],
|
||||
tags: [],
|
||||
groups: [],
|
||||
billing_types: [],
|
||||
endpoint_types: []
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const filterText = computed(() => {
|
||||
if (filters.provider === 'all') {
|
||||
return '全部供应商'
|
||||
}
|
||||
return filters.provider
|
||||
})
|
||||
|
||||
let searchTimer = null
|
||||
|
||||
const loadModels = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
per_page: pagination.per_page,
|
||||
provider: filters.provider,
|
||||
token_group: filters.token_group,
|
||||
billing_type: filters.billing_type,
|
||||
endpoint_type: filters.endpoint_type,
|
||||
show_recharge_price: showRechargePrice.value,
|
||||
show_multiplier: showMultiplier.value
|
||||
}
|
||||
|
||||
// 处理标签
|
||||
if (filters.tags.length > 0 && !filters.tags.includes('all')) {
|
||||
params.tags = filters.tags
|
||||
}
|
||||
|
||||
// 搜索
|
||||
if (searchQuery.value) {
|
||||
params.search = searchQuery.value
|
||||
}
|
||||
|
||||
const response = await modelsApi.getModels(params)
|
||||
models.value = response.models
|
||||
pagination.total = response.pagination.total
|
||||
pagination.pages = response.pagination.pages
|
||||
|
||||
// 更新筛选选项
|
||||
if (response.filters) {
|
||||
if (response.filters.providers) {
|
||||
filterOptions.providers = response.filters.providers
|
||||
}
|
||||
if (response.filters.tags) {
|
||||
filterOptions.tags = response.filters.tags
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模型列表失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadFilters = async () => {
|
||||
try {
|
||||
const response = await modelsApi.getFilters()
|
||||
filterOptions.providers = response.providers || []
|
||||
filterOptions.tags = response.tags || []
|
||||
filterOptions.groups = response.groups || []
|
||||
filterOptions.billing_types = response.billing_types || []
|
||||
filterOptions.endpoint_types = response.endpoint_types || []
|
||||
} catch (error) {
|
||||
console.error('加载筛选选项失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadModels()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleTagsChange = () => {
|
||||
// 如果选择了"全部标签",清除其他选择
|
||||
if (filters.tags.includes('all')) {
|
||||
filters.tags = ['all']
|
||||
}
|
||||
loadModels()
|
||||
if (isMobile.value) {
|
||||
showFilterDrawer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
loadModels()
|
||||
if (isMobile.value) {
|
||||
showFilterDrawer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.provider = 'all'
|
||||
filters.tags = ['all']
|
||||
filters.token_group = 'all'
|
||||
filters.billing_type = 'all'
|
||||
filters.endpoint_type = 'all'
|
||||
searchQuery.value = ''
|
||||
pagination.page = 1
|
||||
loadModels()
|
||||
}
|
||||
|
||||
const copyModelName = (name) => {
|
||||
navigator.clipboard.writeText(name)
|
||||
ElMessage.success('已复制模型名称')
|
||||
}
|
||||
|
||||
const copySelected = () => {
|
||||
if (models.value.length === 0) {
|
||||
ElMessage.warning('没有可复制的模型')
|
||||
return
|
||||
}
|
||||
const names = models.value.map(m => m.name).join('\n')
|
||||
navigator.clipboard.writeText(names)
|
||||
ElMessage.success('已复制所有模型名称')
|
||||
}
|
||||
|
||||
const goToConsole = () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
router.push('/console')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const goToRecharge = () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
router.push('/console/recharge')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
authStore.logout()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.model-square {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-filter-btn {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.filter-sidebar.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-drawer-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.filter-drawer-content .filter-section {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.filter-drawer-content .filter-section:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-sidebar {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.filter-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filter-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-section :deep(.el-radio-group),
|
||||
.filter-section :deep(.el-checkbox-group) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #909399;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.switch-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.models-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
padding: 0 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions .el-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
padding: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mobile-search {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.toolbar-actions.mobile-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar-actions.mobile-actions .switch-group {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.models-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.price-info {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.el-pagination :deep(.el-pagination__sizes),
|
||||
.el-pagination :deep(.el-pagination__jump) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-content {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.model-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.copy-icon:hover {
|
||||
color: var(--primary-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.price-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.model-description {
|
||||
margin: 12px 0 0 0;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.model-tags {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
212
frontend/src/views/Register.vue
Normal file
212
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<div class="register-container">
|
||||
<div class="logo-section">
|
||||
<el-icon :size="48" color="#409eff"><Box /></el-icon>
|
||||
<h1>Claude Code 蓝星</h1>
|
||||
</div>
|
||||
|
||||
<el-card class="register-card">
|
||||
<h2 class="card-title">注册</h2>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@submit.prevent="handleRegister"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="输入密码, 最短8位, 最长20位"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirm_password">
|
||||
<el-input
|
||||
v-model="form.confirm_password"
|
||||
type="password"
|
||||
placeholder="确认密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
style="width: 100%"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-link">
|
||||
<span>已有账户? </span>
|
||||
<el-link type="primary" @click="$router.push('/login')">登录</el-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Box, User, Lock } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
invite_code: route.query.aff || ''
|
||||
})
|
||||
|
||||
const validateConfirmPassword = (rule, value, callback) => {
|
||||
if (value !== form.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 8, max: 20, message: '密码长度必须在8-20位之间', trigger: 'blur' }
|
||||
],
|
||||
confirm_password: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.register(form)
|
||||
ElMessage.success('注册成功')
|
||||
router.push('/console')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.error || '注册失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at top, rgba(91, 140, 255, 0.25), transparent 40%),
|
||||
radial-gradient(circle at 80% 20%, rgba(122, 92, 255, 0.25), transparent 35%),
|
||||
#0b1020;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.register-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.register-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
left: -120px;
|
||||
top: -140px;
|
||||
background: radial-gradient(circle, rgba(122, 92, 255, 0.35), transparent 60%);
|
||||
filter: blur(10px);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.logo-section h1 {
|
||||
margin-top: 16px;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(91, 140, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 18px 50px rgba(8, 14, 30, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 30px;
|
||||
color: #1f2a44;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #5b6b8a;
|
||||
}
|
||||
</style>
|
||||
344
frontend/src/views/console/Dashboard.vue
Normal file
344
frontend/src/views/console/Dashboard.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="greeting">
|
||||
<h2>下午好, {{ username }}</h2>
|
||||
<div class="actions">
|
||||
<el-icon :size="20"><Search /></el-icon>
|
||||
<el-icon :size="20"><Refresh /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<!-- 账户数据 -->
|
||||
<el-card class="stat-card account-card">
|
||||
<div class="card-header">
|
||||
<h3>账户数据</h3>
|
||||
<el-button type="primary" size="small" @click="$router.push('/console/recharge')">充值</el-button>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-item">
|
||||
<span class="label">当前余额</span>
|
||||
<span class="value">${{ stats.account?.balance?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">历史消耗</span>
|
||||
<span class="value">${{ stats.account?.total_consumption?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 使用统计 -->
|
||||
<el-card class="stat-card">
|
||||
<h3>使用统计</h3>
|
||||
<div class="stat-content">
|
||||
<div class="stat-item">
|
||||
<span class="label">请求次数</span>
|
||||
<span class="value">{{ stats.usage?.request_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">统计次数</span>
|
||||
<span class="value">{{ stats.usage?.stat_count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Token统计 -->
|
||||
<el-card class="stat-card">
|
||||
<el-tabs v-model="tokenTab">
|
||||
<el-tab-pane label="今日" name="today">
|
||||
<div class="token-stats">
|
||||
<div class="token-item">
|
||||
<span>输入</span>
|
||||
<span>{{ tokenStats.today?.input || 0 }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span>输出</span>
|
||||
<span>{{ tokenStats.today?.output || 0 }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span>缓存创建</span>
|
||||
<span>{{ tokenStats.today?.cache_create || 0 }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span>缓存读取</span>
|
||||
<span>{{ tokenStats.today?.cache_read || 0 }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span>总计</span>
|
||||
<span>{{ tokenStats.today?.total || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="7天" name="week">
|
||||
<div class="token-stats">
|
||||
<div class="token-item">
|
||||
<span>输入</span>
|
||||
<span>{{ tokenStats.week?.input || 0 }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span>输出</span>
|
||||
<span>{{ tokenStats.week?.output || 0 }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span>总计</span>
|
||||
<span>{{ tokenStats.week?.total || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 性能指标 -->
|
||||
<el-card class="stat-card">
|
||||
<h3>性能指标</h3>
|
||||
<div class="stat-content">
|
||||
<div class="stat-item">
|
||||
<span class="label">平均RPM</span>
|
||||
<span class="value">{{ stats.performance?.avg_rpm || '0.000' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">平均TPM</span>
|
||||
<span class="value">{{ stats.performance?.avg_tpm || '0.000' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 模型数据分析 -->
|
||||
<el-card class="chart-card">
|
||||
<div class="card-header">
|
||||
<h3>模型消耗分布</h3>
|
||||
<span class="total">总计: ${{ totalCost.toFixed(2) }}</span>
|
||||
</div>
|
||||
<el-tabs v-model="chartTab">
|
||||
<el-tab-pane label="消耗分布" name="cost"></el-tab-pane>
|
||||
<el-tab-pane label="消耗趋势" name="trend"></el-tab-pane>
|
||||
<el-tab-pane label="调用次数分布" name="count"></el-tab-pane>
|
||||
<el-tab-pane label="调用次数排行" name="rank"></el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="chart-placeholder">
|
||||
<p>图表区域(需要集成ECharts)</p>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- API信息 -->
|
||||
<el-card class="api-card">
|
||||
<div class="api-content">
|
||||
<div>
|
||||
<h3>蓝星中国服务器</h3>
|
||||
<p class="api-url">{{ serverInfo.url }}</p>
|
||||
<p class="api-desc">{{ serverInfo.description }}</p>
|
||||
</div>
|
||||
<div class="api-actions">
|
||||
<el-button>测速</el-button>
|
||||
<el-button type="primary">跳转</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { dashboardApi } from '@/api/dashboard'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const username = computed(() => authStore.username)
|
||||
|
||||
const stats = reactive({
|
||||
account: {},
|
||||
usage: {},
|
||||
token_stats: {},
|
||||
performance: {}
|
||||
})
|
||||
|
||||
const tokenTab = ref('today')
|
||||
const chartTab = ref('cost')
|
||||
|
||||
const tokenStats = computed(() => stats.token_stats || {})
|
||||
const totalCost = computed(() => {
|
||||
return stats.model_distribution?.reduce((sum, item) => sum + item.cost, 0) || 0
|
||||
})
|
||||
|
||||
const serverInfo = reactive({
|
||||
name: '蓝星中国服务器',
|
||||
url: 'https://cc.honoursoft.cn',
|
||||
description: 'cn2网络回国优化,支持海外,国内双向访问。'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// 确保用户已登录
|
||||
if (!authStore.isAuthenticated) {
|
||||
console.warn('用户未登录,无法获取数据')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await dashboardApi.getStats()
|
||||
Object.assign(stats, data)
|
||||
if (data.model_distribution) {
|
||||
stats.model_distribution = data.model_distribution
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
if (error.response?.status === 422 || error.response?.status === 401) {
|
||||
// Token验证失败,已由拦截器处理
|
||||
console.warn('Token验证失败,请重新登录')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await dashboardApi.getServerInfo()
|
||||
Object.assign(serverInfo, info)
|
||||
} catch (error) {
|
||||
console.error('获取服务器信息失败:', error)
|
||||
if (error.response?.status === 422 || error.response?.status === 401) {
|
||||
// Token验证失败,已由拦截器处理
|
||||
console.warn('Token验证失败,请重新登录')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.greeting h2 {
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
min-height: 150px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.account-card {
|
||||
background: linear-gradient(135deg, #4f7cff 0%, #7a5cff 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 18px 36px rgba(63, 92, 255, 0.25);
|
||||
}
|
||||
|
||||
.account-card .card-header h3,
|
||||
.account-card .label,
|
||||
.account-card .value {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.token-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed rgba(31, 42, 68, 0.12);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(91, 140, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(91, 140, 255, 0.3);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.api-card {
|
||||
background: rgba(91, 140, 255, 0.08);
|
||||
border: 1px solid rgba(91, 140, 255, 0.2);
|
||||
}
|
||||
|
||||
.api-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.api-url {
|
||||
font-family: monospace;
|
||||
color: var(--brand-1);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.api-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.api-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
19
frontend/src/views/console/DrawLogs.vue
Normal file
19
frontend/src/views/console/DrawLogs.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="draw-logs-page">
|
||||
<h2>绘图日志</h2>
|
||||
<el-empty description="暂无数据" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.draw-logs-page {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
</style>
|
||||
234
frontend/src/views/console/Logs.vue
Normal file
234
frontend/src/views/console/Logs.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="logs-page">
|
||||
<div class="stats-bar">
|
||||
<el-card>
|
||||
<div class="stat-item">
|
||||
<span class="label">消耗额度:</span>
|
||||
<span class="value">${{ stats.total_cost?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">RPM:</span>
|
||||
<span class="value">{{ stats.rpm || '0' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">TPM:</span>
|
||||
<span class="value">{{ stats.tpm || '0' }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filters.token_name"
|
||||
placeholder="搜索令牌名称"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input
|
||||
v-model="filters.model_name"
|
||||
placeholder="搜索模型名称"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="filters.group" placeholder="分组" style="width: 150px" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="Claude Code 官方编程模型" value="Claude Code 官方编程模型" />
|
||||
<el-option label="Claude Code 企业专线" value="Claude Code 企业专线" />
|
||||
<el-option label="备份服务器" value="备份服务器" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadLogs">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button>列设置</el-button>
|
||||
<el-button>紧凑列表</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="logs"
|
||||
style="width: 100%"
|
||||
empty-text="搜索无结果"
|
||||
>
|
||||
<el-table-column prop="created_at" label="时间" width="180" />
|
||||
<el-table-column prop="token_name" label="令牌" />
|
||||
<el-table-column prop="group" label="分组" />
|
||||
<el-table-column prop="log_type" label="类型" />
|
||||
<el-table-column prop="model" label="模型" />
|
||||
<el-table-column prop="time_display" label="用时/首字" />
|
||||
<el-table-column prop="input_tokens" label="输入" />
|
||||
<el-table-column prop="output_tokens" label="输出" />
|
||||
<el-table-column prop="cost" label="花费" width="100">
|
||||
<template #default="{ row }">
|
||||
${{ row.cost?.toFixed(4) || '0.0000' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_address" label="IP" />
|
||||
<el-table-column label="详情" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link @click="viewDetails(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.per_page"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadLogs"
|
||||
@current-change="loadLogs"
|
||||
style="margin-top: 20px; justify-content: flex-end"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref([])
|
||||
const stats = reactive({
|
||||
total_cost: 0,
|
||||
rpm: 0,
|
||||
tpm: 0
|
||||
})
|
||||
|
||||
const dateRange = ref([
|
||||
dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
||||
dayjs().format('YYYY-MM-DD HH:mm:ss')
|
||||
])
|
||||
|
||||
const filters = reactive({
|
||||
token_name: '',
|
||||
model_name: '',
|
||||
group: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const loadLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
per_page: pagination.per_page,
|
||||
start_date: dateRange.value?.[0],
|
||||
end_date: dateRange.value?.[1],
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await logsApi.getUsageLogs(params)
|
||||
logs.value = response.logs
|
||||
pagination.total = response.pagination.total
|
||||
|
||||
if (response.stats) {
|
||||
Object.assign(stats, response.stats)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
dateRange.value = [
|
||||
dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
||||
dayjs().format('YYYY-MM-DD HH:mm:ss')
|
||||
]
|
||||
filters.token_name = ''
|
||||
filters.model_name = ''
|
||||
filters.group = ''
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
const viewDetails = (row) => {
|
||||
// 实现详情查看
|
||||
ElMessage.info('查看详情功能待实现')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-page {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(91, 140, 255, 0.06);
|
||||
border: 1px dashed rgba(91, 140, 255, 0.25);
|
||||
}
|
||||
|
||||
.logs-page :deep(.el-table) {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-page :deep(.el-table th.el-table__cell) {
|
||||
background: rgba(91, 140, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logs-page :deep(.el-table__cell) {
|
||||
border-color: rgba(230, 235, 245, 0.8);
|
||||
}
|
||||
</style>
|
||||
473
frontend/src/views/console/Recharge.vue
Normal file
473
frontend/src/views/console/Recharge.vue
Normal file
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<div class="recharge-page">
|
||||
<div class="recharge-layout">
|
||||
<!-- 左侧:账户充值 -->
|
||||
<div class="recharge-section">
|
||||
<h2>账户充值</h2>
|
||||
<p class="rate-info">充值比例 {{ rechargeRate }} = 1美刀</p>
|
||||
|
||||
<!-- 账户统计 -->
|
||||
<el-card class="account-card">
|
||||
<div class="card-content">
|
||||
<div class="stat-item">
|
||||
<span class="label">当前余额</span>
|
||||
<span class="value">${{ accountInfo.balance?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">历史消耗</span>
|
||||
<span class="value">${{ accountInfo.total_consumption?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">请求次数</span>
|
||||
<span class="value">{{ accountInfo.request_count || 0 }}</span>
|
||||
</div>
|
||||
<el-button type="primary" class="bill-btn">账单</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 充值金额 -->
|
||||
<el-form :model="rechargeForm" label-width="140px" style="margin-top: 20px">
|
||||
<el-form-item label="充值金额 (单位: 美元)">
|
||||
<el-input-number
|
||||
v-model="rechargeForm.amount"
|
||||
:min="1"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<p class="actual-amount">实付金额: {{ actualAmount.toFixed(2) }}元</p>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 支付方式 -->
|
||||
<el-form-item label="选择支付方式">
|
||||
<el-radio-group v-model="rechargeForm.payment_method">
|
||||
<el-radio label="alipay">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
支付宝支付
|
||||
</el-radio>
|
||||
<el-radio label="wechat">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
微信支付
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="help-text">充值有问题请联系管理员微信:cursor2028</p>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 充值额度选择 -->
|
||||
<el-form-item label="选择充值额度">
|
||||
<div class="amount-buttons">
|
||||
<el-button
|
||||
v-for="amount in presetAmounts"
|
||||
:key="amount"
|
||||
:type="rechargeForm.amount === amount ? 'primary' : ''"
|
||||
@click="rechargeForm.amount = amount"
|
||||
>
|
||||
{{ amount }} 美元
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" @click="handleRecharge" style="width: 100%">
|
||||
立即充值
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 兑换码充值 -->
|
||||
<el-divider />
|
||||
<el-form :model="exchangeForm" label-width="140px">
|
||||
<el-form-item label="兑换码充值">
|
||||
<div style="display: flex; gap: 12px">
|
||||
<el-input
|
||||
v-model="exchangeForm.code"
|
||||
placeholder="请输入兑换码"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-button type="primary" @click="handleExchange">兑换额度</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:邀请奖励 -->
|
||||
<div class="reward-section">
|
||||
<h2>邀请奖励</h2>
|
||||
<p class="reward-desc">邀请好友获得额外奖励</p>
|
||||
|
||||
<!-- 收益统计 -->
|
||||
<el-card class="reward-card">
|
||||
<div class="card-content">
|
||||
<div class="stat-item">
|
||||
<span class="label">待使用收益</span>
|
||||
<span class="value">${{ inviteReward.pending?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">总收益</span>
|
||||
<span class="value">${{ inviteReward.total?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">邀请人数</span>
|
||||
<span class="value">{{ inviteReward.invite_count || 0 }}</span>
|
||||
</div>
|
||||
<el-button type="success" class="transfer-btn" @click="handleTransferReward">
|
||||
划转到余额
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 邀请链接 -->
|
||||
<div class="invite-link-section">
|
||||
<h3>邀请链接</h3>
|
||||
<div class="link-display">
|
||||
<el-input :value="inviteReward.invite_url" readonly />
|
||||
<el-button type="primary" @click="copyInviteLink">复制</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 奖励说明 -->
|
||||
<div class="reward-rules">
|
||||
<h3>奖励说明</h3>
|
||||
<ul>
|
||||
<li>邀请好友注册,好友充值后您可获得相应奖励</li>
|
||||
<li>通过划转功能将奖励额度转入到您的账户余额中</li>
|
||||
<li>邀请的好友越多,获得的奖励越多</li>
|
||||
<li>返佣功能已经正确激活,返佣比例2%,你的好友每充值一笔你都可以获得2%,快去邀请你的好友吧</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { rechargeApi } from '@/api/recharge'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Wallet } from '@element-plus/icons-vue'
|
||||
|
||||
const rechargeRate = ref(0.65)
|
||||
const presetAmounts = [30, 50, 100, 200, 300, 500, 1000]
|
||||
|
||||
const accountInfo = reactive({
|
||||
balance: 0,
|
||||
total_consumption: 0,
|
||||
request_count: 0
|
||||
})
|
||||
|
||||
const rechargeForm = reactive({
|
||||
amount: 30,
|
||||
payment_method: 'alipay'
|
||||
})
|
||||
|
||||
const exchangeForm = reactive({
|
||||
code: ''
|
||||
})
|
||||
|
||||
const inviteReward = reactive({
|
||||
pending: 0,
|
||||
total: 0,
|
||||
invite_count: 0,
|
||||
invite_url: ''
|
||||
})
|
||||
|
||||
const actualAmount = computed(() => {
|
||||
return (rechargeForm.amount || 0) * rechargeRate.value
|
||||
})
|
||||
|
||||
const loadRechargeInfo = async () => {
|
||||
try {
|
||||
const data = await rechargeApi.getRechargeInfo()
|
||||
Object.assign(accountInfo, {
|
||||
balance: data.balance,
|
||||
total_consumption: data.total_consumption,
|
||||
request_count: data.request_count
|
||||
})
|
||||
rechargeRate.value = data.recharge_rate
|
||||
|
||||
if (data.invite_reward) {
|
||||
Object.assign(inviteReward, data.invite_reward)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载充值信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecharge = async () => {
|
||||
if (!rechargeForm.amount || rechargeForm.amount <= 0) {
|
||||
ElMessage.warning('请输入有效的充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await rechargeApi.createRecharge({
|
||||
amount: rechargeForm.amount,
|
||||
payment_method: rechargeForm.payment_method
|
||||
})
|
||||
|
||||
ElMessage.success('订单创建成功')
|
||||
// 这里应该跳转到支付页面
|
||||
console.log('支付链接:', response.payment_url)
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.error || '创建订单失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExchange = async () => {
|
||||
if (!exchangeForm.code.trim()) {
|
||||
ElMessage.warning('请输入兑换码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await rechargeApi.exchangeCode({ code: exchangeForm.code })
|
||||
ElMessage.success('兑换成功')
|
||||
exchangeForm.code = ''
|
||||
loadRechargeInfo()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.error || '兑换失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTransferReward = async () => {
|
||||
try {
|
||||
await rechargeApi.transferReward()
|
||||
ElMessage.success('转移成功')
|
||||
loadRechargeInfo()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.error || '转移失败')
|
||||
}
|
||||
}
|
||||
|
||||
const copyInviteLink = () => {
|
||||
navigator.clipboard.writeText(inviteReward.invite_url)
|
||||
ElMessage.success('已复制邀请链接')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRechargeInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recharge-page {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.recharge-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.recharge-section h2,
|
||||
.reward-section h2 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rate-info,
|
||||
.reward-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
background: linear-gradient(135deg, #4f7cff 0%, #7a5cff 100%);
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
border: none;
|
||||
box-shadow: 0 18px 36px rgba(63, 92, 255, 0.25);
|
||||
}
|
||||
|
||||
.reward-card {
|
||||
background: linear-gradient(135deg, #2ad4a5 0%, #24c6dc 100%);
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
border: none;
|
||||
box-shadow: 0 18px 36px rgba(36, 198, 220, 0.25);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-item .label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.stat-item .value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bill-btn,
|
||||
.transfer-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.actual-amount {
|
||||
margin-top: 8px;
|
||||
color: var(--brand-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.amount-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-link-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.link-display {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.reward-rules {
|
||||
background: rgba(91, 140, 255, 0.08);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(91, 140, 255, 0.25);
|
||||
}
|
||||
|
||||
.reward-rules h3 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.reward-rules ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reward-rules li {
|
||||
padding: 8px 0;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.reward-rules li::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--brand-1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recharge-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.recharge-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.recharge-section h2,
|
||||
.reward-section h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.account-card,
|
||||
.reward-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-item .value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.bill-btn,
|
||||
.transfer-btn {
|
||||
position: static;
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.amount-buttons {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.amount-buttons .el-button {
|
||||
flex: 1;
|
||||
min-width: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.link-display {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.link-display .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
width: 100% !important;
|
||||
text-align: left;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.recharge-page {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.recharge-section h2,
|
||||
.reward-section h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stat-item .value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.amount-buttons .el-button {
|
||||
min-width: calc(50% - 4px);
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
319
frontend/src/views/console/Settings.vue
Normal file
319
frontend/src/views/console/Settings.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<!-- 用户概览 -->
|
||||
<el-card class="user-overview">
|
||||
<div class="overview-content">
|
||||
<el-avatar :size="80" style="background: #667eea">
|
||||
{{ username.charAt(0).toUpperCase() }}{{ username.charAt(1)?.toUpperCase() }}
|
||||
</el-avatar>
|
||||
<div class="user-info">
|
||||
<h2>{{ username }}</h2>
|
||||
<p>普通用户</p>
|
||||
<div class="balance-display">
|
||||
<span>当前余额</span>
|
||||
<span class="balance-value">${{ userProfile.balance?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-stats">
|
||||
<div class="stat">
|
||||
<span class="label">历史消耗</span>
|
||||
<span class="value">${{ userProfile.total_consumption?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">请求次数</span>
|
||||
<span class="value">{{ userProfile.request_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">用户分组</span>
|
||||
<span class="value">{{ userProfile.user_group || 'default' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 账户管理 -->
|
||||
<el-card class="settings-section">
|
||||
<template #header>
|
||||
<div class="section-header">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>账户管理</span>
|
||||
</div>
|
||||
<p class="section-desc">账户绑定、安全设置和身份验证</p>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="accountTab">
|
||||
<el-tab-pane label="账户绑定" name="binding">
|
||||
<div class="binding-list">
|
||||
<div class="binding-item" v-for="item in bindings" :key="item.name">
|
||||
<div class="binding-info">
|
||||
<span class="binding-name">{{ item.name }}</span>
|
||||
<el-tag :type="item.status === '已绑定' ? 'success' : 'info'">
|
||||
{{ item.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button
|
||||
:type="item.status === '已绑定' ? '' : 'primary'"
|
||||
size="small"
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
{{ item.status === '已绑定' ? '已启用' : '绑定' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="安全设置" name="security">
|
||||
<p>安全设置功能待实现</p>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 其他设置 -->
|
||||
<el-card class="settings-section">
|
||||
<template #header>
|
||||
<div class="section-header">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>其他设置</span>
|
||||
</div>
|
||||
<p class="section-desc">通知、价格和隐私相关设置</p>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="otherTab">
|
||||
<el-tab-pane label="通知配置" name="notification">
|
||||
<el-form :model="settingsForm" label-width="200px" style="max-width: 600px">
|
||||
<el-form-item label="通知方式*">
|
||||
<el-radio-group v-model="settingsForm.notification.method">
|
||||
<el-radio label="email">邮件通知</el-radio>
|
||||
<el-radio label="webhook">Webhook通知</el-radio>
|
||||
<el-radio label="bark">Bark通知</el-radio>
|
||||
<el-radio label="gotify">Gotify通知</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="额度预警阈值 等价金额:$1.00 *">
|
||||
<el-input-number
|
||||
v-model="settingsForm.notification.threshold"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<p class="help-text">当剩余额度低于此数值时,系统将通过选择的方式发送通知</p>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="通知邮箱">
|
||||
<el-input
|
||||
v-model="settingsForm.notification.email"
|
||||
placeholder="留空则使用账号绑定的邮箱"
|
||||
/>
|
||||
<p class="help-text">设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱</p>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveSettings">保存设置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="价格设置" name="price">
|
||||
<p>价格设置功能待实现</p>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="隐私设置" name="privacy">
|
||||
<p>隐私设置功能待实现</p>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="边栏设置" name="sidebar">
|
||||
<p>边栏设置功能待实现</p>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { userApi } from '@/api/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Bell } from '@element-plus/icons-vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const username = computed(() => authStore.username)
|
||||
|
||||
const accountTab = ref('binding')
|
||||
const otherTab = ref('notification')
|
||||
|
||||
const userProfile = reactive({
|
||||
balance: 0,
|
||||
total_consumption: 0,
|
||||
request_count: 0,
|
||||
user_group: 'default'
|
||||
})
|
||||
|
||||
const bindings = ref([
|
||||
{ name: '邮箱', status: '未绑定', disabled: false },
|
||||
{ name: '微信', status: '未启用', disabled: true },
|
||||
{ name: 'GitHub', status: '未绑定', disabled: true },
|
||||
{ name: 'OIDC', status: '未绑定', disabled: true },
|
||||
{ name: 'Telegram', status: '未绑定', disabled: true },
|
||||
{ name: 'LinuxDO', status: '未绑定', disabled: true }
|
||||
])
|
||||
|
||||
const settingsForm = reactive({
|
||||
notification: {
|
||||
method: 'email',
|
||||
threshold: 500000,
|
||||
email: ''
|
||||
}
|
||||
})
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const response = await userApi.getProfile()
|
||||
Object.assign(userProfile, response.user)
|
||||
} catch (error) {
|
||||
ElMessage.error('加载用户信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await userApi.getSettings()
|
||||
if (response.notification) {
|
||||
Object.assign(settingsForm.notification, response.notification)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await userApi.updateSettings(settingsForm)
|
||||
ElMessage.success('设置保存成功')
|
||||
} catch (error) {
|
||||
ElMessage.error('保存设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.user-overview {
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, #4f7cff 0%, #7a5cff 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 18px 36px rgba(63, 92, 255, 0.25);
|
||||
}
|
||||
|
||||
.overview-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.user-info h2 {
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-info p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.balance-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat .value {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.binding-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.binding-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: rgba(91, 140, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(91, 140, 255, 0.2);
|
||||
}
|
||||
|
||||
.binding-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.binding-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
19
frontend/src/views/console/TaskLogs.vue
Normal file
19
frontend/src/views/console/TaskLogs.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="task-logs-page">
|
||||
<h2>任务日志</h2>
|
||||
<el-empty description="暂无数据" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-logs-page {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
</style>
|
||||
366
frontend/src/views/console/Tokens.vue
Normal file
366
frontend/src/views/console/Tokens.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="tokens-page">
|
||||
<div class="page-header">
|
||||
<h2>令牌管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="showAddDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加令牌
|
||||
</el-button>
|
||||
<el-button @click="handleCopySelected">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
复制所选令牌
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleDeleteSelected">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除所选令牌
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索关键字"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input
|
||||
v-model="searchForm.key"
|
||||
placeholder="密钥"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="loadTokens">查询</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tokens"
|
||||
@selection-change="handleSelectionChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'">
|
||||
{{ row.status === 'enabled' ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quota_display" label="剩余额度/总额度" />
|
||||
<el-table-column prop="group" label="分组" />
|
||||
<el-table-column prop="key" label="密钥" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="key-display">
|
||||
<span>{{ row.key }}</span>
|
||||
<el-icon class="key-icon" @click="copyKey(row.key)"><DocumentCopy /></el-icon>
|
||||
<el-icon class="key-icon" @click="toggleKeyVisibility(row)"><View /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="available_models" label="可用模型">
|
||||
<template #default="{ row }">
|
||||
{{ row.available_models || '无限制' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_restriction" label="IP限制">
|
||||
<template #default="{ row }">
|
||||
{{ row.ip_restriction || '无限制' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column prop="expires_display" label="过期时间" width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-dropdown>
|
||||
<el-button type="primary" size="small">聊天</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item>使用此令牌</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button
|
||||
:type="row.status === 'enabled' ? 'warning' : 'success'"
|
||||
size="small"
|
||||
@click="toggleTokenStatus(row)"
|
||||
>
|
||||
{{ row.status === 'enabled' ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" @click="editToken(row)">编辑</el-button>
|
||||
<el-button type="danger" size="small" @click="deleteToken(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="showAddDialog"
|
||||
:title="editingToken ? '编辑令牌' : '添加令牌'"
|
||||
width="600px"
|
||||
>
|
||||
<el-form :model="tokenForm" label-width="100px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="tokenForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="tokenForm.group" style="width: 100%">
|
||||
<el-option label="Claude Code 官方编程模型" value="Claude Code 官方编程模型" />
|
||||
<el-option label="Claude Code 企业专线" value="Claude Code 企业专线" />
|
||||
<el-option label="备份服务器" value="备份服务器" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="剩余额度">
|
||||
<el-input-number v-model="tokenForm.remaining_quota" :precision="2" style="width: 100%" />
|
||||
<span style="margin-left: 10px; color: #909399">留空表示无限额度</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="总额度">
|
||||
<el-input-number v-model="tokenForm.total_quota" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP限制">
|
||||
<el-input v-model="tokenForm.ip_restriction" placeholder="多个IP用逗号分隔,留空表示无限制" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveToken">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { tokensApi } from '@/api/tokens'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, DocumentCopy, Delete, Search, View } from '@element-plus/icons-vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tokens = ref([])
|
||||
const selectedTokens = ref([])
|
||||
const showAddDialog = ref(false)
|
||||
const editingToken = ref(null)
|
||||
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
key: ''
|
||||
})
|
||||
|
||||
const tokenForm = reactive({
|
||||
name: '',
|
||||
group: 'Claude Code 官方编程模型',
|
||||
remaining_quota: null,
|
||||
total_quota: null,
|
||||
available_models: null,
|
||||
ip_restriction: ''
|
||||
})
|
||||
|
||||
const loadTokens = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await tokensApi.getTokens(searchForm)
|
||||
tokens.value = response.tokens
|
||||
} catch (error) {
|
||||
ElMessage.error('加载令牌列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.key = ''
|
||||
loadTokens()
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedTokens.value = selection
|
||||
}
|
||||
|
||||
const handleCopySelected = () => {
|
||||
if (selectedTokens.value.length === 0) {
|
||||
ElMessage.warning('请先选择要复制的令牌')
|
||||
return
|
||||
}
|
||||
// 实现复制逻辑
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedTokens.value.length === 0) {
|
||||
ElMessage.warning('请先选择要删除的令牌')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除选中的令牌吗?', '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const ids = selectedTokens.value.map(t => t.id)
|
||||
await tokensApi.batchOperation({
|
||||
token_ids: ids,
|
||||
operation: 'delete'
|
||||
})
|
||||
|
||||
ElMessage.success('删除成功')
|
||||
loadTokens()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const copyKey = (key) => {
|
||||
navigator.clipboard.writeText(key)
|
||||
ElMessage.success('已复制密钥')
|
||||
}
|
||||
|
||||
const toggleKeyVisibility = (row) => {
|
||||
// 实现密钥显示/隐藏切换
|
||||
}
|
||||
|
||||
const toggleTokenStatus = async (row) => {
|
||||
try {
|
||||
await tokensApi.batchOperation({
|
||||
token_ids: [row.id],
|
||||
operation: row.status === 'enabled' ? 'disable' : 'enable'
|
||||
})
|
||||
ElMessage.success('操作成功')
|
||||
loadTokens()
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const editToken = (row) => {
|
||||
editingToken.value = row
|
||||
Object.assign(tokenForm, {
|
||||
name: row.name,
|
||||
group: row.group,
|
||||
remaining_quota: row.remaining_quota,
|
||||
total_quota: row.total_quota,
|
||||
ip_restriction: row.ip_restriction || ''
|
||||
})
|
||||
showAddDialog.value = true
|
||||
}
|
||||
|
||||
const deleteToken = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除此令牌吗?', '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await tokensApi.deleteToken(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadTokens()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveToken = async () => {
|
||||
try {
|
||||
if (editingToken.value) {
|
||||
await tokensApi.updateToken(editingToken.value.id, tokenForm)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await tokensApi.createToken(tokenForm)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
showAddDialog.value = false
|
||||
editingToken.value = null
|
||||
Object.assign(tokenForm, {
|
||||
name: '',
|
||||
group: 'Claude Code 官方编程模型',
|
||||
remaining_quota: null,
|
||||
total_quota: null,
|
||||
ip_restriction: ''
|
||||
})
|
||||
loadTokens()
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTokens()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tokens-page {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(91, 140, 255, 0.06);
|
||||
border: 1px dashed rgba(91, 140, 255, 0.25);
|
||||
}
|
||||
|
||||
.key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.key-icon {
|
||||
cursor: pointer;
|
||||
color: var(--brand-1);
|
||||
}
|
||||
|
||||
.tokens-page :deep(.el-table) {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tokens-page :deep(.el-table th.el-table__cell) {
|
||||
background: rgba(91, 140, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tokens-page :deep(.el-table__cell) {
|
||||
border-color: rgba(230, 235, 245, 0.8);
|
||||
}
|
||||
</style>
|
||||
24
frontend/vite.config.js
Normal file
24
frontend/vite.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // 允许所有IP访问
|
||||
port: 5173,
|
||||
strictPort: false, // 如果端口被占用,自动尝试下一个可用端口
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user