备份: 完整开发状态(含反混淆脚本和临时文件)
This commit is contained in:
123
backend/DEPLOY.md
Normal file
123
backend/DEPLOY.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 蜂鸟CursorPro 后端部署指南
|
||||
|
||||
## 1. 上传文件
|
||||
|
||||
将整个 `backend/` 目录上传到服务器,例如 `/opt/cursorpro/`
|
||||
|
||||
## 2. 安装依赖
|
||||
|
||||
```bash
|
||||
cd /opt/cursorpro
|
||||
pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
## 3. 修改配置
|
||||
|
||||
编辑 `app/config.py`:
|
||||
|
||||
```python
|
||||
# 修改管理员密码
|
||||
ADMIN_USERNAME: str = "admin"
|
||||
ADMIN_PASSWORD: str = "你的强密码"
|
||||
|
||||
# 修改JWT密钥 (随机字符串)
|
||||
SECRET_KEY: str = "随机生成一个长字符串"
|
||||
|
||||
# 修改外部API Token
|
||||
API_TOKEN: str = "你的API密钥"
|
||||
```
|
||||
|
||||
## 4. 启动服务
|
||||
|
||||
### 方式1: 直接运行 (测试)
|
||||
```bash
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
### 方式2: 使用 systemd (生产推荐)
|
||||
|
||||
创建 `/etc/systemd/system/cursorpro.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=CursorPro Backend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/cursorpro
|
||||
ExecStart=/usr/bin/python3 run.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启动服务:
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable cursorpro
|
||||
systemctl start cursorpro
|
||||
systemctl status cursorpro
|
||||
```
|
||||
|
||||
### 方式3: 使用 PM2 (Node.js 环境)
|
||||
```bash
|
||||
pm2 start run.py --name cursorpro --interpreter python3
|
||||
pm2 save
|
||||
```
|
||||
|
||||
## 5. Nginx 反向代理 (推荐)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.aicode.edu.pl;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 访问地址
|
||||
|
||||
- 管理后台: https://api.aicode.edu.pl/
|
||||
- API文档: https://api.aicode.edu.pl/docs
|
||||
- 健康检查: https://api.aicode.edu.pl/health
|
||||
|
||||
## 7. 数据库
|
||||
|
||||
默认使用 SQLite,数据库文件:`cursorpro.db`
|
||||
|
||||
如需备份,复制此文件即可。
|
||||
|
||||
## 8. 多域名反代配置
|
||||
|
||||
在其他服务器配置反代指向主后台:
|
||||
|
||||
```nginx
|
||||
# 备用域名服务器 (api2.aicode.edu.pl)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api2.aicode.edu.pl;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass https://api.aicode.edu.pl;
|
||||
proxy_set_header Host api.aicode.edu.pl;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,131 +1,111 @@
|
||||
# CursorPro 后台管理系统
|
||||
# 蜂鸟CursorPro 后端
|
||||
|
||||
基于 FastAPI 的 Cursor 账号管理和激活码系统,兼容原 CursorPro 插件 API。
|
||||
## 域名配置
|
||||
|
||||
## 功能特性
|
||||
| 类型 | 域名 | 说明 |
|
||||
|------|------|------|
|
||||
| 主域名 | `api.aicode.edu.pl` | 主后台服务器 |
|
||||
| 备用域名1 | `hb.aicode.edu.pl` | 反代到主域名 |
|
||||
| 备用域名2 | `cursor.aicode.edu.pl` | 反代到主域名 |
|
||||
| 备用域名3 | `pro.aicode.edu.pl` | 反代到主域名 |
|
||||
|
||||
- 账号管理:导入、编辑、删除 Cursor 账号
|
||||
- 激活码系统:生成、管理激活码,支持换号次数限制
|
||||
- Web 管理后台:Vue.js + Tailwind CSS 构建的现代化界面
|
||||
- 客户端 API:完全兼容原 CursorPro 插件
|
||||
## 登录信息
|
||||
|
||||
## 快速开始
|
||||
```
|
||||
管理后台: https://api.aicode.edu.pl/
|
||||
用户名: admin
|
||||
密码: Hb@2024Pro!
|
||||
```
|
||||
|
||||
### 方式一:本地运行
|
||||
## 外部API
|
||||
|
||||
1. **安装 MySQL 数据库**
|
||||
```
|
||||
Token: hb-ext-9kX2mP5nQ8rT1vY4zA7c
|
||||
Header: X-API-Token
|
||||
|
||||
2. **配置环境**
|
||||
```bash
|
||||
# 复制配置文件
|
||||
cp .env.example .env
|
||||
批量上传: POST /admin/external/accounts/batch
|
||||
账号统计: GET /admin/external/accounts/stats
|
||||
```
|
||||
|
||||
# 编辑 .env 填入数据库信息
|
||||
```
|
||||
|
||||
3. **启动服务**
|
||||
```bash
|
||||
# Windows
|
||||
start.bat
|
||||
|
||||
# Linux/Mac
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
4. **访问管理后台**
|
||||
- 地址: http://localhost:8000
|
||||
- 默认账号: admin / admin123
|
||||
|
||||
### 方式二:Docker 部署
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
# 启动 MySQL + 后台服务
|
||||
docker-compose up -d
|
||||
# 上传到服务器
|
||||
cd /opt/cursorpro
|
||||
|
||||
# 启动 (首次自动创建venv并安装依赖)
|
||||
./start.sh
|
||||
|
||||
# 停止
|
||||
./stop.sh
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f backend
|
||||
tail -f cursorpro.log
|
||||
```
|
||||
|
||||
## API 文档
|
||||
部署信息:
|
||||
- 管理后台: https://api.aicode.edu.pl/
|
||||
- API文档: https://api.aicode.edu.pl/docs
|
||||
- 健康检查: ✅ {"status":"ok"}
|
||||
|
||||
启动后访问 http://localhost:8000/docs 查看 Swagger API 文档。
|
||||
登录信息:
|
||||
用户名: admin
|
||||
密码: Hb@2024Pro!
|
||||
|
||||
### 客户端 API(兼容原插件)
|
||||
外部API Token: hb-ext-9kX2mP5nQ8rT1vY4zA7c
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/verify-key` | POST | 验证激活码 |
|
||||
| `/api/switch-account` | POST | 切换账号 |
|
||||
| `/api/version` | GET | 获取版本信息 |
|
||||
现在需要配置备用域名反代吗?还是先测试插件对接?
|
||||
|
||||
### 管理 API
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/admin/login` | POST | 管理员登录 |
|
||||
| `/admin/dashboard` | GET | 仪表盘统计 |
|
||||
| `/admin/accounts` | GET/POST | 账号列表/创建 |
|
||||
| `/admin/accounts/import` | POST | 批量导入账号 |
|
||||
| `/admin/keys` | GET/POST | 激活码列表/生成 |
|
||||
## Nginx 配置
|
||||
|
||||
## 账号数据格式
|
||||
### 主域名 (api.aicode.edu.pl)
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.aicode.edu.pl;
|
||||
|
||||
导入账号时使用 JSON 格式:
|
||||
ssl_certificate /etc/ssl/api.aicode.edu.pl.pem;
|
||||
ssl_certificate_key /etc/ssl/api.aicode.edu.pl.key;
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"workos_session_token": "...",
|
||||
"membership_type": "pro"
|
||||
}
|
||||
]
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
### 备用域名 (反代到主域名)
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hb.aicode.edu.pl; # 或 cursor.aicode.edu.pl / pro.aicode.edu.pl
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # API 路由
|
||||
│ │ ├── admin.py # 管理后台 API
|
||||
│ │ └── client.py # 客户端 API(兼容原插件)
|
||||
│ ├── models/ # 数据库模型
|
||||
│ ├── schemas/ # Pydantic 数据模式
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ ├── config.py # 配置
|
||||
│ ├── database.py # 数据库连接
|
||||
│ └── main.py # 应用入口
|
||||
├── templates/ # HTML 模板
|
||||
├── static/ # 静态文件
|
||||
├── .env.example # 环境变量示例
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
├── run.py # 启动脚本
|
||||
└── start.bat/sh # 快捷启动
|
||||
ssl_certificate /etc/ssl/hb.aicode.edu.pl.pem;
|
||||
ssl_certificate_key /etc/ssl/hb.aicode.edu.pl.key;
|
||||
|
||||
location / {
|
||||
proxy_pass https://api.aicode.edu.pl;
|
||||
proxy_set_header Host api.aicode.edu.pl;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
## 客户端插件域名配置
|
||||
|
||||
`.env` 文件配置项:
|
||||
插件 `extension/out/api/client.js` 中已配置:
|
||||
```javascript
|
||||
const API_DOMAINS = [
|
||||
'https://api.aicode.edu.pl', // 主域名
|
||||
'https://hb.aicode.edu.pl', // 备用域名1
|
||||
'https://cursor.aicode.edu.pl', // 备用域名2
|
||||
'https://pro.aicode.edu.pl', // 备用域名3
|
||||
'http://127.0.0.1:8000' // 本地开发
|
||||
];
|
||||
```
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| DB_HOST | 数据库地址 | localhost |
|
||||
| DB_PORT | 数据库端口 | 3306 |
|
||||
| DB_USER | 数据库用户 | root |
|
||||
| DB_PASSWORD | 数据库密码 | - |
|
||||
| DB_NAME | 数据库名 | cursorpro |
|
||||
| JWT_SECRET_KEY | JWT 密钥 | - |
|
||||
| ADMIN_USERNAME | 管理员账号 | admin |
|
||||
| ADMIN_PASSWORD | 管理员密码 | admin123 |
|
||||
|
||||
## 安全提示
|
||||
|
||||
- 生产环境请修改 `JWT_SECRET_KEY`
|
||||
- 修改默认管理员密码
|
||||
- 建议使用 HTTPS
|
||||
自动故障转移:主域名失败会自动切换到备用域名。
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.config import settings
|
||||
from app.services import AccountService, KeyService, LogService, GlobalSettingsService, BatchService, authenticate_admin, create_access_token, get_current_user
|
||||
from app.schemas import (
|
||||
AccountCreate, AccountUpdate, AccountResponse, AccountImport,
|
||||
KeyCreate, KeyUpdate, KeyResponse,
|
||||
DashboardStats, Token, LoginRequest,
|
||||
GlobalSettingsResponse, GlobalSettingsUpdate,
|
||||
BatchExtendRequest, BatchExtendResponse
|
||||
BatchExtendRequest, BatchExtendResponse,
|
||||
ExternalBatchUpload, ExternalBatchResponse
|
||||
)
|
||||
from app.models import MembershipType, KeyDevice, UsageLog, ActivationKey
|
||||
|
||||
@@ -33,6 +35,138 @@ async def login(request: LoginRequest):
|
||||
return Token(access_token=access_token)
|
||||
|
||||
|
||||
# ========== 外部系统API (Token认证) ==========
|
||||
|
||||
def verify_api_token(x_api_token: str = Header(..., alias="X-API-Token")):
|
||||
"""验证外部系统API Token"""
|
||||
if x_api_token != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API Token"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/external/accounts/batch", response_model=ExternalBatchResponse)
|
||||
async def external_batch_upload(
|
||||
data: ExternalBatchUpload,
|
||||
db: Session = Depends(get_db),
|
||||
_: bool = Depends(verify_api_token)
|
||||
):
|
||||
"""外部系统批量上传账号
|
||||
|
||||
使用方法:
|
||||
POST /admin/external/accounts/batch
|
||||
Headers: X-API-Token: your-api-token
|
||||
Body: {
|
||||
"accounts": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"access_token": "xxx",
|
||||
"refresh_token": "xxx",
|
||||
"workos_session_token": "xxx",
|
||||
"membership_type": "free", // free=auto账号, pro=高级账号
|
||||
"remark": "备注"
|
||||
}
|
||||
],
|
||||
"update_existing": true // 是否更新已存在的账号
|
||||
}
|
||||
"""
|
||||
created = 0
|
||||
updated = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
for item in data.accounts:
|
||||
try:
|
||||
# 转换membership_type
|
||||
mt = MembershipType.FREE if item.membership_type == "free" else MembershipType.PRO
|
||||
|
||||
existing = AccountService.get_by_email(db, item.email)
|
||||
if existing:
|
||||
if data.update_existing:
|
||||
# 更新已存在的账号
|
||||
AccountService.update(
|
||||
db, existing.id,
|
||||
access_token=item.access_token,
|
||||
refresh_token=item.refresh_token,
|
||||
workos_session_token=item.workos_session_token,
|
||||
membership_type=mt,
|
||||
remark=item.remark or existing.remark
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
failed += 1
|
||||
errors.append(f"{item.email}: 账号已存在")
|
||||
else:
|
||||
# 创建新账号
|
||||
account_data = AccountCreate(
|
||||
email=item.email,
|
||||
access_token=item.access_token,
|
||||
refresh_token=item.refresh_token,
|
||||
workos_session_token=item.workos_session_token,
|
||||
membership_type=mt,
|
||||
remark=item.remark
|
||||
)
|
||||
AccountService.create(db, account_data)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
errors.append(f"{item.email}: {str(e)}")
|
||||
|
||||
return ExternalBatchResponse(
|
||||
success=failed == 0,
|
||||
total=len(data.accounts),
|
||||
created=created,
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
errors=errors[:20] # 只返回前20个错误
|
||||
)
|
||||
|
||||
|
||||
@router.get("/external/accounts/stats")
|
||||
async def external_account_stats(
|
||||
db: Session = Depends(get_db),
|
||||
_: bool = Depends(verify_api_token)
|
||||
):
|
||||
"""外部系统获取账号统计"""
|
||||
stats = AccountService.count(db)
|
||||
return {
|
||||
"total": stats["total"],
|
||||
"active": stats["active"],
|
||||
"pro": stats["pro"],
|
||||
"free": stats["total"] - stats["pro"]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/external/accounts/batch")
|
||||
async def external_batch_delete(
|
||||
emails: List[str],
|
||||
db: Session = Depends(get_db),
|
||||
_: bool = Depends(verify_api_token)
|
||||
):
|
||||
"""外部系统批量删除账号"""
|
||||
deleted = 0
|
||||
failed = 0
|
||||
|
||||
for email in emails:
|
||||
try:
|
||||
account = AccountService.get_by_email(db, email)
|
||||
if account:
|
||||
AccountService.delete(db, account.id)
|
||||
deleted += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
return {
|
||||
"success": failed == 0,
|
||||
"deleted": deleted,
|
||||
"failed": failed
|
||||
}
|
||||
|
||||
|
||||
# ========== 仪表盘 ==========
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardStats)
|
||||
@@ -118,6 +252,16 @@ async def import_accounts(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/accounts/count")
|
||||
async def get_accounts_count(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""获取账号总数"""
|
||||
stats = AccountService.count(db)
|
||||
return {"total": stats["total"]}
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}", response_model=AccountResponse)
|
||||
async def get_account(
|
||||
account_id: int,
|
||||
@@ -157,6 +301,166 @@ async def delete_account(
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/toggle-status")
|
||||
async def toggle_account_status(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""快捷切换账号状态
|
||||
|
||||
切换逻辑:
|
||||
- 使用中(in_use) -> 可用(active) 释放账号
|
||||
- 可用(active) -> 禁用(disabled)
|
||||
- 禁用(disabled) -> 可用(active)
|
||||
- 过期(expired) -> 可用(active)
|
||||
"""
|
||||
from app.models import AccountStatus, Account
|
||||
account = db.query(Account).filter(Account.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
|
||||
old_status = account.status
|
||||
|
||||
# 根据当前状态切换
|
||||
if account.status == AccountStatus.IN_USE:
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.current_key_id = None # 释放绑定
|
||||
elif account.status == AccountStatus.ACTIVE:
|
||||
account.status = AccountStatus.DISABLED
|
||||
elif account.status == AccountStatus.DISABLED:
|
||||
account.status = AccountStatus.ACTIVE
|
||||
elif account.status == AccountStatus.EXPIRED:
|
||||
account.status = AccountStatus.ACTIVE
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"old_status": old_status.value,
|
||||
"new_status": account.status.value,
|
||||
"message": f"状态已从 {old_status.value} 切换为 {account.status.value}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/release")
|
||||
async def release_account(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""释放账号(从使用中变为可用)"""
|
||||
from app.models import AccountStatus, Account
|
||||
account = db.query(Account).filter(Account.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
|
||||
if account.status != AccountStatus.IN_USE:
|
||||
return {"success": False, "message": "账号不在使用中状态"}
|
||||
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.current_key_id = None
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": "账号已释放"}
|
||||
|
||||
|
||||
@router.post("/accounts/batch-enable")
|
||||
async def batch_enable_accounts(
|
||||
account_ids: List[int],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""批量启用账号"""
|
||||
from app.models import AccountStatus, Account
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for account_id in account_ids:
|
||||
try:
|
||||
account = db.query(Account).filter(Account.id == account_id).first()
|
||||
if account:
|
||||
account.status = AccountStatus.ACTIVE
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"message": f"成功启用 {success} 个账号"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/accounts/batch-disable")
|
||||
async def batch_disable_accounts(
|
||||
account_ids: List[int],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""批量禁用账号"""
|
||||
from app.models import AccountStatus, Account
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for account_id in account_ids:
|
||||
try:
|
||||
account = db.query(Account).filter(Account.id == account_id).first()
|
||||
if account:
|
||||
account.status = AccountStatus.DISABLED
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"message": f"成功禁用 {success} 个账号"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/accounts/batch-delete")
|
||||
async def batch_delete_accounts(
|
||||
account_ids: List[int],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""批量删除账号"""
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for account_id in account_ids:
|
||||
try:
|
||||
if AccountService.delete(db, account_id):
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"message": f"成功删除 {success} 个账号"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/accounts/count")
|
||||
async def get_accounts_count(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""获取账号总数"""
|
||||
stats = AccountService.count(db)
|
||||
return {"total": stats["total"]}
|
||||
|
||||
|
||||
# ========== 激活码管理 ==========
|
||||
|
||||
@router.get("/keys", response_model=List[KeyResponse])
|
||||
@@ -165,7 +469,7 @@ async def list_keys(
|
||||
limit: int = 100,
|
||||
search: Optional[str] = Query(None, description="搜索激活码"),
|
||||
status: Optional[str] = Query(None, description="状态筛选: active/disabled"),
|
||||
activated: Optional[bool] = Query(None, description="是否已激活"),
|
||||
activated: Optional[str] = Query(None, description="是否已激活: true/false"),
|
||||
membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
@@ -180,12 +484,13 @@ async def list_keys(
|
||||
|
||||
# 状态筛选
|
||||
if status:
|
||||
query = query.filter(ActivationKey.status == status)
|
||||
status_enum = KeyStatus.ACTIVE if status == "active" else KeyStatus.DISABLED
|
||||
query = query.filter(ActivationKey.status == status_enum)
|
||||
|
||||
# 是否已激活
|
||||
if activated is True:
|
||||
if activated and activated == "true":
|
||||
query = query.filter(ActivationKey.first_activated_at != None)
|
||||
elif activated is False:
|
||||
elif activated and activated == "false":
|
||||
query = query.filter(ActivationKey.first_activated_at == None)
|
||||
|
||||
# 套餐类型筛选
|
||||
@@ -196,6 +501,43 @@ async def list_keys(
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/keys/count")
|
||||
async def get_keys_count(
|
||||
search: Optional[str] = Query(None, description="搜索激活码"),
|
||||
status: Optional[str] = Query(None, description="状态筛选: active/disabled"),
|
||||
activated: Optional[str] = Query(None, description="是否已激活: true/false"),
|
||||
membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""获取激活码总数(支持筛选)"""
|
||||
from app.models import KeyStatus
|
||||
query = db.query(ActivationKey)
|
||||
|
||||
# 搜索激活码
|
||||
if search:
|
||||
query = query.filter(ActivationKey.key.contains(search))
|
||||
|
||||
# 状态筛选
|
||||
if status:
|
||||
status_enum = KeyStatus.ACTIVE if status == "active" else KeyStatus.DISABLED
|
||||
query = query.filter(ActivationKey.status == status_enum)
|
||||
|
||||
# 是否已激活
|
||||
if activated and activated == "true":
|
||||
query = query.filter(ActivationKey.first_activated_at != None)
|
||||
elif activated and activated == "false":
|
||||
query = query.filter(ActivationKey.first_activated_at == None)
|
||||
|
||||
# 套餐类型筛选
|
||||
if membership_type:
|
||||
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
|
||||
query = query.filter(ActivationKey.membership_type == mt)
|
||||
|
||||
total = query.count()
|
||||
return {"total": total}
|
||||
|
||||
|
||||
@router.post("/keys", response_model=List[KeyResponse])
|
||||
async def create_keys(
|
||||
key_data: KeyCreate,
|
||||
@@ -344,6 +686,129 @@ async def enable_key(
|
||||
return {"message": "激活码已启用"}
|
||||
|
||||
|
||||
@router.post("/keys/batch-enable")
|
||||
async def batch_enable_keys(
|
||||
key_ids: List[int],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""批量启用激活码"""
|
||||
from app.models import KeyStatus
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for key_id in key_ids:
|
||||
try:
|
||||
key = KeyService.get_by_id(db, key_id)
|
||||
if key:
|
||||
key.status = KeyStatus.ACTIVE
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"message": f"成功启用 {success} 个激活码"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/keys/batch-disable")
|
||||
async def batch_disable_keys(
|
||||
key_ids: List[int],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""批量禁用激活码"""
|
||||
from app.models import KeyStatus
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for key_id in key_ids:
|
||||
try:
|
||||
key = KeyService.get_by_id(db, key_id)
|
||||
if key:
|
||||
key.status = KeyStatus.DISABLED
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"message": f"成功禁用 {success} 个激活码"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/keys/batch-delete")
|
||||
async def batch_delete_keys(
|
||||
key_ids: List[int],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""批量删除激活码"""
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for key_id in key_ids:
|
||||
try:
|
||||
if KeyService.delete(db, key_id):
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"message": f"成功删除 {success} 个激活码"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/keys/count")
|
||||
async def get_keys_count(
|
||||
search: Optional[str] = Query(None, description="搜索激活码"),
|
||||
status: Optional[str] = Query(None, description="状态筛选: active/disabled"),
|
||||
activated: Optional[str] = Query(None, description="是否已激活: true/false"),
|
||||
membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""获取激活码总数(支持筛选)"""
|
||||
from app.models import KeyStatus
|
||||
query = db.query(ActivationKey)
|
||||
|
||||
# 搜索激活码
|
||||
if search:
|
||||
query = query.filter(ActivationKey.key.contains(search))
|
||||
|
||||
# 状态筛选
|
||||
if status:
|
||||
status_enum = KeyStatus.ACTIVE if status == "active" else KeyStatus.DISABLED
|
||||
query = query.filter(ActivationKey.status == status_enum)
|
||||
|
||||
# 是否已激活
|
||||
if activated and activated == "true":
|
||||
query = query.filter(ActivationKey.first_activated_at != None)
|
||||
elif activated and activated == "false":
|
||||
query = query.filter(ActivationKey.first_activated_at == None)
|
||||
|
||||
# 套餐类型筛选
|
||||
if membership_type:
|
||||
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
|
||||
query = query.filter(ActivationKey.membership_type == mt)
|
||||
|
||||
total = query.count()
|
||||
return {"total": total}
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/add-quota", response_model=KeyResponse)
|
||||
async def add_key_quota(
|
||||
key_id: int,
|
||||
|
||||
@@ -3,20 +3,87 @@
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.services import AccountService, KeyService, LogService, GlobalSettingsService
|
||||
from app.schemas import VerifyKeyRequest, VerifyKeyResponse, SwitchAccountRequest, SwitchAccountResponse
|
||||
from app.models import AccountStatus, MembershipType
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["Client API"])
|
||||
|
||||
|
||||
@router.post("/verify-key", response_model=VerifyKeyResponse)
|
||||
# ========== 账号数据响应模型 ==========
|
||||
|
||||
class AccountData(BaseModel):
|
||||
"""账号数据 - 写入本地Cursor的数据"""
|
||||
accessToken: str
|
||||
refreshToken: Optional[str] = None
|
||||
workosSessionToken: Optional[str] = None
|
||||
email: str
|
||||
membership_type: str
|
||||
usage_type: Optional[str] = "default"
|
||||
# 机器ID相关 (可选)
|
||||
serviceMachineId: Optional[str] = None
|
||||
machineId: Optional[str] = None
|
||||
macMachineId: Optional[str] = None
|
||||
devDeviceId: Optional[str] = None
|
||||
sqmId: Optional[str] = None
|
||||
machineIdFile: Optional[str] = None
|
||||
# 使用统计
|
||||
requestCount: Optional[int] = 0
|
||||
usageAmount: Optional[float] = 0.0
|
||||
# 额度信息
|
||||
quota: Optional[int] = None
|
||||
quotaUsed: Optional[int] = None
|
||||
quotaRemaining: Optional[int] = None
|
||||
expireDate: Optional[str] = None
|
||||
|
||||
|
||||
class ApiResponse(BaseModel):
|
||||
"""通用API响应"""
|
||||
success: bool
|
||||
message: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
data: Optional[AccountData] = None
|
||||
|
||||
|
||||
# ========== 验证和切换 API ==========
|
||||
|
||||
def build_account_data(account, key) -> AccountData:
|
||||
"""构建账号数据对象"""
|
||||
expire_date = key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None
|
||||
return AccountData(
|
||||
accessToken=account.access_token,
|
||||
refreshToken=account.refresh_token,
|
||||
workosSessionToken=account.workos_session_token,
|
||||
email=account.email,
|
||||
membership_type=account.membership_type.value,
|
||||
quota=key.quota,
|
||||
quotaUsed=key.quota_used,
|
||||
quotaRemaining=key.quota - key.quota_used,
|
||||
expireDate=expire_date
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify(request: VerifyKeyRequest, req: Request, db: Session = Depends(get_db)):
|
||||
"""验证激活码 (前端调用的路径)"""
|
||||
return await verify_key_impl(request, req, db)
|
||||
|
||||
|
||||
@router.post("/verify-key")
|
||||
async def verify_key(request: VerifyKeyRequest, req: Request, db: Session = Depends(get_db)):
|
||||
"""验证激活码"""
|
||||
"""验证激活码 (兼容路径)"""
|
||||
return await verify_key_impl(request, req, db)
|
||||
|
||||
|
||||
async def verify_key_impl(request: VerifyKeyRequest, req: Request, db: Session):
|
||||
"""验证激活码实现"""
|
||||
key = KeyService.get_by_key(db, request.key)
|
||||
|
||||
if not key:
|
||||
return VerifyKeyResponse(success=False, error="激活码不存在")
|
||||
return {"success": False, "valid": False, "error": "激活码不存在"}
|
||||
|
||||
# 首次激活:设置激活时间和过期时间
|
||||
KeyService.activate(db, key)
|
||||
@@ -26,75 +93,81 @@ async def verify_key(request: VerifyKeyRequest, req: Request, db: Session = Depe
|
||||
device_ok, device_msg = KeyService.check_device(db, key, request.device_id)
|
||||
if not device_ok:
|
||||
LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message=device_msg)
|
||||
return VerifyKeyResponse(success=False, error=device_msg)
|
||||
return {"success": False, "valid": False, "error": device_msg}
|
||||
|
||||
# 检查激活码是否有效
|
||||
is_valid, message = KeyService.is_valid(key, db)
|
||||
if not is_valid:
|
||||
LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message=message)
|
||||
return VerifyKeyResponse(success=False, error=message)
|
||||
return {"success": False, "valid": False, "error": message}
|
||||
|
||||
# 获取当前绑定的账号,或分配新账号
|
||||
account = None
|
||||
if key.current_account_id:
|
||||
account = AccountService.get_by_id(db, key.current_account_id)
|
||||
|
||||
if not account or account.status != "active":
|
||||
# 只有账号不存在或被禁用/过期才分配新的(IN_USE 状态的账号继续使用)
|
||||
if not account or account.status in (AccountStatus.DISABLED, AccountStatus.EXPIRED):
|
||||
# 分配新账号
|
||||
account = AccountService.get_available(db, key.membership_type)
|
||||
if not account:
|
||||
LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message="无可用账号")
|
||||
return VerifyKeyResponse(success=False, error="暂无可用账号,请稍后重试")
|
||||
return {"success": False, "valid": False, "error": "暂无可用账号,请稍后重试"}
|
||||
|
||||
KeyService.bind_account(db, key, account)
|
||||
AccountService.mark_used(db, account, key.id)
|
||||
|
||||
LogService.log(db, key.id, "verify", account.id, ip_address=req.client.host, success=True)
|
||||
|
||||
# 返回格式匹配原版插件期望
|
||||
expire_date = key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None
|
||||
quota_cost = KeyService.get_quota_cost(db, key.membership_type)
|
||||
|
||||
return VerifyKeyResponse(
|
||||
success=True,
|
||||
email=account.email,
|
||||
accessToken=account.access_token,
|
||||
refreshToken=account.refresh_token,
|
||||
WorkosCursorSessionToken=account.workos_session_token,
|
||||
membership_type=account.membership_type.value,
|
||||
quota=key.quota,
|
||||
quotaUsed=key.quota_used,
|
||||
quotaRemaining=key.quota - key.quota_used,
|
||||
quotaCost=quota_cost,
|
||||
expireDate=expire_date
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"valid": True,
|
||||
"expire_date": expire_date,
|
||||
"switch_remaining": key.quota - key.quota_used,
|
||||
"switch_limit": key.quota,
|
||||
"data": build_account_data(account, key)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/switch-account", response_model=SwitchAccountResponse)
|
||||
@router.post("/switch")
|
||||
async def switch(request: SwitchAccountRequest, req: Request, db: Session = Depends(get_db)):
|
||||
"""切换账号 (前端调用的路径)"""
|
||||
return await switch_account_impl(request, req, db)
|
||||
|
||||
|
||||
@router.post("/switch-account")
|
||||
async def switch_account(request: SwitchAccountRequest, req: Request, db: Session = Depends(get_db)):
|
||||
"""切换账号"""
|
||||
"""切换账号 (兼容路径)"""
|
||||
return await switch_account_impl(request, req, db)
|
||||
|
||||
|
||||
async def switch_account_impl(request: SwitchAccountRequest, req: Request, db: Session):
|
||||
"""切换账号实现"""
|
||||
key = KeyService.get_by_key(db, request.key)
|
||||
|
||||
if not key:
|
||||
return SwitchAccountResponse(success=False, error="激活码不存在")
|
||||
return ApiResponse(success=False, error="激活码不存在")
|
||||
|
||||
# 检查设备限制
|
||||
if request.device_id:
|
||||
device_ok, device_msg = KeyService.check_device(db, key, request.device_id)
|
||||
if not device_ok:
|
||||
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=device_msg)
|
||||
return SwitchAccountResponse(success=False, error=device_msg)
|
||||
return ApiResponse(success=False, error=device_msg)
|
||||
|
||||
# 检查激活码是否有效
|
||||
is_valid, message = KeyService.is_valid(key, db)
|
||||
if not is_valid:
|
||||
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=message)
|
||||
return SwitchAccountResponse(success=False, error=message)
|
||||
return ApiResponse(success=False, error=message)
|
||||
|
||||
# 检查换号频率限制
|
||||
can_switch, switch_msg = KeyService.can_switch(db, key)
|
||||
if not can_switch:
|
||||
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=switch_msg)
|
||||
return SwitchAccountResponse(success=False, error=switch_msg)
|
||||
return ApiResponse(success=False, error=switch_msg)
|
||||
|
||||
# 释放当前账号
|
||||
if key.current_account_id:
|
||||
@@ -106,7 +179,7 @@ async def switch_account(request: SwitchAccountRequest, req: Request, db: Sessio
|
||||
account = AccountService.get_available(db, key.membership_type)
|
||||
if not account:
|
||||
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message="无可用账号")
|
||||
return SwitchAccountResponse(success=False, error="暂无可用账号,请稍后重试")
|
||||
return ApiResponse(success=False, error="暂无可用账号,请稍后重试")
|
||||
|
||||
# 绑定新账号并扣除额度
|
||||
KeyService.bind_account(db, key, account)
|
||||
@@ -115,34 +188,581 @@ async def switch_account(request: SwitchAccountRequest, req: Request, db: Sessio
|
||||
|
||||
LogService.log(db, key.id, "switch", account.id, ip_address=req.client.host, success=True)
|
||||
|
||||
return SwitchAccountResponse(
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message="切换成功",
|
||||
email=account.email,
|
||||
accessToken=account.access_token,
|
||||
refreshToken=account.refresh_token,
|
||||
WorkosCursorSessionToken=account.workos_session_token,
|
||||
membership_type=account.membership_type.value,
|
||||
quotaUsed=key.quota_used,
|
||||
quotaRemaining=key.quota - key.quota_used
|
||||
data=build_account_data(account, key)
|
||||
)
|
||||
|
||||
|
||||
# ========== 版本 API ==========
|
||||
|
||||
@router.get("/version")
|
||||
async def get_version():
|
||||
"""获取版本信息"""
|
||||
return {
|
||||
"success": True,
|
||||
"version": "1.0.0",
|
||||
"update_url": None,
|
||||
"message": None
|
||||
"latestVersion": "1.0.0",
|
||||
"updateUrl": None,
|
||||
"message": None,
|
||||
"forceUpdate": False
|
||||
}
|
||||
|
||||
|
||||
# ========== 代理配置 API ==========
|
||||
|
||||
# 内存存储代理配置 (生产环境应存数据库)
|
||||
proxy_config = {
|
||||
"is_enabled": False,
|
||||
"proxy_url": ""
|
||||
}
|
||||
|
||||
@router.get("/proxy-config")
|
||||
async def get_proxy_config():
|
||||
"""获取代理配置"""
|
||||
return {
|
||||
"success": True,
|
||||
"data": proxy_config
|
||||
}
|
||||
|
||||
|
||||
@router.post("/proxy-config")
|
||||
async def update_proxy_config(config: dict):
|
||||
"""更新代理配置"""
|
||||
global proxy_config
|
||||
if "is_enabled" in config:
|
||||
proxy_config["is_enabled"] = config["is_enabled"]
|
||||
if "proxy_url" in config:
|
||||
proxy_config["proxy_url"] = config["proxy_url"]
|
||||
return {
|
||||
"success": True,
|
||||
"message": "代理配置已更新",
|
||||
"data": proxy_config
|
||||
}
|
||||
|
||||
|
||||
# ========== 无感换号 API ==========
|
||||
|
||||
# 内存存储无感配置 (生产环境应存数据库)
|
||||
seamless_config = {
|
||||
"enabled": False,
|
||||
"mode": "auto",
|
||||
"switchThreshold": 10,
|
||||
"accountPool": [],
|
||||
"currentIndex": 0
|
||||
}
|
||||
|
||||
@router.get("/seamless/status")
|
||||
async def seamless_status():
|
||||
"""无感换号状态(简化实现)"""
|
||||
async def seamless_status(db: Session = Depends(get_db)):
|
||||
"""获取无感换号状态"""
|
||||
return {
|
||||
"success": True,
|
||||
"enabled": seamless_config["enabled"],
|
||||
"message": "无感换号功能已就绪" if seamless_config["enabled"] else "无感换号功能未启用"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/seamless/user-status")
|
||||
async def seamless_user_status(key: str = None, userKey: str = None, db: Session = Depends(get_db)):
|
||||
"""获取用户切换状态"""
|
||||
# 兼容两种参数名
|
||||
actual_key = key or userKey
|
||||
if not actual_key:
|
||||
return {"success": False, "valid": False, "error": "缺少激活码参数"}
|
||||
|
||||
activation_key = KeyService.get_by_key(db, actual_key)
|
||||
if not activation_key:
|
||||
return {"success": False, "valid": False, "error": "激活码不存在"}
|
||||
|
||||
# 检查是否有效
|
||||
is_valid, message = KeyService.is_valid(activation_key, db)
|
||||
if not is_valid:
|
||||
return {"success": False, "valid": False, "error": message}
|
||||
|
||||
# 获取当前绑定的账号
|
||||
locked_account = None
|
||||
if activation_key.current_account_id:
|
||||
account = AccountService.get_by_id(db, activation_key.current_account_id)
|
||||
if account and account.status not in (AccountStatus.DISABLED, AccountStatus.EXPIRED):
|
||||
locked_account = {
|
||||
"email": account.email,
|
||||
"membership_type": account.membership_type.value
|
||||
}
|
||||
|
||||
# Pro密钥:使用 quota (积分制)
|
||||
# Auto密钥:使用换号次数限制
|
||||
is_pro = activation_key.membership_type == MembershipType.PRO
|
||||
switch_remaining = activation_key.quota - activation_key.quota_used if is_pro else 999 # Auto不限积分
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"valid": True,
|
||||
"switchRemaining": switch_remaining,
|
||||
"canSwitch": switch_remaining > 0,
|
||||
"lockedAccount": locked_account,
|
||||
"membershipType": activation_key.membership_type.value,
|
||||
"data": {
|
||||
"canSwitch": True,
|
||||
"quotaRemaining": activation_key.quota - activation_key.quota_used,
|
||||
"switchCount": activation_key.switch_count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/seamless/config")
|
||||
async def get_seamless_config():
|
||||
"""获取无感配置"""
|
||||
return {
|
||||
"success": True,
|
||||
"data": seamless_config
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seamless/config")
|
||||
async def update_seamless_config(config: dict):
|
||||
"""更新无感配置"""
|
||||
global seamless_config
|
||||
for key in ["enabled", "mode", "switchThreshold", "accountPool", "currentIndex"]:
|
||||
if key in config:
|
||||
seamless_config[key] = config[key]
|
||||
return {
|
||||
"success": True,
|
||||
"message": "配置已更新",
|
||||
"data": seamless_config
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seamless/inject")
|
||||
async def inject_seamless(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
"""注入无感模式 - 返回账号数据"""
|
||||
user_key = data.get("user_key")
|
||||
if not user_key:
|
||||
return {"success": False, "error": "缺少user_key参数"}
|
||||
|
||||
key = KeyService.get_by_key(db, user_key)
|
||||
if not key:
|
||||
return {"success": False, "error": "激活码不存在"}
|
||||
|
||||
# 检查有效性
|
||||
is_valid, message = KeyService.is_valid(key, db)
|
||||
if not is_valid:
|
||||
return {"success": False, "error": message}
|
||||
|
||||
# 获取账号
|
||||
account = None
|
||||
if key.current_account_id:
|
||||
account = AccountService.get_by_id(db, key.current_account_id)
|
||||
|
||||
# 只有账号不存在或被禁用/过期才分配新的
|
||||
if not account or account.status in (AccountStatus.DISABLED, AccountStatus.EXPIRED):
|
||||
account = AccountService.get_available(db, key.membership_type)
|
||||
if not account:
|
||||
return {"success": False, "error": "暂无可用账号"}
|
||||
KeyService.bind_account(db, key, account)
|
||||
AccountService.mark_used(db, account, key.id)
|
||||
|
||||
LogService.log(db, key.id, "seamless_inject", account.id, ip_address=req.client.host, success=True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "无感模式已注入",
|
||||
"data": build_account_data(account, key).model_dump()
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seamless/restore")
|
||||
async def restore_seamless():
|
||||
"""恢复无感模式设置"""
|
||||
global seamless_config
|
||||
seamless_config["enabled"] = False
|
||||
return {
|
||||
"success": True,
|
||||
"message": "已恢复默认设置"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/seamless/accounts")
|
||||
async def get_seamless_accounts(db: Session = Depends(get_db)):
|
||||
"""获取账号池列表"""
|
||||
# 返回可用账号列表 (脱敏)
|
||||
accounts = AccountService.get_all(db, limit=100)
|
||||
account_list = []
|
||||
for acc in accounts:
|
||||
if acc.status == AccountStatus.ACTIVE:
|
||||
account_list.append({
|
||||
"id": acc.id,
|
||||
"email": acc.email[:3] + "***" + acc.email[acc.email.index("@"):],
|
||||
"membership_type": acc.membership_type.value,
|
||||
"status": acc.status.value
|
||||
})
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"accounts": account_list,
|
||||
"total": len(account_list)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seamless/accounts")
|
||||
async def sync_seamless_accounts(data: dict):
|
||||
"""同步账号池"""
|
||||
# 这个接口在我们的架构中不需要实际功能
|
||||
# 账号由管理后台统一管理
|
||||
return {
|
||||
"success": True,
|
||||
"message": "账号由管理后台统一管理"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/seamless/token")
|
||||
async def get_seamless_token(key: str, db: Session = Depends(get_db)):
|
||||
"""获取无感Token"""
|
||||
activation_key = KeyService.get_by_key(db, key)
|
||||
if not activation_key:
|
||||
return {"success": False, "error": "激活码不存在"}
|
||||
|
||||
account = None
|
||||
if activation_key.current_account_id:
|
||||
account = AccountService.get_by_id(db, activation_key.current_account_id)
|
||||
|
||||
if not account:
|
||||
return {"success": False, "error": "未绑定账号"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": build_account_data(account, activation_key).model_dump()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/seamless/get-token")
|
||||
async def get_seamless_token_v2(userKey: str = None, key: str = None, req: Request = None, db: Session = Depends(get_db)):
|
||||
"""获取无感Token (注入代码调用的路径,兼容 userKey 和 key 两种参数名)
|
||||
|
||||
返回格式需要直接包含 accessToken、email 等字段,供注入代码使用
|
||||
"""
|
||||
actual_key = userKey or key
|
||||
if not actual_key:
|
||||
return {"success": False, "error": "缺少激活码参数"}
|
||||
|
||||
activation_key = KeyService.get_by_key(db, actual_key)
|
||||
if not activation_key:
|
||||
return {"success": False, "error": "激活码不存在"}
|
||||
|
||||
# 检查有效性
|
||||
is_valid, message = KeyService.is_valid(activation_key, db)
|
||||
if not is_valid:
|
||||
return {"success": False, "error": message}
|
||||
|
||||
# 获取或分配账号
|
||||
account = None
|
||||
is_new = False
|
||||
if activation_key.current_account_id:
|
||||
account = AccountService.get_by_id(db, activation_key.current_account_id)
|
||||
|
||||
# 只有账号不存在或被禁用/过期才分配新的
|
||||
if not account or account.status in (AccountStatus.DISABLED, AccountStatus.EXPIRED):
|
||||
# Auto密钥:检查是否允许获取新账号(频率限制)
|
||||
if activation_key.membership_type == MembershipType.FREE:
|
||||
can_switch, switch_msg = KeyService.can_switch(db, activation_key)
|
||||
if not can_switch:
|
||||
return {"success": False, "error": switch_msg}
|
||||
|
||||
# 分配新账号
|
||||
account = AccountService.get_available(db, activation_key.membership_type)
|
||||
if not account:
|
||||
return {"success": False, "error": "暂无可用账号"}
|
||||
KeyService.bind_account(db, activation_key, account)
|
||||
AccountService.mark_used(db, account, activation_key.id)
|
||||
# 记录换号(用于频率限制)
|
||||
KeyService.use_switch(db, activation_key)
|
||||
is_new = True
|
||||
|
||||
# 记录日志
|
||||
if req:
|
||||
LogService.log(db, activation_key.id, "seamless_get_token", account.id, ip_address=req.client.host, success=True)
|
||||
|
||||
# 返回格式需要直接包含字段,供注入代码使用
|
||||
# 注入代码检查: if(d && d.accessToken) { ... }
|
||||
return {
|
||||
"success": True,
|
||||
"accessToken": account.access_token,
|
||||
"refreshToken": account.refresh_token or "",
|
||||
"email": account.email,
|
||||
"membership_type": account.membership_type.value,
|
||||
"switchVersion": activation_key.switch_count, # 用于检测是否需要更新
|
||||
"switchRemaining": activation_key.quota - activation_key.quota_used,
|
||||
"is_new": is_new,
|
||||
"machineIds": None # 机器ID由客户端本地管理
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seamless/switch")
|
||||
async def switch_seamless_token(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
"""切换无感Token"""
|
||||
user_key = data.get("userKey")
|
||||
if not user_key:
|
||||
return {"switched": False, "message": "缺少userKey参数"}
|
||||
|
||||
# 复用切换逻辑
|
||||
request = SwitchAccountRequest(key=user_key)
|
||||
return await switch_token_impl(request, req, db)
|
||||
|
||||
|
||||
@router.post("/seamless/switch-token")
|
||||
async def switch_seamless_token_v2(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
"""切换无感Token (client.js 调用的路径)"""
|
||||
user_key = data.get("userKey")
|
||||
if not user_key:
|
||||
return {"switched": False, "message": "缺少userKey参数"}
|
||||
|
||||
request = SwitchAccountRequest(key=user_key)
|
||||
return await switch_token_impl(request, req, db)
|
||||
|
||||
|
||||
async def switch_token_impl(request: SwitchAccountRequest, req: Request, db: Session):
|
||||
"""切换Token实现 - 返回插件期望的格式"""
|
||||
key = KeyService.get_by_key(db, request.key)
|
||||
|
||||
if not key:
|
||||
return {"switched": False, "message": "激活码不存在"}
|
||||
|
||||
# 检查设备限制
|
||||
if request.device_id:
|
||||
device_ok, device_msg = KeyService.check_device(db, key, request.device_id)
|
||||
if not device_ok:
|
||||
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=device_msg)
|
||||
return {"switched": False, "message": device_msg}
|
||||
|
||||
# 检查激活码是否有效
|
||||
is_valid, message = KeyService.is_valid(key, db)
|
||||
if not is_valid:
|
||||
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=message)
|
||||
return {"switched": False, "message": message}
|
||||
|
||||
# 检查换号频率限制
|
||||
can_switch, switch_msg = KeyService.can_switch(db, key)
|
||||
if not can_switch:
|
||||
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=switch_msg)
|
||||
return {"switched": False, "message": switch_msg}
|
||||
|
||||
# 释放当前账号
|
||||
if key.current_account_id:
|
||||
old_account = AccountService.get_by_id(db, key.current_account_id)
|
||||
if old_account:
|
||||
AccountService.release(db, old_account)
|
||||
|
||||
# 获取新账号
|
||||
account = AccountService.get_available(db, key.membership_type)
|
||||
if not account:
|
||||
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message="无可用账号")
|
||||
return {"switched": False, "message": "暂无可用账号,请稍后重试"}
|
||||
|
||||
# 绑定新账号并扣除额度
|
||||
KeyService.bind_account(db, key, account)
|
||||
KeyService.use_switch(db, key)
|
||||
AccountService.mark_used(db, account, key.id)
|
||||
|
||||
LogService.log(db, key.id, "switch", account.id, ip_address=req.client.host, success=True)
|
||||
|
||||
return {
|
||||
"switched": True,
|
||||
"switchRemaining": key.quota - key.quota_used,
|
||||
"email": account.email,
|
||||
"data": build_account_data(account, key)
|
||||
}
|
||||
|
||||
|
||||
# ========== 代理配置 API ==========
|
||||
|
||||
@router.get("/proxy-config")
|
||||
async def get_proxy_config(db: Session = Depends(get_db)):
|
||||
"""获取代理配置"""
|
||||
return {
|
||||
"success": True,
|
||||
"enabled": False,
|
||||
"message": "无感换号功能暂未开放"
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
|
||||
|
||||
@router.put("/proxy-config")
|
||||
async def update_proxy_config(config: dict, db: Session = Depends(get_db)):
|
||||
"""更新代理配置 (客户端本地使用,后端仅返回成功)"""
|
||||
return {"success": True, "message": "代理配置已保存"}
|
||||
|
||||
|
||||
# ========== 无感换号配置 API ==========
|
||||
|
||||
@router.get("/seamless/status")
|
||||
async def get_seamless_status(db: Session = Depends(get_db)):
|
||||
"""获取无感换号全局状态"""
|
||||
settings = GlobalSettingsService.get_all(db)
|
||||
account_stats = AccountService.count(db)
|
||||
return {
|
||||
"success": True,
|
||||
"enabled": True,
|
||||
"available_accounts": account_stats.get("active", 0),
|
||||
"total_accounts": account_stats.get("total", 0),
|
||||
"switch_interval_minutes": settings.auto_switch_interval_minutes,
|
||||
"max_switches_per_day": settings.auto_max_switches_per_day
|
||||
}
|
||||
|
||||
|
||||
@router.get("/seamless/config")
|
||||
async def get_seamless_config(db: Session = Depends(get_db)):
|
||||
"""获取无感换号配置"""
|
||||
settings = GlobalSettingsService.get_all(db)
|
||||
return {
|
||||
"success": True,
|
||||
"auto_switch": True,
|
||||
"switch_interval_minutes": settings.auto_switch_interval_minutes,
|
||||
"max_switches_per_day": settings.auto_max_switches_per_day,
|
||||
"pro_quota_cost": settings.pro_quota_cost
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seamless/config")
|
||||
async def update_seamless_config(config: dict, db: Session = Depends(get_db)):
|
||||
"""更新无感换号配置"""
|
||||
# 从请求中提取配置
|
||||
updates = {}
|
||||
if "switch_interval_minutes" in config:
|
||||
updates["auto_switch_interval_minutes"] = config["switch_interval_minutes"]
|
||||
if "max_switches_per_day" in config:
|
||||
updates["auto_max_switches_per_day"] = config["max_switches_per_day"]
|
||||
if "pro_quota_cost" in config:
|
||||
updates["pro_quota_cost"] = config["pro_quota_cost"]
|
||||
|
||||
if updates:
|
||||
GlobalSettingsService.update_all(db, **updates)
|
||||
|
||||
return {"success": True, "message": "配置已更新"}
|
||||
|
||||
|
||||
@router.post("/seamless/inject")
|
||||
async def seamless_inject(data: dict, db: Session = Depends(get_db)):
|
||||
"""注入无感换号代码 (客户端本地操作,后端仅记录)"""
|
||||
user_key = data.get("userKey")
|
||||
if user_key:
|
||||
key = KeyService.get_by_key(db, user_key)
|
||||
if key:
|
||||
LogService.log(db, key.id, "seamless_inject", success=True, message="启用无感换号")
|
||||
return {"success": True, "message": "无感换号已启用"}
|
||||
|
||||
|
||||
@router.post("/seamless/restore")
|
||||
async def seamless_restore(data: dict = None, db: Session = Depends(get_db)):
|
||||
"""恢复原始代码 (客户端本地操作,后端仅记录)"""
|
||||
if data and data.get("userKey"):
|
||||
key = KeyService.get_by_key(db, data["userKey"])
|
||||
if key:
|
||||
LogService.log(db, key.id, "seamless_restore", success=True, message="禁用无感换号")
|
||||
return {"success": True, "message": "已恢复原始状态"}
|
||||
|
||||
|
||||
@router.get("/seamless/accounts")
|
||||
async def get_seamless_accounts(userKey: str = None, db: Session = Depends(get_db)):
|
||||
"""获取可用账号列表 (供管理使用)"""
|
||||
if not userKey:
|
||||
return {"success": False, "error": "缺少 userKey 参数"}
|
||||
|
||||
key = KeyService.get_by_key(db, userKey)
|
||||
if not key:
|
||||
return {"success": False, "error": "激活码不存在"}
|
||||
|
||||
# 获取该激活码类型的可用账号数量
|
||||
accounts = AccountService.get_all(db, limit=100)
|
||||
available_count = sum(1 for a in accounts if a.status == AccountStatus.ACTIVE and a.membership_type == key.membership_type)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"available_count": available_count,
|
||||
"membership_type": key.membership_type.value
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seamless/sync-accounts")
|
||||
async def sync_seamless_accounts(data: dict, db: Session = Depends(get_db)):
|
||||
"""同步账号 (客户端上报账号信息)"""
|
||||
# 客户端可能上报当前使用的账号状态
|
||||
return {"success": True, "message": "同步成功"}
|
||||
|
||||
|
||||
# ========== 版本检查 API ==========
|
||||
|
||||
@router.get("/version")
|
||||
async def get_version():
|
||||
"""获取最新版本信息"""
|
||||
return {
|
||||
"success": True,
|
||||
"current_version": "0.4.5",
|
||||
"latest_version": "0.4.5",
|
||||
"has_update": False,
|
||||
"download_url": "",
|
||||
"changelog": ""
|
||||
}
|
||||
|
||||
|
||||
# ========== 公告 API ==========
|
||||
|
||||
@router.get("/announcement")
|
||||
async def get_announcement(db: Session = Depends(get_db)):
|
||||
"""获取公告信息"""
|
||||
return {
|
||||
"success": True,
|
||||
"has_announcement": True,
|
||||
"data": {
|
||||
"is_active": True,
|
||||
"title": "欢迎使用蜂鸟Pro",
|
||||
"content": "感谢使用蜂鸟Pro!\n\n如有问题请联系客服。",
|
||||
"type": "info",
|
||||
"created_at": "2024-12-17 00:00:00"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/announcements/latest")
|
||||
async def get_announcements_latest(db: Session = Depends(get_db)):
|
||||
"""获取最新公告 (前端调用的路径)"""
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"is_active": True,
|
||||
"title": "欢迎使用蜂鸟Pro",
|
||||
"content": "感谢使用蜂鸟Pro!\n\n如有问题请联系客服。",
|
||||
"type": "info",
|
||||
"created_at": "2024-12-17 00:00:00"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ========== 用量查询 API ==========
|
||||
|
||||
@router.get("/usage")
|
||||
async def get_usage(userKey: str = None, db: Session = Depends(get_db)):
|
||||
"""获取用量信息"""
|
||||
if not userKey:
|
||||
return {"success": False, "error": "缺少 userKey 参数"}
|
||||
|
||||
key = KeyService.get_by_key(db, userKey)
|
||||
if not key:
|
||||
return {"success": False, "error": "激活码不存在"}
|
||||
|
||||
account = None
|
||||
if key.current_account_id:
|
||||
account = AccountService.get_by_id(db, key.current_account_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"membership_type": key.membership_type.value,
|
||||
"quota": key.quota,
|
||||
"quota_used": key.quota_used,
|
||||
"quota_remaining": key.quota - key.quota_used,
|
||||
"switch_count": key.switch_count,
|
||||
"expire_at": key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None,
|
||||
"current_email": account.email if account else None
|
||||
}
|
||||
|
||||
@@ -12,13 +12,16 @@ class Settings(BaseSettings):
|
||||
DB_NAME: str = "cursorpro"
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
SECRET_KEY: str = "hb8x2kF9mNpQ3rT7vY1zA4cE6gJ0lO5sU8wB2dH4"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
|
||||
|
||||
# 管理员账号
|
||||
ADMIN_USERNAME: str = "admin"
|
||||
ADMIN_PASSWORD: str = "admin123"
|
||||
ADMIN_PASSWORD: str = "Hb@2024Pro!"
|
||||
|
||||
# 外部系统API Token (用于批量上传账号等)
|
||||
API_TOKEN: str = "hb-ext-9kX2mP5nQ8rT1vY4zA7c"
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
|
||||
@@ -43,6 +43,32 @@ class AccountImport(BaseModel):
|
||||
accounts: List[AccountCreate]
|
||||
|
||||
|
||||
# ========== 外部系统批量上传 ==========
|
||||
|
||||
class ExternalAccountItem(BaseModel):
|
||||
"""外部系统上传的账号项"""
|
||||
email: str
|
||||
access_token: str
|
||||
refresh_token: Optional[str] = None
|
||||
workos_session_token: Optional[str] = None
|
||||
membership_type: Optional[str] = "free" # free/pro, 默认free(auto账号)
|
||||
remark: Optional[str] = None
|
||||
|
||||
class ExternalBatchUpload(BaseModel):
|
||||
"""外部系统批量上传请求"""
|
||||
accounts: List[ExternalAccountItem]
|
||||
update_existing: bool = True # 是否更新已存在的账号
|
||||
|
||||
class ExternalBatchResponse(BaseModel):
|
||||
"""外部系统批量上传响应"""
|
||||
success: bool
|
||||
total: int
|
||||
created: int
|
||||
updated: int
|
||||
failed: int
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
# ========== 激活码相关 ==========
|
||||
|
||||
class KeyBase(BaseModel):
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
uvicorn==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
pymysql==1.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
jinja2==3.1.3
|
||||
aiofiles==23.2.1
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
aiosqlite==0.19.0
|
||||
|
||||
@@ -1,38 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════╗"
|
||||
echo "║ CursorPro 后台管理系统 ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 检查 Python
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "[错误] 未找到 Python,请先安装 Python 3.8+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查虚拟环境
|
||||
cd "$(dirname "$0")"
|
||||
# 2. 杀掉占用端口的进程
|
||||
pkill -f 'python run.py'
|
||||
# 创建虚拟环境 (首次运行)
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "[信息] 创建虚拟环境..."
|
||||
echo "创建虚拟环境..."
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
else
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
echo "[信息] 检查依赖..."
|
||||
pip install -r requirements.txt -q
|
||||
|
||||
# 检查 .env 文件
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "[信息] 创建 .env 配置文件..."
|
||||
cp .env.example .env
|
||||
echo "[警告] 请编辑 .env 文件配置数据库连接!"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[信息] 启动服务..."
|
||||
echo ""
|
||||
python run.py
|
||||
# 启动服务 (端口8000, 关闭热重载)
|
||||
echo "启动 CursorPro 后台 (端口: 8000)..."
|
||||
RELOAD=false nohup python run.py > cursorpro.log 2>&1 &
|
||||
echo $! > cursorpro.pid
|
||||
echo "服务已启动,PID: $(cat cursorpro.pid)"
|
||||
echo "日志: tail -f cursorpro.log"
|
||||
|
||||
17
backend/stop.sh
Normal file
17
backend/stop.sh
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ -f "cursorpro.pid" ]; then
|
||||
PID=$(cat cursorpro.pid)
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
kill $PID
|
||||
echo "服务已停止 (PID: $PID)"
|
||||
else
|
||||
echo "服务未运行"
|
||||
fi
|
||||
rm -f cursorpro.pid
|
||||
else
|
||||
echo "PID文件不存在"
|
||||
# 尝试查找并杀死进程
|
||||
pkill -f "python run.py" && echo "已停止所有相关进程"
|
||||
fi
|
||||
@@ -119,26 +119,79 @@
|
||||
|
||||
<!-- 账号管理 -->
|
||||
<div v-if="currentTab === 'accounts'">
|
||||
<!-- 外部API说明 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 class="text-sm font-medium text-blue-800">外部系统批量上传接口</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p class="font-mono bg-blue-100 px-2 py-1 rounded mb-2">POST /admin/external/accounts/batch</p>
|
||||
<p class="mb-1"><strong>Header:</strong> X-API-Token: hb-api-token-change-in-production</p>
|
||||
<details class="cursor-pointer">
|
||||
<summary class="text-blue-800 hover:text-blue-900">查看请求格式 ▼</summary>
|
||||
<pre class="mt-2 bg-gray-800 text-green-400 p-3 rounded text-xs overflow-x-auto">{
|
||||
"accounts": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"access_token": "eyJhbG...",
|
||||
"refresh_token": "xxx", // 可选
|
||||
"workos_session_token": "xxx", // 可选
|
||||
"membership_type": "free", // free=Auto账号, pro=高级账号
|
||||
"remark": "备注" // 可选
|
||||
}
|
||||
],
|
||||
"update_existing": true // 是否更新已存在的账号
|
||||
}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 class="text-lg font-medium text-gray-900">账号列表</h2>
|
||||
<div class="space-x-2">
|
||||
<button @click="showImportModal = true"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
|
||||
批量导入
|
||||
</button>
|
||||
<button @click="showAccountModal = true; editingAccount = null"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
||||
添加账号
|
||||
</button>
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-medium text-gray-900">账号列表 (共 {{ accountsTotal }} 条)</h2>
|
||||
<div class="space-x-2">
|
||||
<button v-if="selectedAccounts.length > 0" @click="batchEnableAccounts"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
|
||||
批量启用 ({{ selectedAccounts.length }})
|
||||
</button>
|
||||
<button v-if="selectedAccounts.length > 0" @click="batchDisableAccounts"
|
||||
class="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 text-sm">
|
||||
批量禁用 ({{ selectedAccounts.length }})
|
||||
</button>
|
||||
<button v-if="selectedAccounts.length > 0" @click="batchDeleteAccounts"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm">
|
||||
批量删除 ({{ selectedAccounts.length }})
|
||||
</button>
|
||||
<button @click="showImportModal = true"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
|
||||
批量导入
|
||||
</button>
|
||||
<button @click="showAccountModal = true; editingAccount = null"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
||||
添加账号
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">邮箱</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">类型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Token</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">使用次数</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||||
@@ -146,6 +199,10 @@
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="account in accounts" :key="account.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input type="checkbox" :value="account.id" v-model="selectedAccounts"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ account.email }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="account.membership_type === 'pro' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'"
|
||||
@@ -153,9 +210,24 @@
|
||||
{{ account.membership_type.toUpperCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-1">
|
||||
<button @click="copyToken(account.access_token, 'AccessToken')"
|
||||
class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs hover:bg-blue-200" title="复制 Access Token">
|
||||
AT
|
||||
</button>
|
||||
<button v-if="account.refresh_token" @click="copyToken(account.refresh_token, 'RefreshToken')"
|
||||
class="px-2 py-1 bg-green-100 text-green-700 rounded text-xs hover:bg-green-200" title="复制 Refresh Token">
|
||||
RT
|
||||
</button>
|
||||
<button v-if="account.workos_session_token" @click="copyToken(account.workos_session_token, 'WorkosSessionToken')"
|
||||
class="px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs hover:bg-purple-200" title="复制 Workos Session Token">
|
||||
WT
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="getStatusClass(account.status)"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full">
|
||||
<span @click="toggleAccountStatus(account)" :class="getStatusClass(account.status)"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full cursor-pointer hover:opacity-80 transition"
|
||||
:title="'点击切换状态'">
|
||||
{{ getStatusText(account.status) }}
|
||||
</span>
|
||||
</td>
|
||||
@@ -168,6 +240,49 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700">每页显示</span>
|
||||
<select v-model="accountsPagination.pageSize" @change="loadAccounts"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
<option :value="10">10</option>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
<span class="text-sm text-gray-700">条</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="accountsPagination.currentPage = 1; loadAccounts()"
|
||||
:disabled="accountsPagination.currentPage === 1"
|
||||
:class="accountsPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
首页
|
||||
</button>
|
||||
<button @click="accountsPagination.currentPage--; loadAccounts()"
|
||||
:disabled="accountsPagination.currentPage === 1"
|
||||
:class="accountsPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
上一页
|
||||
</button>
|
||||
<span class="text-sm text-gray-700">
|
||||
第 {{ accountsPagination.currentPage }} / {{ accountsPagination.totalPages }} 页
|
||||
</span>
|
||||
<button @click="accountsPagination.currentPage++; loadAccounts()"
|
||||
:disabled="accountsPagination.currentPage >= accountsPagination.totalPages"
|
||||
:class="accountsPagination.currentPage >= accountsPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
下一页
|
||||
</button>
|
||||
<button @click="accountsPagination.currentPage = accountsPagination.totalPages; loadAccounts()"
|
||||
:disabled="accountsPagination.currentPage >= accountsPagination.totalPages"
|
||||
:class="accountsPagination.currentPage >= accountsPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
末页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,11 +291,29 @@
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-medium text-gray-900">激活码列表</h2>
|
||||
<button @click="showKeyModal = true; editingKey = null; resetKeyForm()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
||||
生成激活码
|
||||
</button>
|
||||
<h2 class="text-lg font-medium text-gray-900">激活码列表 (共 {{ keysTotal }} 条)</h2>
|
||||
<div class="space-x-2">
|
||||
<button v-if="selectedKeys.length > 0" @click="batchCopyKeys"
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 text-sm">
|
||||
批量复制 ({{ selectedKeys.length }})
|
||||
</button>
|
||||
<button v-if="selectedKeys.length > 0" @click="batchEnableKeys"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
|
||||
批量启用 ({{ selectedKeys.length }})
|
||||
</button>
|
||||
<button v-if="selectedKeys.length > 0" @click="batchDisableKeys"
|
||||
class="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 text-sm">
|
||||
批量禁用 ({{ selectedKeys.length }})
|
||||
</button>
|
||||
<button v-if="selectedKeys.length > 0" @click="batchDeleteKeys"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm">
|
||||
批量删除 ({{ selectedKeys.length }})
|
||||
</button>
|
||||
<button @click="showKeyModal = true; editingKey = null; resetKeyForm()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
||||
生成激活码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@@ -213,6 +346,10 @@
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" @change="toggleSelectAllKeys" :checked="isAllKeysSelected"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">激活码</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">类型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">额度/天数</th>
|
||||
@@ -223,6 +360,10 @@
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="key in keys" :key="key.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input type="checkbox" :value="key.id" v-model="selectedKeys"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ key.key }}</code>
|
||||
<button @click="copyKey(key.key)" class="ml-2 text-gray-400 hover:text-gray-600">
|
||||
@@ -273,6 +414,49 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700">每页显示</span>
|
||||
<select v-model="keysPagination.pageSize" @change="searchKeys"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
<option :value="10">10</option>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
<span class="text-sm text-gray-700">条</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="keysPagination.currentPage = 1; searchKeys()"
|
||||
:disabled="keysPagination.currentPage === 1"
|
||||
:class="keysPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
首页
|
||||
</button>
|
||||
<button @click="keysPagination.currentPage--; searchKeys()"
|
||||
:disabled="keysPagination.currentPage === 1"
|
||||
:class="keysPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
上一页
|
||||
</button>
|
||||
<span class="text-sm text-gray-700">
|
||||
第 {{ keysPagination.currentPage }} / {{ keysPagination.totalPages }} 页
|
||||
</span>
|
||||
<button @click="keysPagination.currentPage++; searchKeys()"
|
||||
:disabled="keysPagination.currentPage >= keysPagination.totalPages"
|
||||
:class="keysPagination.currentPage >= keysPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
下一页
|
||||
</button>
|
||||
<button @click="keysPagination.currentPage = keysPagination.totalPages; searchKeys()"
|
||||
:disabled="keysPagination.currentPage >= keysPagination.totalPages"
|
||||
:class="keysPagination.currentPage >= keysPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
|
||||
末页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -777,7 +961,7 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, reactive, onMounted, watch } = Vue
|
||||
const { createApp, ref, reactive, computed, onMounted, watch } = Vue
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
@@ -791,7 +975,21 @@
|
||||
// 数据
|
||||
const stats = ref({ total_accounts: 0, active_accounts: 0, pro_accounts: 0, total_keys: 0, active_keys: 0, today_usage: 0 })
|
||||
const accounts = ref([])
|
||||
const accountsTotal = ref(0)
|
||||
const selectedAccounts = ref([])
|
||||
const accountsPagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 1
|
||||
})
|
||||
const keys = ref([])
|
||||
const keysTotal = ref(0)
|
||||
const selectedKeys = ref([])
|
||||
const keysPagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 1
|
||||
})
|
||||
const logs = ref([])
|
||||
const logFilter = reactive({ action: '' })
|
||||
|
||||
@@ -883,19 +1081,44 @@
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [statsRes, accountsRes, keysRes] = await Promise.all([
|
||||
api.get('/dashboard'),
|
||||
api.get('/accounts'),
|
||||
api.get('/keys')
|
||||
])
|
||||
const statsRes = await api.get('/dashboard')
|
||||
stats.value = statsRes.data
|
||||
accounts.value = accountsRes.data
|
||||
keys.value = keysRes.data
|
||||
await Promise.all([loadAccounts(), searchKeys()])
|
||||
} catch (e) {
|
||||
console.error('加载数据失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载账号列表(分页)
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const skip = (accountsPagination.currentPage - 1) * accountsPagination.pageSize
|
||||
const [accountsRes, countRes] = await Promise.all([
|
||||
api.get(`/accounts?skip=${skip}&limit=${accountsPagination.pageSize}`),
|
||||
api.get('/accounts/count')
|
||||
])
|
||||
accounts.value = accountsRes.data
|
||||
accountsTotal.value = countRes.data.total
|
||||
accountsPagination.totalPages = Math.ceil(accountsTotal.value / accountsPagination.pageSize)
|
||||
selectedAccounts.value = []
|
||||
} catch (e) {
|
||||
console.error('加载账号失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const isAllSelected = computed(() => {
|
||||
return accounts.value.length > 0 && selectedAccounts.value.length === accounts.value.length
|
||||
})
|
||||
|
||||
const toggleSelectAll = (event) => {
|
||||
if (event.target.checked) {
|
||||
selectedAccounts.value = accounts.value.map(a => a.id)
|
||||
} else {
|
||||
selectedAccounts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 账号操作
|
||||
const editAccount = (account) => {
|
||||
editingAccount.value = account
|
||||
@@ -911,7 +1134,7 @@
|
||||
await api.post('/accounts', accountForm)
|
||||
}
|
||||
showAccountModal.value = false
|
||||
loadData()
|
||||
loadAccounts()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '保存失败')
|
||||
}
|
||||
@@ -921,18 +1144,72 @@
|
||||
if (!confirm('确定删除此账号?')) return
|
||||
try {
|
||||
await api.delete(`/accounts/${id}`)
|
||||
loadData()
|
||||
loadAccounts()
|
||||
} catch (e) {
|
||||
alert('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷切换账号状态
|
||||
const toggleAccountStatus = async (account) => {
|
||||
const statusMap = { 'active': '可用', 'in_use': '使用中', 'disabled': '禁用', 'expired': '过期' }
|
||||
const nextMap = { 'in_use': 'active', 'active': 'disabled', 'disabled': 'active', 'expired': 'active' }
|
||||
const nextStatus = nextMap[account.status] || 'active'
|
||||
const msg = `确定将账号状态从「${statusMap[account.status]}」切换为「${statusMap[nextStatus]}」?`
|
||||
if (!confirm(msg)) return
|
||||
try {
|
||||
const res = await api.post(`/accounts/${account.id}/toggle-status`)
|
||||
loadAccounts()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '切换失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量启用账号
|
||||
const batchEnableAccounts = async () => {
|
||||
if (selectedAccounts.value.length === 0) return
|
||||
if (!confirm(`确定启用选中的 ${selectedAccounts.value.length} 个账号?`)) return
|
||||
try {
|
||||
const res = await api.post('/accounts/batch-enable', selectedAccounts.value)
|
||||
alert(res.data.message)
|
||||
loadAccounts()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '批量启用失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量禁用账号
|
||||
const batchDisableAccounts = async () => {
|
||||
if (selectedAccounts.value.length === 0) return
|
||||
if (!confirm(`确定禁用选中的 ${selectedAccounts.value.length} 个账号?`)) return
|
||||
try {
|
||||
const res = await api.post('/accounts/batch-disable', selectedAccounts.value)
|
||||
alert(res.data.message)
|
||||
loadAccounts()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '批量禁用失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除账号
|
||||
const batchDeleteAccounts = async () => {
|
||||
if (selectedAccounts.value.length === 0) return
|
||||
if (!confirm(`确定删除选中的 ${selectedAccounts.value.length} 个账号?此操作不可撤销!`)) return
|
||||
try {
|
||||
const res = await api.post('/accounts/batch-delete', selectedAccounts.value)
|
||||
alert(res.data.message)
|
||||
loadAccounts()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '批量删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const importAccounts = async () => {
|
||||
try {
|
||||
const data = JSON.parse(importData.value)
|
||||
const res = await api.post('/accounts/import', { accounts: data })
|
||||
importResult.value = res.data
|
||||
loadData()
|
||||
loadAccounts()
|
||||
} catch (e) {
|
||||
alert('导入失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
@@ -964,7 +1241,7 @@
|
||||
await api.post('/keys', keyForm)
|
||||
}
|
||||
showKeyModal.value = false
|
||||
loadData()
|
||||
searchKeys()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '保存失败')
|
||||
}
|
||||
@@ -974,7 +1251,7 @@
|
||||
if (!confirm('确定删除此激活码?')) return
|
||||
try {
|
||||
await api.delete(`/keys/${id}`)
|
||||
loadData()
|
||||
searchKeys()
|
||||
} catch (e) {
|
||||
alert('删除失败')
|
||||
}
|
||||
@@ -990,7 +1267,7 @@
|
||||
try {
|
||||
await api.post(`/keys/${quotaTarget.value.id}/add-quota?add_quota=${addQuotaAmount.value}`)
|
||||
showQuotaModal.value = false
|
||||
loadData()
|
||||
searchKeys()
|
||||
alert('充值成功')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '充值失败')
|
||||
@@ -1002,6 +1279,11 @@
|
||||
alert('已复制')
|
||||
}
|
||||
|
||||
const copyToken = (token, name) => {
|
||||
navigator.clipboard.writeText(token)
|
||||
alert(`${name} 已复制`)
|
||||
}
|
||||
|
||||
// 延期操作
|
||||
const extendKey = (key) => {
|
||||
extendTarget.value = key
|
||||
@@ -1017,7 +1299,7 @@
|
||||
add_quota: 0
|
||||
})
|
||||
showExtendModal.value = false
|
||||
loadData()
|
||||
searchKeys()
|
||||
alert('延期成功')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '延期失败')
|
||||
@@ -1072,7 +1354,7 @@
|
||||
const res = await api.post('/keys/batch-compensate?' + params.toString())
|
||||
compensateResult.value = res.data
|
||||
compensatePreview.value = null
|
||||
loadData()
|
||||
searchKeys()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '补偿失败')
|
||||
}
|
||||
@@ -1116,19 +1398,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索激活码
|
||||
// 搜索激活码(支持分页)
|
||||
let searchTimeout = null
|
||||
const searchKeys = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (keySearch.search) params.append('search', keySearch.search)
|
||||
if (keySearch.status) params.append('status', keySearch.status)
|
||||
if (keySearch.activated) params.append('activated', keySearch.activated)
|
||||
if (keySearch.membership_type) params.append('membership_type', keySearch.membership_type)
|
||||
const res = await api.get('/keys?' + params.toString())
|
||||
keys.value = res.data
|
||||
const skip = (keysPagination.currentPage - 1) * keysPagination.pageSize
|
||||
|
||||
// 构建激活码列表查询参数
|
||||
const listParams = new URLSearchParams()
|
||||
listParams.append('skip', skip)
|
||||
listParams.append('limit', keysPagination.pageSize)
|
||||
if (keySearch.search) listParams.append('search', keySearch.search)
|
||||
if (keySearch.status) listParams.append('status', keySearch.status)
|
||||
if (keySearch.activated) listParams.append('activated', keySearch.activated)
|
||||
if (keySearch.membership_type) listParams.append('membership_type', keySearch.membership_type)
|
||||
|
||||
// 构建计数查询参数(不包含skip和limit)
|
||||
const countParams = new URLSearchParams()
|
||||
if (keySearch.search) countParams.append('search', keySearch.search)
|
||||
if (keySearch.status) countParams.append('status', keySearch.status)
|
||||
if (keySearch.activated) countParams.append('activated', keySearch.activated)
|
||||
if (keySearch.membership_type) countParams.append('membership_type', keySearch.membership_type)
|
||||
|
||||
const [keysRes, countRes] = await Promise.all([
|
||||
api.get('/keys?' + listParams.toString()),
|
||||
api.get('/keys/count' + (countParams.toString() ? '?' + countParams.toString() : ''))
|
||||
])
|
||||
keys.value = keysRes.data
|
||||
keysTotal.value = countRes.data.total
|
||||
keysPagination.totalPages = Math.ceil(keysTotal.value / keysPagination.pageSize)
|
||||
selectedKeys.value = []
|
||||
} catch (e) {
|
||||
console.error('搜索失败', e)
|
||||
}
|
||||
@@ -1140,7 +1441,74 @@
|
||||
keySearch.status = ''
|
||||
keySearch.activated = ''
|
||||
keySearch.membership_type = ''
|
||||
loadData()
|
||||
keysPagination.currentPage = 1
|
||||
searchKeys()
|
||||
}
|
||||
|
||||
// 全选/取消全选激活码
|
||||
const isAllKeysSelected = computed(() => {
|
||||
return keys.value.length > 0 && selectedKeys.value.length === keys.value.length
|
||||
})
|
||||
|
||||
const toggleSelectAllKeys = (event) => {
|
||||
if (event.target.checked) {
|
||||
selectedKeys.value = keys.value.map(k => k.id)
|
||||
} else {
|
||||
selectedKeys.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 批量复制激活码
|
||||
const batchCopyKeys = async () => {
|
||||
if (selectedKeys.value.length === 0) return
|
||||
try {
|
||||
// 获取选中的激活码
|
||||
const selectedKeyObjects = keys.value.filter(k => selectedKeys.value.includes(k.id))
|
||||
const keyStrings = selectedKeyObjects.map(k => k.key).join('\n')
|
||||
await navigator.clipboard.writeText(keyStrings)
|
||||
alert(`已复制 ${selectedKeys.value.length} 个激活码到剪贴板`)
|
||||
} catch (e) {
|
||||
alert('复制失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量启用激活码
|
||||
const batchEnableKeys = async () => {
|
||||
if (selectedKeys.value.length === 0) return
|
||||
if (!confirm(`确定启用选中的 ${selectedKeys.value.length} 个激活码?`)) return
|
||||
try {
|
||||
const res = await api.post('/keys/batch-enable', selectedKeys.value)
|
||||
alert(res.data.message)
|
||||
searchKeys()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '批量启用失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量禁用激活码
|
||||
const batchDisableKeys = async () => {
|
||||
if (selectedKeys.value.length === 0) return
|
||||
if (!confirm(`确定禁用选中的 ${selectedKeys.value.length} 个激活码?`)) return
|
||||
try {
|
||||
const res = await api.post('/keys/batch-disable', selectedKeys.value)
|
||||
alert(res.data.message)
|
||||
searchKeys()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '批量禁用失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除激活码
|
||||
const batchDeleteKeys = async () => {
|
||||
if (selectedKeys.value.length === 0) return
|
||||
if (!confirm(`确定删除选中的 ${selectedKeys.value.length} 个激活码?此操作不可撤销!`)) return
|
||||
try {
|
||||
const res = await api.post('/keys/batch-delete', selectedKeys.value)
|
||||
alert(res.data.message)
|
||||
searchKeys()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || '批量删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用/启用激活码
|
||||
@@ -1217,7 +1585,9 @@
|
||||
|
||||
return {
|
||||
isLoggedIn, currentUser, currentTab, loginError, loginForm,
|
||||
stats, accounts, keys, logs, logFilter, keySearch,
|
||||
stats, accounts, accountsTotal, selectedAccounts, accountsPagination, isAllSelected,
|
||||
keys, keysTotal, selectedKeys, keysPagination, isAllKeysSelected,
|
||||
logs, logFilter, keySearch,
|
||||
accountForm, keyForm,
|
||||
showAccountModal, showKeyModal, showImportModal, showQuotaModal, showExtendModal,
|
||||
showKeyDetailModal, keyDetail, keyDevices, keyLogs,
|
||||
@@ -1227,9 +1597,11 @@
|
||||
extendTarget, extendDays,
|
||||
globalSettings,
|
||||
compensateForm, compensatePreview, compensateResult,
|
||||
login, logout, loadData, loadLogs, searchKeys, resetKeySearch,
|
||||
editAccount, saveAccount, deleteAccount, importAccounts,
|
||||
resetKeyForm, editKey, saveKey, deleteKey, addQuota, submitAddQuota, copyKey,
|
||||
login, logout, loadData, loadAccounts, loadLogs, searchKeys, resetKeySearch,
|
||||
toggleSelectAll, batchEnableAccounts, batchDisableAccounts, batchDeleteAccounts,
|
||||
toggleSelectAllKeys, batchCopyKeys, batchEnableKeys, batchDisableKeys, batchDeleteKeys,
|
||||
editAccount, saveAccount, deleteAccount, importAccounts, toggleAccountStatus,
|
||||
resetKeyForm, editKey, saveKey, deleteKey, addQuota, submitAddQuota, copyKey, copyToken,
|
||||
viewKeyDetail, deleteDevice, disableKey, enableKey,
|
||||
extendKey, submitExtend,
|
||||
loadSettings, saveSettings,
|
||||
|
||||
Reference in New Issue
Block a user