This commit is contained in:
27942
2026-01-22 18:26:47 +08:00
commit 8e1b750300
54 changed files with 8666 additions and 0 deletions

47
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,18 @@
cd backend
python app.py
cd frontend
npm install
npm run dev

389
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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'}")

View 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

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

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

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

View File

@@ -0,0 +1 @@
# Routes package

109
backend/routes/auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View 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
View 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
View 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')
}
}

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

@@ -0,0 +1,7 @@
import api from './index'
export const logsApi = {
getUsageLogs(params) {
return api.get('/logs/usage', { params })
}
}

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

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

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

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

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

View 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

View 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
View 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
View 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>平均响应延迟&lt;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>

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

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

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

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

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

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

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

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

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

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