初步增加 web 界面

This commit is contained in:
yml2213
2026-06-22 13:11:15 +08:00
parent 4c924375aa
commit 347edb8103
66 changed files with 6816 additions and 21 deletions
BIN
View File
Binary file not shown.
+71 -20
View File
@@ -3,6 +3,7 @@
import json import json
import re import re
import threading import threading
import time
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox, filedialog from tkinter import ttk, messagebox, filedialog
from datetime import datetime from datetime import datetime
@@ -566,17 +567,15 @@ class DouyuLoginApp:
self.whitelist_status_var.set(status) self.whitelist_status_var.set(status)
self.whitelist_test_btn.configure(state='normal' if enabled else 'disabled') self.whitelist_test_btn.configure(state='normal' if enabled else 'disabled')
@staticmethod def _get_exit_ip_for_test(self, proxy_config: ProxyConfig) -> Optional[str]:
def _get_exit_ip_for_test(proxy_config: ProxyConfig) -> Optional[str]:
"""获取出口IP用于白名单测试""" """获取出口IP用于白名单测试"""
proxy_url = None proxy_url = None
if proxy_config.enabled: if proxy_config.enabled:
if proxy_config.api_url: if proxy_config.api_url:
try: try:
resp = requests.get(proxy_config.api_url, timeout=10) resp = requests.get(proxy_config.api_url, timeout=10)
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', resp.text) resp.raise_for_status()
if match: proxy_url = DouyuLoginApp._parse_proxy_url(resp.text.strip())
proxy_url = f"http://{match.group(1)}:{match.group(2)}"
except Exception: except Exception:
pass pass
if not proxy_url: if not proxy_url:
@@ -755,15 +754,37 @@ class DouyuLoginApp:
self.proxy_test_queue.put(result) self.proxy_test_queue.put(result)
@staticmethod @staticmethod
def _fetch_proxy_from_api(api_url: str) -> str: def _parse_proxy_url(text: str) -> str:
"""从代理API获取一个代理地址。""" """从代理API响应中解析代理地址。"""
response = requests.get(api_url, timeout=10) # ip:port 格式
response.raise_for_status()
text = response.text.strip()
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text) match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text)
if not match: if match:
return '' return f'http://{match.group(1)}:{match.group(2)}'
return f'http://{match.group(1)}:{match.group(2)}'
# JSON 格式: {"ip": "...", "port": ...} 等
try:
data = json.loads(text) if text else {}
if isinstance(data, dict):
ip = data.get('ip') or data.get('host') or data.get('proxy')
port = data.get('port')
if ip and port:
return f'http://{ip}:{port}'
if ip:
match = re.search(r'(\d+\.\d+\.\d+\.\d+):?(\d+)?', str(ip))
if match:
return f'http://{match.group(1)}:{match.group(2) or "80"}'
except Exception:
pass
return ''
@staticmethod
def _extract_whitelist_ip_from_error(text: str) -> Optional[str]:
"""从代理API白名单错误消息中提取需要添加的IP。"""
if '添加白名单' in text or '白名单' in text:
match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text)
if match:
return match.group(1)
return None
@staticmethod @staticmethod
def _extract_origin_ip(response: requests.Response) -> str: def _extract_origin_ip(response: requests.Response) -> str:
@@ -795,7 +816,7 @@ class DouyuLoginApp:
'http': proxy_config.http or proxy_url, 'http': proxy_config.http or proxy_url,
'https': proxy_config.https or proxy_url, 'https': proxy_config.https or proxy_url,
} }
last_error = '' errors = []
for name, url in PROXY_TEST_TARGETS: for name, url in PROXY_TEST_TARGETS:
try: try:
@@ -809,28 +830,58 @@ class DouyuLoginApp:
origin = DouyuLoginApp._extract_origin_ip(response) origin = DouyuLoginApp._extract_origin_ip(response)
return True, f'代理可用,出口IP: {origin}{name}' return True, f'代理可用,出口IP: {origin}{name}'
except Exception as exc: except Exception as exc:
last_error = f'{name} 验证失败: {exc}' err_msg = str(exc)
if 'Tunnel connection failed' in err_msg or '503' in err_msg:
detail = '代理拒绝连接(白名单可能未生效)'
elif 'timed out' in err_msg.lower():
detail = '连接超时'
else:
detail = type(exc).__name__
errors.append(f'{name}: {detail}')
return False, last_error or '代理验证失败' return False, '; '.join(errors) or '代理验证失败'
def _resolve_working_proxy_config(self, proxy_config: ProxyConfig) -> dict: def _resolve_working_proxy_config(self, proxy_config: ProxyConfig) -> dict:
"""获取并验证可用代理,成功后返回可用于登录的代理配置。""" """获取并验证可用代理,成功后返回可用于登录的代理配置。"""
attempts = PROXY_API_TEST_ATTEMPTS if proxy_config.api_url else PROXY_STATIC_TEST_ATTEMPTS attempts = PROXY_API_TEST_ATTEMPTS if proxy_config.api_url else PROXY_STATIC_TEST_ATTEMPTS
last_message = '' last_message = ''
synced_whitelist = False
for attempt in range(1, attempts + 1): for attempt in range(1, attempts + 1):
proxy_url = proxy_config.http or proxy_config.https proxy_url = proxy_config.http or proxy_config.https
if proxy_config.api_url: if proxy_config.api_url:
self.log_queue.put(('info', f'代理预检 {attempt}/{attempts}: 正在获取代理')) self.log_queue.put(('info', f'代理预检 {attempt}/{attempts}: 正在获取代理'))
try: try:
proxy_url = self._fetch_proxy_from_api(proxy_config.api_url) response = requests.get(proxy_config.api_url, timeout=10)
response.raise_for_status()
text = response.text.strip()
proxy_url = self._parse_proxy_url(text)
# 代理API返回了白名单错误,提取需要添加的IP
if not proxy_url and not synced_whitelist and proxy_config.whitelist_enabled:
whitelist_ip = self._extract_whitelist_ip_from_error(text)
if whitelist_ip:
self.log_queue.put(
('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...'))
manager = WhitelistManager(
proxy_config.whitelist_uid, proxy_config.whitelist_ukey)
ok, msg = manager.sync_ip(whitelist_ip)
self.log_queue.put(
('success' if ok else 'error', f'白名单同步: {msg}'))
if ok:
synced_whitelist = True
self.log_queue.put(('info', '白名单已更新,等待2秒后重试获取代理...'))
time.sleep(2)
continue
last_message = f'白名单同步失败: {msg}'
else:
last_message = f'代理API响应无法解析: {text[:60]}'
elif not proxy_url:
last_message = f'代理API响应无法解析: {text[:60]}'
except Exception as exc: except Exception as exc:
last_message = f'代理API请求失败: {exc}' last_message = f'代理API请求失败: {exc}'
self.log_queue.put(('warning', f'代理预检 {attempt}/{attempts}: {last_message}'))
continue
if not proxy_url: if not proxy_url:
last_message = '代理API未返回有效的 ip:port'
self.log_queue.put(('warning', f'代理预检 {attempt}/{attempts}: {last_message}')) self.log_queue.put(('warning', f'代理预检 {attempt}/{attempts}: {last_message}'))
continue continue
else: else:
+7
View File
@@ -118,6 +118,7 @@ def main():
parser.add_argument('-i', '--index', type=int, default=0, help='单账号登录时的账号索引') parser.add_argument('-i', '--index', type=int, default=0, help='单账号登录时的账号索引')
parser.add_argument('-b', '--batch', action='store_true', help='批量登录模式') parser.add_argument('-b', '--batch', action='store_true', help='批量登录模式')
parser.add_argument('-g', '--gui', action='store_true', help='启动GUI界面') parser.add_argument('-g', '--gui', action='store_true', help='启动GUI界面')
parser.add_argument('-w', '--web', action='store_true', help='启动Web后台')
parser.add_argument('-v', '--verbose', action='store_true', help='详细日志') parser.add_argument('-v', '--verbose', action='store_true', help='详细日志')
args = parser.parse_args() args = parser.parse_args()
@@ -133,6 +134,12 @@ def main():
app.run() app.run()
return return
# 启动Web后台
if args.web:
from web.backend.main import run
run()
return
# 加载配置 # 加载配置
try: try:
config = Config(args.config) config = Config(args.config)
+8 -1
View File
@@ -12,6 +12,13 @@ dependencies = [
"Pillow>=10.0.0", "Pillow>=10.0.0",
"PyYAML>=6.0", "PyYAML>=6.0",
"loguru>=0.7.0", "loguru>=0.7.0",
"fastapi>=0.110.0",
"uvicorn[standard]>=0.27.0",
"sqlalchemy>=2.0.0",
"python-jose[cryptography]>=3.3.0",
"bcrypt>=4.0.0",
"pydantic>=2.0.0",
"python-multipart>=0.0.9",
] ]
[project.scripts] [project.scripts]
@@ -22,4 +29,4 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["douyu", "geetest", "utils", "gui"] packages = ["douyu", "geetest", "utils", "gui", "web"]
+8
View File
@@ -5,3 +5,11 @@ opencv-python-headless>=4.8.0
Pillow>=10.0.0 Pillow>=10.0.0
PyYAML>=6.0 PyYAML>=6.0
loguru>=0.7.0 loguru>=0.7.0
# Web 后端
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
sqlalchemy>=2.0.0
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
pydantic>=2.0.0
python-multipart>=0.0.9
Executable
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -e
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT_DIR"
PYTHON="$ROOT_DIR/.venv/bin/python"
FRONTEND_DIR="$ROOT_DIR/web/frontend"
BACKEND_PORT=8000
FRONTEND_PORT=5173
# 检查 Python 虚拟环境
if [ ! -f "$PYTHON" ]; then
echo "❌ 未找到 .venv,正在创建..."
uv venv .venv
uv pip install -e .
fi
# 检查前端依赖
if [ ! -d "$FRONTEND_DIR/node_modules" ]; then
echo "❌ 前端依赖未安装,正在安装..."
cd "$FRONTEND_DIR" && npm install && cd "$ROOT_DIR"
fi
echo ""
echo "=============================="
echo " 斗鱼批量登录 - Web 后台"
echo "=============================="
echo " 后端: http://localhost:$BACKEND_PORT"
echo " 前端: http://localhost:$FRONTEND_PORT"
echo " 默认账号: admin / admin123"
echo " 按 Ctrl+C 停止所有服务"
echo "=============================="
echo ""
# 清理子进程
cleanup() {
echo ""
echo "正在停止服务..."
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null
wait $BACKEND_PID $FRONTEND_PID 2>/dev/null
echo "已停止"
}
trap cleanup EXIT INT TERM
# 启动后端
echo "启动后端 (FastAPI :$BACKEND_PORT)..."
"$PYTHON" -m uvicorn web.backend.main:app --host 0.0.0.0 --port $BACKEND_PORT --reload &
BACKEND_PID=$!
# 等后端就绪
sleep 2
# 启动前端
echo "启动前端 (Vite :$FRONTEND_PORT)..."
cd "$FRONTEND_DIR"
npm run dev -- --port $FRONTEND_PORT --host 0.0.0.0 &
FRONTEND_PID=$!
cd "$ROOT_DIR"
# 等待子进程
wait
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
"""数据库引擎与会话管理"""
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
DB_PATH = Path(__file__).parent.parent.parent / "data" / "web.db"
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(
f"sqlite:///{DB_PATH}",
connect_args={"check_same_thread": False},
echo=False,
)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Base = declarative_base()
def get_db():
"""FastAPI 依赖:提供数据库会话,请求结束自动关闭。"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""建表 + 写入初始数据。"""
Base.metadata.create_all(bind=engine)
_seed()
def _seed():
"""写入默认超管账号和角色。"""
from .models import User
from .security import hash_password
db = SessionLocal()
try:
if not db.query(User).first():
admin = User(
username="admin",
password_hash=hash_password("admin123"),
role="super_admin",
is_active=True,
remark="默认超级管理员",
)
db.add(admin)
db.commit()
finally:
db.close()
+50
View File
@@ -0,0 +1,50 @@
"""FastAPI 依赖注入"""
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from jose import JWTError
from .database import get_db
from .security import decode_access_token
from .models import User
from .permissions import get_role_permissions
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(token)
if payload is None:
raise credentials_exc
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exc
except JWTError:
raise credentials_exc
user = db.query(User).filter(User.id == int(user_id)).first()
if user is None or not user.is_active:
raise credentials_exc
return user
def require_permission(permission: str):
"""权限检查依赖工厂。用法: Depends(require_permission('user:create'))"""
def checker(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_active:
raise HTTPException(status_code=403, detail="账号已禁用")
perms = get_role_permissions(current_user.role)
if permission not in perms:
raise HTTPException(status_code=403, detail=f"无权限: {permission}")
return current_user
return checker
+51
View File
@@ -0,0 +1,51 @@
"""FastAPI 入口"""
import uvicorn
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import init_db
from .routers import auth, users, accounts, login, proxy
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(
title="斗鱼批量登录后台",
version="1.0.0",
lifespan=lifespan,
)
# CORS(开发期允许前端 localhost:5173
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(accounts.router)
app.include_router(login.router)
app.include_router(proxy.router)
@app.get("/api/health")
def health():
return {"status": "ok"}
def run():
uvicorn.run("web.backend.main:app", host="0.0.0.0", port=8000, reload=True)
if __name__ == "__main__":
run()
+89
View File
@@ -0,0 +1,89 @@
"""ORM 模型"""
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON,
)
from sqlalchemy.orm import relationship
from .database import Base
class User(Base):
"""系统用户"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(64), unique=True, nullable=False, index=True)
password_hash = Column(String(256), nullable=False)
role = Column(String(32), nullable=False, default="support") # super_admin / operation / support
is_active = Column(Boolean, default=True)
remark = Column(String(256), default="")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 客服被分配的账号
assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to")
class Account(Base):
"""斗鱼账号"""
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(128), nullable=False)
password = Column(String(256), nullable=False)
email = Column(String(128), nullable=False)
email_password = Column(String(256), nullable=False)
email_imap_server = Column(String(128), default="")
email_imap_port = Column(Integer, default=993)
assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
remark = Column(String(256), default="")
created_at = Column(DateTime, default=datetime.utcnow)
assigned_user = relationship("User", back_populates="assigned_accounts", foreign_keys=[assigned_to])
login_tasks = relationship("LoginTask", back_populates="account")
class LoginTask(Base):
"""单次登录任务"""
__tablename__ = "login_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
batch_id = Column(String(64), nullable=False, index=True) # 批次ID
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
status = Column(String(32), default="pending") # pending / running / success / failed / error
cookie = Column(Text, default="")
message = Column(String(512), default="")
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
finished_at = Column(DateTime, nullable=True)
account = relationship("Account", back_populates="login_tasks")
class ProxyConfig(Base):
"""代理配置(全局单条记录)"""
__tablename__ = "proxy_config"
id = Column(Integer, primary_key=True, autoincrement=True)
enabled = Column(Boolean, default=False)
api_url = Column(String(512), default="")
http = Column(String(256), default="")
https = Column(String(256), default="")
# 白名单
whitelist_enabled = Column(Boolean, default=False)
whitelist_uid = Column(String(64), default="")
whitelist_ukey = Column(String(128), default="")
class AuditLog(Base):
"""操作审计日志"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, nullable=True)
username = Column(String(64), default="")
action = Column(String(128), nullable=False)
target = Column(String(256), default="")
detail = Column(Text, default="")
created_at = Column(DateTime, default=datetime.utcnow)
+70
View File
@@ -0,0 +1,70 @@
"""权限点定义与角色默认权限"""
# ---- 权限点 ----
PERMISSIONS = {
# 用户管理
"user:view": "查看用户列表",
"user:create": "创建用户",
"user:edit": "编辑用户",
"user:delete": "删除用户",
"user:assign_permissions": "分配权限",
# 账号管理
"account:view_all": "查看所有账号(完整字段)",
"account:view_assigned": "查看分配给自己的账号(仅用户名)",
"account:import": "导入账号",
"account:assign": "分配账号给客服",
"account:delete": "删除账号",
# 登录任务
"login:batch": "发起批量登录",
"login:view_all": "查看所有登录任务",
"login:view_assigned": "查看自己账号的登录任务",
# Cookie
"cookie:view": "查看 Cookie",
"cookie:export": "导出 Cookie",
# 代理 & 白名单
"proxy:manage": "代理配置管理",
"whitelist:manage": "白名单配置管理",
"whitelist:test": "测试白名单",
# 系统
"system:settings": "系统设置",
"audit:view": "查看审计日志",
}
# ---- 角色默认权限 ----
ROLE_PERMISSIONS = {
"super_admin": list(PERMISSIONS.keys()), # 全部权限
"operation": [
"account:view_all",
"account:import",
"account:assign",
"account:delete",
"login:batch",
"login:view_all",
"cookie:view",
"cookie:export",
"proxy:manage",
"whitelist:manage",
"whitelist:test",
],
"support": [
"account:view_assigned",
"login:view_assigned",
],
}
# 角色中文映射
ROLE_LABELS = {
"super_admin": "超级管理员",
"operation": "运营",
"support": "客服",
}
def get_role_permissions(role: str) -> list[str]:
"""获取角色拥有的权限列表。"""
return ROLE_PERMISSIONS.get(role, [])
def has_permission(role: str, permission: str) -> bool:
"""检查角色是否拥有某权限。"""
return permission in get_role_permissions(role)
View File
Binary file not shown.
+152
View File
@@ -0,0 +1,152 @@
"""账号管理路由"""
import re
import csv
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User, Account, AuditLog
from ..schemas import AccountImport, AccountAssign, AccountOut
from ..deps import get_current_user, require_permission
from ..permissions import has_permission
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$')
def _split_account_line(line: str) -> list[str]:
if '|' in line:
return line.split('|')
if '\t' in line:
return line.split('\t')
if ',' in line:
return next(csv.reader([line]))
return line.split()
@router.get("", response_model=list[AccountOut])
def list_accounts(
assigned_only: bool = Query(False),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""列表:按角色返回不同字段和范围。"""
query = db.query(Account)
# 权限控制:客服只能看分配给自己的
if not has_permission(current.role, "account:view_all"):
if has_permission(current.role, "account:view_assigned"):
query = query.filter(Account.assigned_to == current.id)
else:
raise HTTPException(status_code=403, detail="无权查看账号")
if assigned_only and has_permission(current.role, "account:view_all"):
query = query.filter(Account.assigned_to.isnot(None))
accounts = query.order_by(Account.id).all()
result = []
for acc in accounts:
item = AccountOut(
id=acc.id, username=acc.username, remark=acc.remark or "",
assigned_to=acc.assigned_to,
assigned_username=acc.assigned_user.username if acc.assigned_user else None,
created_at=acc.created_at,
)
# 运营+超管可看完整字段
if has_permission(current.role, "account:view_all"):
item.password = acc.password
item.email = acc.email
item.email_password = acc.email_password
result.append(item)
return result
@router.post("/import")
def import_accounts(
req: AccountImport,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:import")),
):
"""批量导入账号。格式:用户名|密码|邮箱|邮箱密码"""
from douyu.email_verifier import get_email_config_for_account
accounts = []
skipped = 0
for line_num, line in enumerate(req.text.strip().split('\n'), 1):
line = line.strip()
if not line or line.startswith('#'):
continue
parts = _split_account_line(line)
if len(parts) != 4:
skipped += 1
continue
username, password, email, email_password = [p.strip() for p in parts]
if not all([username, password, email, email_password]):
skipped += 1
continue
if not EMAIL_PATTERN.match(email):
skipped += 1
continue
email_cfg = get_email_config_for_account(email)
accounts.append(Account(
username=username,
password=password,
email=email,
email_password=email_password,
email_imap_server=email_cfg['server'],
email_imap_port=email_cfg.get('port', 993),
))
if accounts:
db.add_all(accounts)
db.add(AuditLog(user_id=current.id, username=current.username,
action="account:import", target=f"导入{len(accounts)}"))
db.commit()
return {"message": f"导入成功 {len(accounts)} 个,跳过 {skipped}", "success": True, "count": len(accounts)}
@router.put("/{account_id}/assign")
def assign_account(
account_id: int,
req: AccountAssign,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:assign")),
):
acc = db.query(Account).filter(Account.id == account_id).first()
if not acc:
raise HTTPException(status_code=404, detail="账号不存在")
if req.assigned_to:
target = db.query(User).filter(User.id == req.assigned_to).first()
if not target:
raise HTTPException(status_code=404, detail="目标用户不存在")
if target.role != "support":
raise HTTPException(status_code=400, detail="只能分配给客服角色")
acc.assigned_to = req.assigned_to
db.add(AuditLog(user_id=current.id, username=current.username,
action="account:assign", target=acc.username))
db.commit()
return {"message": "已分配", "success": True}
@router.delete("/{account_id}")
def delete_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:delete")),
):
acc = db.query(Account).filter(Account.id == account_id).first()
if not acc:
raise HTTPException(status_code=404, detail="账号不存在")
db.add(AuditLog(user_id=current.id, username=current.username,
action="account:delete", target=acc.username))
db.delete(acc)
db.commit()
return {"message": "已删除", "success": True}
+57
View File
@@ -0,0 +1,57 @@
"""认证路由"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User, AuditLog
from ..security import verify_password, create_access_token
from ..permissions import get_role_permissions, ROLE_LABELS
from ..schemas import LoginRequest, TokenResponse
from ..deps import get_current_user
router = APIRouter(prefix="/api/auth", tags=["认证"])
@router.post("/login", response_model=TokenResponse)
def login(req: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == req.username).first()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
if not user.is_active:
raise HTTPException(status_code=403, detail="账号已禁用,请联系管理员")
token = create_access_token({"sub": str(user.id), "role": user.role})
perms = get_role_permissions(user.role)
# 审计
db.add(AuditLog(user_id=user.id, username=user.username, action="login", target="auth"))
db.commit()
return TokenResponse(
access_token=token,
role=user.role,
username=user.username,
permissions=perms,
)
@router.get("/me")
def me(current_user: User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"role": current_user.role,
"role_label": ROLE_LABELS.get(current_user.role, current_user.role),
"is_active": current_user.is_active,
"remark": current_user.remark,
"permissions": get_role_permissions(current_user.role),
}
@router.post("/logout")
def logout(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
db.add(AuditLog(user_id=current_user.id, username=current_user.username, action="logout", target="auth"))
db.commit()
return {"message": "已登出"}
+137
View File
@@ -0,0 +1,137 @@
"""登录任务路由 + WebSocket 实时日志"""
import asyncio
import threading
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from ..database import get_db, SessionLocal
from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel
from ..schemas import LoginBatchRequest, LoginTaskOut
from ..deps import get_current_user, require_permission
from ..permissions import has_permission
from ..services.login_service import LoginBatchRunner
router = APIRouter(prefix="/api/login", tags=["登录任务"])
# 运行中的批次: batch_id -> {runner, log_queue, loop}
_active_batches: dict[str, dict] = {}
@router.post("/batch")
def create_batch(
req: LoginBatchRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("login:batch")),
):
"""创建批量登录任务。"""
if not req.account_ids:
raise HTTPException(status_code=400, detail="请选择账号")
# 读取代理配置
proxy = db.query(ProxyConfigModel).first()
# 权限过滤账号
valid_ids = []
for aid in req.account_ids:
acc = db.query(Account).filter(Account.id == aid).first()
if not acc:
continue
if not has_permission(current.role, "login:view_all"):
if acc.assigned_to != current.id:
continue
valid_ids.append(aid)
if not valid_ids:
raise HTTPException(status_code=403, detail="没有可登录的账号")
# 创建执行器(用独立的 DB 会话,因为在线程中运行)
thread_db = SessionLocal()
runner = LoginBatchRunner(
db=thread_db,
account_ids=valid_ids,
created_by=current.id,
creator_role=current.role,
max_geetest_retries=req.max_geetest_retries,
proxy_config=proxy,
)
batch_id = runner.batch_id
# 启动线程
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
return {"batch_id": batch_id, "count": len(valid_ids), "success": True}
@router.get("/tasks", response_model=list[LoginTaskOut])
def list_tasks(
batch_id: str | None = None,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看登录任务列表。"""
query = db.query(LoginTask)
# 客服只能看自己账号的任务
if not has_permission(current.role, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id).filter(
Account.assigned_to == current.id
)
if batch_id:
query = query.filter(LoginTask.batch_id == batch_id)
tasks = query.order_by(LoginTask.id.desc()).limit(200).all()
result = []
for t in tasks:
acc = db.query(Account).filter(Account.id == t.account_id).first()
result.append(LoginTaskOut(
id=t.id, batch_id=t.batch_id, account_id=t.account_id,
account_username=acc.username if acc else "",
status=t.status, cookie=t.cookie or "", message=t.message or "",
created_by=t.created_by, created_at=t.created_at, finished_at=t.finished_at,
))
return result
@router.post("/stop/{batch_id}")
def stop_batch(
batch_id: str,
current: User = Depends(require_permission("login:batch")),
):
batch = _active_batches.get(batch_id)
if batch:
batch["runner"].stop()
return {"message": "已发送停止信号", "success": True}
raise HTTPException(status_code=404, detail="批次不存在或已结束")
@router.websocket("/ws/login/{batch_id}")
async def ws_login_logs(websocket: WebSocket, batch_id: str):
"""WebSocket 推送登录实时日志。"""
await websocket.accept()
log_queue = asyncio.Queue()
loop = asyncio.get_event_loop()
# 查找已运行的批次,或等待新批次
# 简化:直接把 log_queue 注册到全局,前端创建批次后连 ws
_active_batches[batch_id] = {
"log_queue": log_queue,
"loop": loop,
}
try:
while True:
try:
msg = await asyncio.wait_for(log_queue.get(), timeout=30)
await websocket.send_json(msg)
except asyncio.TimeoutError:
await websocket.send_json({"level": "heartbeat", "message": ""})
except WebSocketDisconnect:
pass
finally:
_active_batches.pop(batch_id, None)
+146
View File
@@ -0,0 +1,146 @@
"""代理 & 白名单配置路由"""
import re
import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User, ProxyConfig as ProxyConfigModel, AuditLog
from ..schemas import ProxyConfigOut, ProxyConfigUpdate, MessageResponse
from ..deps import require_permission
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
def _get_or_create(db: Session) -> ProxyConfigModel:
cfg = db.query(ProxyConfigModel).first()
if not cfg:
cfg = ProxyConfigModel()
db.add(cfg)
db.commit()
db.refresh(cfg)
return cfg
@router.get("", response_model=ProxyConfigOut)
def get_proxy_config(
db: Session = Depends(get_db),
_: User = Depends(require_permission("proxy:manage")),
):
return _get_or_create(db)
@router.put("", response_model=ProxyConfigOut)
def update_proxy_config(
req: ProxyConfigUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("proxy:manage")),
):
cfg = _get_or_create(db)
cfg.enabled = req.enabled
cfg.api_url = req.api_url
cfg.http = req.http
cfg.https = req.https
cfg.whitelist_enabled = req.whitelist_enabled
cfg.whitelist_uid = req.whitelist_uid
cfg.whitelist_ukey = req.whitelist_ukey
db.commit()
db.refresh(cfg)
db.add(AuditLog(user_id=current.id, username=current.username,
action="proxy:update", target="proxy_config"))
db.commit()
return cfg
@router.post("/test")
def test_proxy(
db: Session = Depends(get_db),
current: User = Depends(require_permission("proxy:manage")),
):
"""测试代理连通性。"""
import requests as req_lib
cfg = _get_or_create(db)
if not cfg.enabled:
return {"success": False, "message": "代理未启用"}
proxy_url = cfg.http or cfg.https
if cfg.api_url and not proxy_url:
try:
resp = req_lib.get(cfg.api_url, timeout=10)
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', resp.text)
if match:
proxy_url = f"http://{match.group(1)}:{match.group(2)}"
except Exception as e:
return {"success": False, "message": f"代理API请求失败: {e}"}
if not proxy_url:
return {"success": False, "message": "无可用代理地址"}
try:
resp = req_lib.get(
"https://qifu-api.baidubce.com/ip/local/geo/v1/district",
proxies={"http": proxy_url, "https": proxy_url},
timeout=(4, 6),
headers={"User-Agent": "Mozilla/5.0"},
)
resp.raise_for_status()
return {"success": True, "message": f"代理可用,响应: {resp.text[:100]}"}
except Exception as e:
return {"success": False, "message": f"代理验证失败: {e}"}
@router.post("/whitelist/test")
def test_whitelist(
db: Session = Depends(get_db),
current: User = Depends(require_permission("whitelist:test")),
):
"""测试白名单连接并自动同步出口IP。"""
from douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy
import requests as req_lib
cfg = _get_or_create(db)
if not cfg.whitelist_enabled:
return {"success": False, "message": "白名单未启用"}
if not cfg.whitelist_uid or not cfg.whitelist_ukey:
return {"success": False, "message": "未配置白名单UID/UKEY"}
manager = WhitelistManager(cfg.whitelist_uid, cfg.whitelist_ukey)
# 测试API连接
ok, msg = manager.test_connection()
if not ok:
return {"success": False, "message": msg}
# 获取出口IP
proxy_url = cfg.http or cfg.https
if cfg.api_url and not proxy_url:
try:
resp = req_lib.get(cfg.api_url, timeout=10)
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', resp.text)
if match:
proxy_url = f"http://{match.group(1)}:{match.group(2)}"
except Exception:
pass
exit_ip = None
if proxy_url:
exit_ip = get_exit_ip_via_proxy(proxy_url)
if not exit_ip:
try:
resp = req_lib.get("https://4.ipw.cn", timeout=6, headers={"User-Agent": "Mozilla/5.0"})
match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', resp.text)
if match:
exit_ip = match.group(1)
except Exception:
pass
if not exit_ip:
return {"success": False, "message": "API连接正常但无法获取出口IP"}
# 同步白名单
sync_ok, sync_msg = manager.sync_ip(exit_ip)
if sync_ok:
return {"success": True, "message": f"出口IP {exit_ip} 已同步: {sync_msg}"}
return {"success": False, "message": f"同步失败: {sync_msg}"}
+117
View File
@@ -0,0 +1,117 @@
"""用户管理路由(超管)"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User, AuditLog
from ..security import hash_password
from ..permissions import ROLE_LABELS, get_role_permissions
from ..schemas import UserCreate, UserUpdate, UserInfo
from ..deps import require_permission, get_current_user
router = APIRouter(prefix="/api/users", tags=["用户管理"])
@router.get("", response_model=list[UserInfo])
def list_users(
db: Session = Depends(get_db),
_: User = Depends(require_permission("user:view")),
):
users = db.query(User).order_by(User.id).all()
result = []
for u in users:
result.append(UserInfo(
id=u.id,
username=u.username,
role=u.role,
is_active=u.is_active,
remark=u.remark or "",
created_at=u.created_at,
permissions=get_role_permissions(u.role),
))
return result
@router.post("", response_model=UserInfo)
def create_user(
req: UserCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("user:create")),
):
if db.query(User).filter(User.username == req.username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
user = User(
username=req.username,
password_hash=hash_password(req.password),
role=req.role,
remark=req.remark,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
db.add(AuditLog(user_id=current.id, username=current.username,
action="user:create", target=user.username))
db.commit()
return UserInfo(
id=user.id, username=user.username, role=user.role,
is_active=user.is_active, remark=user.remark or "",
permissions=get_role_permissions(user.role),
)
@router.put("/{user_id}", response_model=UserInfo)
def update_user(
user_id: int,
req: UserUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("user:edit")),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if req.password:
user.password_hash = hash_password(req.password)
if req.role is not None:
user.role = req.role
if req.is_active is not None:
user.is_active = req.is_active
if req.remark is not None:
user.remark = req.remark
db.commit()
db.refresh(user)
db.add(AuditLog(user_id=current.id, username=current.username,
action="user:edit", target=user.username))
db.commit()
return UserInfo(
id=user.id, username=user.username, role=user.role,
is_active=user.is_active, remark=user.remark or "",
permissions=get_role_permissions(user.role),
)
@router.delete("/{user_id}")
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("user:delete")),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.role == "super_admin":
raise HTTPException(status_code=400, detail="不能删除超级管理员")
db.add(AuditLog(user_id=current.id, username=current.username,
action="user:delete", target=user.username))
db.delete(user)
db.commit()
return {"message": "已删除", "success": True}
+116
View File
@@ -0,0 +1,116 @@
"""Pydantic 请求/响应模型"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ---- 认证 ----
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
role: str
username: str
permissions: list[str]
class UserInfo(BaseModel):
id: int
username: str
role: str
is_active: bool
remark: str = ""
created_at: Optional[datetime] = None
permissions: list[str] = []
class Config:
from_attributes = True
# ---- 用户管理 ----
class UserCreate(BaseModel):
username: str = Field(..., min_length=2, max_length=64)
password: str = Field(..., min_length=6, max_length=128)
role: str = Field("support", pattern="^(super_admin|operation|support)$")
remark: str = ""
class UserUpdate(BaseModel):
password: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
remark: Optional[str] = None
# ---- 账号 ----
class AccountImport(BaseModel):
"""批量导入,文本格式:用户名|密码|邮箱|邮箱密码"""
text: str
class AccountAssign(BaseModel):
assigned_to: Optional[int] = None
class AccountOut(BaseModel):
id: int
username: str
# 敏感字段根据角色决定是否返回
password: Optional[str] = None
email: Optional[str] = None
email_password: Optional[str] = None
assigned_to: Optional[int] = None
assigned_username: Optional[str] = None
remark: str = ""
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ---- 登录任务 ----
class LoginBatchRequest(BaseModel):
account_ids: list[int]
max_geetest_retries: int = 5
class LoginTaskOut(BaseModel):
id: int
batch_id: str
account_id: int
account_username: str = ""
status: str
cookie: str = ""
message: str = ""
created_by: int
created_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
class Config:
from_attributes = True
# ---- 代理配置 ----
class ProxyConfigOut(BaseModel):
enabled: bool = False
api_url: str = ""
http: str = ""
https: str = ""
whitelist_enabled: bool = False
whitelist_uid: str = ""
whitelist_ukey: str = ""
class ProxyConfigUpdate(ProxyConfigOut):
pass
# ---- 通用 ----
class MessageResponse(BaseModel):
message: str
success: bool = True
+38
View File
@@ -0,0 +1,38 @@
"""安全模块:密码哈希 + JWT"""
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
from jose import jwt, JWTError
SECRET_KEY = "douyu-web-secret-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
def hash_password(password: str) -> str:
pwd_bytes = password.encode("utf-8")
# bcrypt 限制72字节,截断
pwd_bytes = pwd_bytes[:72]
return bcrypt.hashpw(pwd_bytes, bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("utf-8"))
except Exception:
return False
def create_access_token(data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None
View File
+139
View File
@@ -0,0 +1,139 @@
"""登录服务:复用 douyu/ 核心模块,在线程池中执行登录并推送日志。"""
import asyncio
import threading
import time
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from douyu import DouyuLogin
from douyu.config import Account, ProxyConfig as DouyuProxyConfig
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
from ..permissions import has_permission
class LoginBatchRunner:
"""批量登录执行器,在线程中运行,通过 asyncio.Queue 推送日志。"""
def __init__(
self,
db: Session,
account_ids: list[int],
created_by: int,
creator_role: str,
max_geetest_retries: int = 5,
proxy_config: Optional[ProxyConfigModel] = None,
log_queue: Optional[asyncio.Queue] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
):
self.db = db
self.account_ids = account_ids
self.created_by = created_by
self.creator_role = creator_role
self.max_geetest_retries = max_geetest_retries
self.proxy_config = proxy_config
self.log_queue = log_queue
self.loop = loop
self.batch_id = uuid.uuid4().hex[:12]
self._stop = threading.Event()
def stop(self):
self._stop.set()
def _push_log(self, level: str, message: str):
if self.log_queue and self.loop:
asyncio.run_coroutine_threadsafe(
self.log_queue.put({"level": level, "message": message}),
self.loop,
)
def run(self):
"""在线程中执行批量登录。"""
batch_id = self.batch_id
self._push_log("info", f"批量登录任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号")
# 创建任务记录
tasks = []
for aid in self.account_ids:
acc = self.db.query(AccountModel).filter(AccountModel.id == aid).first()
if not acc:
continue
# 权限检查:客服只能跑分配给自己的
if not has_permission(self.creator_role, "login:view_all"):
if acc.assigned_to != self.created_by:
self._push_log("warning", f"跳过无权账号: {acc.username}")
continue
task = LoginTask(
batch_id=batch_id,
account_id=aid,
status="pending",
created_by=self.created_by,
)
self.db.add(task)
tasks.append((task, acc))
self.db.commit()
# 构建代理配置
proxy_url = None
proxy_api_url = None
if self.proxy_config and self.proxy_config.enabled:
if self.proxy_config.http or self.proxy_config.https:
proxy_url = {
'http': self.proxy_config.http or self.proxy_config.https,
'https': self.proxy_config.https or self.proxy_config.http,
}
elif self.proxy_config.api_url:
proxy_api_url = self.proxy_config.api_url
for i, (task, acc) in enumerate(tasks):
if self._stop.is_set():
self._push_log("warning", "任务已停止")
break
task.status = "running"
self.db.commit()
self._push_log("info", f"[{i+1}/{len(tasks)}] 开始登录: {acc.username}")
try:
account = Account(
username=acc.username,
password=acc.password,
email=acc.email,
email_password=acc.email_password,
email_imap_server=acc.email_imap_server or "",
email_imap_port=acc.email_imap_port or 993,
)
loginer = DouyuLogin(
account,
proxy=proxy_url,
proxy_api_url=proxy_api_url,
max_geetest_retries=self.max_geetest_retries,
)
result = loginer.login()
if result.success:
task.status = "success"
task.cookie = result.cookie
task.message = "登录成功"
self._push_log("success", f"[{i+1}] {acc.username} 登录成功")
else:
task.status = "failed"
task.message = result.message
self._push_log("error", f"[{i+1}] {acc.username} 登录失败: {result.message}")
except Exception as e:
task.status = "error"
task.message = str(e)
self._push_log("error", f"[{i+1}] {acc.username} 登录异常: {e}")
task.finished_at = datetime.utcnow()
self.db.commit()
self._push_log("info", f"批量登录任务 {batch_id} 完成")
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4084
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.2.5",
"antd": "^6.4.4",
"axios": "^1.18.0",
"dayjs": "^1.11.21",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.18.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+42
View File
@@ -0,0 +1,42 @@
import { useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import LoginPage from './pages/LoginPage';
import MainLayout from './layouts/MainLayout';
import DashboardPage from './pages/DashboardPage';
import AccountsPage from './pages/AccountsPage';
import LoginTasksPage from './pages/LoginTasksPage';
import ProxyPage from './pages/ProxyPage';
import UsersPage from './pages/UsersPage';
import { getToken } from './store/auth';
function App() {
// 用 state 驱动重渲染,登录/登出时调 refreshAuth()
const [authVersion, setAuthVersion] = useState(0);
const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []);
const isLoggedIn = !!getToken();
return (
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage onLogin={refreshAuth} />} />
<Route
path="/"
element={isLoggedIn ? <MainLayout key={authVersion} onLogout={refreshAuth} /> : <Navigate to="/login" replace />}
>
<Route index element={<DashboardPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="login-tasks" element={<LoginTasksPage />} />
<Route path="proxy" element={<ProxyPage />} />
<Route path="users" element={<UsersPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
);
}
export default App;
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
timeout: 30000,
});
// 请求拦截:携带 token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截:统一错误处理
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
const msg = error.response?.data?.detail || error.message || '请求失败';
return Promise.reject(new Error(msg));
}
);
export default api;
+60
View File
@@ -0,0 +1,60 @@
import api from './index';
export interface LoginResult {
access_token: string;
token_type: string;
role: string;
username: string;
permissions: string[];
}
export interface UserInfo {
id: number;
username: string;
role: string;
is_active: boolean;
remark: string;
permissions: string[];
}
export const authApi = {
login: (username: string, password: string) =>
api.post<any, LoginResult>('/auth/login', { username, password }),
me: () => api.get<any, any>('/auth/me'),
logout: () => api.post<any, any>('/auth/logout'),
};
export const userApi = {
list: () => api.get<any, UserInfo[]>('/users'),
create: (data: { username: string; password: string; role: string; remark?: string }) =>
api.post<any, UserInfo>('/users', data),
update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string }) =>
api.put<any, UserInfo>(`/users/${id}`, data),
delete: (id: number) => api.delete<any, any>(`/users/${id}`),
};
export const accountApi = {
list: (assigned_only?: boolean) =>
api.get<any, any[]>('/accounts', { params: assigned_only ? { assigned_only: true } : {} }),
import: (text: string) => api.post<any, any>('/accounts/import', { text }),
assign: (id: number, assigned_to: number | null) =>
api.put<any, any>(`/accounts/${id}/assign`, { assigned_to }),
delete: (id: number) => api.delete<any, any>(`/accounts/${id}`),
};
export const loginApi = {
createBatch: (account_ids: number[], max_geetest_retries?: number) =>
api.post<any, any>('/login/batch', { account_ids, max_geetest_retries }),
listTasks: (batch_id?: string) =>
api.get<any, any[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
stop: (batch_id: string) => api.post<any, any>(`/login/stop/${batch_id}`),
};
export const proxyApi = {
get: () => api.get<any, any>('/proxy'),
update: (data: any) => api.put<any, any>('/proxy', data),
test: () => api.post<any, any>('/proxy/test'),
testWhitelist: () => api.post<any, any>('/proxy/whitelist/test'),
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+9
View File
@@ -0,0 +1,9 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react';
import { Layout, Menu, Dropdown, Avatar, Space, Typography } from 'antd';
import {
DashboardOutlined, UserOutlined, LogoutOutlined,
CloudServerOutlined, TeamOutlined, ApiOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth';
import { authApi } from '../api/modules';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const ROLE_LABELS: Record<string, string> = {
super_admin: '超级管理员',
operation: '运营',
support: '客服',
};
export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
const navigate = useNavigate();
const location = useLocation();
const [user] = useState<AuthUser | null>(getUser());
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
if (!user) navigate('/login');
}, [user]);
if (!user) return null;
const menuItems: any[] = [];
// Dashboard - 所有人可见
menuItems.push({ key: '/', label: '概览', icon: <DashboardOutlined /> });
// 账号管理
if (hasPerm(user, 'account:view_all') || hasPerm(user, 'account:view_assigned')) {
menuItems.push({ key: '/accounts', label: '账号管理', icon: <UserOutlined /> });
}
// 登录任务
if (hasPerm(user, 'login:batch') || hasPerm(user, 'login:view_assigned')) {
menuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
}
// 代理配置
if (hasPerm(user, 'proxy:manage')) {
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
}
// 用户管理
if (hasPerm(user, 'user:view')) {
menuItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
}
const handleLogout = async () => {
try {
await authApi.logout();
} catch {}
clearAuth();
onLogout?.(); // 触发 App 重渲染
navigate('/login', { replace: true });
};
const userMenu = {
items: [
{
key: 'logout',
label: '退出登录',
icon: <LogoutOutlined />,
onClick: handleLogout,
},
],
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider collapsible collapsed={collapsed} onCollapse={setCollapsed}>
<div style={{
height: 48, margin: 12, color: '#fff', fontSize: 16,
textAlign: 'center', lineHeight: '48px', fontWeight: 'bold',
whiteSpace: 'nowrap', overflow: 'hidden',
}}>
{collapsed ? '鱼' : '斗鱼登录后台'}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header style={{
background: '#fff', padding: '0 24px',
display: 'flex', justifyContent: 'flex-end', alignItems: 'center',
}}>
<Dropdown menu={userMenu} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} />
<Text>{user.username}</Text>
<Text type="secondary">({ROLE_LABELS[user.role] || user.role})</Text>
</Space>
</Dropdown>
</Header>
<Content style={{ margin: 16, padding: 24, background: '#fff', borderRadius: 8 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react';
import {
Table, Button, Modal, Input, Select, message, Popconfirm, Typography,
} from 'antd';
import { ImportOutlined, DeleteOutlined } from '@ant-design/icons';
import { accountApi, userApi } from '../api/modules';
import { getUser, hasPerm } from '../store/auth';
const { TextArea } = Input;
const { Text } = Typography;
export default function AccountsPage() {
const [accounts, setAccounts] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState('');
const [importing, setImporting] = useState(false);
const user = getUser();
const canViewAll = hasPerm(user, 'account:view_all');
const canImport = hasPerm(user, 'account:import');
const canAssign = hasPerm(user, 'account:assign');
const canDelete = hasPerm(user, 'account:delete');
const loadAccounts = async () => {
setLoading(true);
try {
const data = await accountApi.list();
setAccounts(data);
} catch (e: any) {
message.error(e.message);
} finally {
setLoading(false);
}
};
const loadUsers = async () => {
try {
const data = await userApi.list();
setUsers(data.filter((u: any) => u.role === 'support'));
} catch {}
};
useEffect(() => {
loadAccounts();
if (canAssign) loadUsers();
}, []);
const handleImport = async () => {
if (!importText.trim()) {
message.warning('请输入账号数据');
return;
}
setImporting(true);
try {
const result = await accountApi.import(importText);
message.success(result.message);
setImportOpen(false);
setImportText('');
loadAccounts();
} catch (e: any) {
message.error(e.message);
} finally {
setImporting(false);
}
};
const handleAssign = async (accountId: number, assignedTo: number | null) => {
try {
await accountApi.assign(accountId, assignedTo);
message.success('已分配');
loadAccounts();
} catch (e: any) {
message.error(e.message);
}
};
const handleDelete = async (id: number) => {
try {
await accountApi.delete(id);
message.success('已删除');
loadAccounts();
} catch (e: any) {
message.error(e.message);
}
};
const columns: any[] = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' },
];
if (canViewAll) {
columns.push(
{ title: '密码', dataIndex: 'password', width: 120 },
{ title: '邮箱', dataIndex: 'email' },
{ title: '邮箱密码', dataIndex: 'email_password', width: 120 },
);
}
columns.push({
title: '分配给',
dataIndex: 'assigned_username',
render: (_: any, record: any) => {
if (canAssign) {
return (
<Select
style={{ width: 140 }}
allowClear
placeholder="未分配"
value={record.assigned_to}
onChange={(val) => handleAssign(record.id, val ?? null)}
options={users.map((u: any) => ({ value: u.id, label: u.username }))}
/>
);
}
return record.assigned_username || <Text type="secondary"></Text>;
},
});
if (canDelete) {
columns.push({
title: '操作',
width: 80,
render: (_: any, record: any) => (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />}></Button>
</Popconfirm>
),
});
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
{canImport && (
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
</Button>
)}
</div>
<Table
columns={columns}
dataSource={accounts}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 20 }}
/>
<Modal
title="批量导入账号"
open={importOpen}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
confirmLoading={importing}
okText="导入"
width={600}
>
<Text type="secondary">
|||
</Text>
<TextArea
rows={10}
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder="用户名|密码|邮箱|邮箱密码&#10;用户名|密码|邮箱|邮箱密码"
style={{ marginTop: 8 }}
/>
</Modal>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Card, Col, Row, Statistic } from 'antd';
import { useEffect, useState } from 'react';
import { accountApi, loginApi } from '../api/modules';
export default function DashboardPage() {
const [stats, setStats] = useState({ accounts: 0, tasks: 0, success: 0, failed: 0 });
useEffect(() => {
Promise.all([accountApi.list(), loginApi.listTasks()])
.then(([accounts, tasks]) => {
setStats({
accounts: accounts.length,
tasks: tasks.length,
success: tasks.filter((t: any) => t.status === 'success').length,
failed: tasks.filter((t: any) => ['failed', 'error'].includes(t.status)).length,
});
})
.catch(() => {});
}, []);
return (
<div>
<h2></h2>
<Row gutter={16}>
<Col span={6}>
<Card>
<Statistic title="账号总数" value={stats.accounts} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="登录任务总数" value={stats.tasks} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="成功" value={stats.success} valueStyle={{ color: '#3f8600' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="失败" value={stats.failed} valueStyle={{ color: '#cf1322' }} />
</Card>
</Col>
</Row>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { Card, Form, Input, Button, message, Typography } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { authApi } from '../api/modules';
import { setAuth, type AuthUser } from '../store/auth';
const { Title } = Typography;
export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const result = await authApi.login(values.username, values.password);
const user: AuthUser = {
id: 0,
username: result.username,
role: result.role,
permissions: result.permissions,
};
setAuth(result.access_token, user);
message.success('登录成功');
onLogin?.(); // 触发 App 重渲染
navigate('/', { replace: true });
} catch (e: any) {
message.error(e.message || '登录失败');
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Card style={{ width: 380, boxShadow: '0 8px 24px rgba(0,0,0,0.15)' }}>
<Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
</Title>
<Form onFinish={onFinish} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
默认管理员: admin / admin123
</div>
</Card>
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
import { useEffect, useState, useRef } from 'react';
import {
Table, Button, Select, message, Tag, Space, Card, Row, Col, Statistic, Spin,
} from 'antd';
import { PlayCircleOutlined, StopOutlined } from '@ant-design/icons';
import { accountApi, loginApi } from '../api/modules';
import { getUser, hasPerm } from '../store/auth';
const STATUS_COLORS: Record<string, string> = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
};
const STATUS_LABELS: Record<string, string> = {
pending: '等待中',
running: '登录中',
success: '成功',
failed: '失败',
error: '异常',
};
export default function LoginTasksPage() {
const [accounts, setAccounts] = useState<any[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [tasks, setTasks] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [batchId, setBatchId] = useState<string | null>(null);
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const user = getUser();
const canBatch = hasPerm(user, 'login:batch');
const loadAccounts = async () => {
try {
const data = await accountApi.list();
setAccounts(data);
} catch (e: any) {
message.error(e.message);
}
};
const loadTasks = async () => {
try {
const data = await loginApi.listTasks(batchId || undefined);
setTasks(data);
} catch {}
};
useEffect(() => {
loadAccounts();
}, []);
useEffect(() => {
const timer = setInterval(loadTasks, 3000);
return () => clearInterval(timer);
}, [batchId]);
const handleBatchLogin = async () => {
if (selectedIds.length === 0) {
message.warning('请选择账号');
return;
}
setLoading(true);
setLogs([]);
try {
const result = await loginApi.createBatch(selectedIds);
setBatchId(result.batch_id);
message.success(`已创建登录任务,共 ${result.count} 个账号`);
// 连接 WebSocket
const wsUrl = `ws://${window.location.hostname}:8000/api/login/ws/login/${result.batch_id}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.level === 'heartbeat') return;
setLogs((prev) => [...prev, msg]);
};
ws.onclose = () => {
wsRef.current = null;
};
} catch (e: any) {
message.error(e.message);
} finally {
setLoading(false);
}
};
const handleStop = async () => {
if (batchId) {
try {
await loginApi.stop(batchId);
message.success('已发送停止信号');
} catch (e: any) {
message.error(e.message);
}
}
};
const successCount = tasks.filter((t) => t.status === 'success').length;
const failedCount = tasks.filter((t) => ['failed', 'error'].includes(t.status)).length;
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '账号', dataIndex: 'account_username' },
{
title: '状态',
dataIndex: 'status',
render: (status: string) => <Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status] || status}</Tag>,
},
{ title: '消息', dataIndex: 'message', ellipsis: true },
{ title: '时间', dataIndex: 'created_at', width: 180 },
];
return (
<div>
<h2></h2>
{canBatch && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space>
<Select
mode="multiple"
style={{ minWidth: 400 }}
placeholder="选择要登录的账号"
value={selectedIds}
onChange={setSelectedIds}
options={accounts.map((a) => ({ value: a.id, label: a.username }))}
maxTagCount="responsive"
/>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={loading}
onClick={handleBatchLogin}
>
</Button>
{batchId && (
<Button danger icon={<StopOutlined />} onClick={handleStop}>
</Button>
)}
</Space>
</Card>
)}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card size="small"><Statistic title="任务总数" value={tasks.length} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="成功" value={successCount} valueStyle={{ color: '#3f8600' }} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="失败" value={failedCount} valueStyle={{ color: '#cf1322' }} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="当前批次" value={batchId || '-'} /></Card>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Card title="任务列表" size="small">
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
pagination={{ pageSize: 15 }}
/>
</Card>
</Col>
<Col span={10}>
<Card
title="实时日志"
size="small"
bodyStyle={{ maxHeight: 500, overflow: 'auto', fontFamily: 'monospace', fontSize: 12 }}
>
{logs.length === 0 ? (
<Spin spinning={!!batchId} size="small" />
) : (
logs.map((log, i) => (
<div
key={i}
style={{
color:
log.level === 'error' ? '#ff0000' :
log.level === 'success' ? '#008000' :
log.level === 'warning' ? '#FF8C00' :
'#333',
}}
>
{log.message}
</div>
))
)}
</Card>
</Col>
</Row>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { Form, Input, Switch, Button, Card, message, Divider, Space } from 'antd';
import { proxyApi } from '../api/modules';
export default function ProxyPage() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [testingWl, setTestingWl] = useState(false);
const loadConfig = async () => {
try {
const data = await proxyApi.get();
form.setFieldsValue(data);
} catch (e: any) {
message.error(e.message);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
setLoading(true);
try {
const values = await form.validateFields();
await proxyApi.update(values);
message.success('已保存');
} catch (e: any) {
message.error(e.message);
} finally {
setLoading(false);
}
};
const handleTestProxy = async () => {
setTesting(true);
try {
const result = await proxyApi.test();
if (result.success) {
message.success(result.message);
} else {
message.warning(result.message);
}
} catch (e: any) {
message.error(e.message);
} finally {
setTesting(false);
}
};
const handleTestWhitelist = async () => {
setTestingWl(true);
try {
const result = await proxyApi.testWhitelist();
if (result.success) {
message.success(result.message);
} else {
message.warning(result.message);
}
} catch (e: any) {
message.error(e.message);
} finally {
setTestingWl(false);
}
};
return (
<div>
<h2></h2>
<Form form={form} layout="vertical" style={{ maxWidth: 600 }}>
<Card title="代理设置" size="small" style={{ marginBottom: 16 }}>
<Form.Item name="enabled" label="启用代理" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="api_url" label="代理API地址">
<Input placeholder="http://op.xiequ.cn/...?act=get" />
</Form.Item>
<Form.Item name="http" label="静态HTTP代理">
<Input placeholder="http://ip:port" />
</Form.Item>
<Form.Item name="https" label="静态HTTPS代理">
<Input placeholder="http://ip:port" />
</Form.Item>
<Button onClick={handleTestProxy} loading={testing}></Button>
</Card>
<Card title="白名单管理" size="small">
<Form.Item name="whitelist_enabled" label="启用白名单自动管理" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="whitelist_uid" label="协固UID">
<Input placeholder="如: 99769" />
</Form.Item>
<Form.Item name="whitelist_ukey" label="协固UKEY">
<Input placeholder="如: C99371082B965B70F46DCAA87A04618B" />
</Form.Item>
<Button onClick={handleTestWhitelist} loading={testingWl}></Button>
</Card>
<Divider />
<Space>
<Button type="primary" onClick={handleSave} loading={loading}></Button>
</Space>
</Form>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import {
Table, Button, Modal, Form, Input, Select, Tag, Popconfirm, message, Space,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { userApi, type UserInfo } from '../api/modules';
const ROLE_OPTIONS = [
{ value: 'super_admin', label: '超级管理员' },
{ value: 'operation', label: '运营' },
{ value: 'support', label: '客服' },
];
const ROLE_COLORS: Record<string, string> = {
super_admin: 'red',
operation: 'blue',
support: 'green',
};
export default function UsersPage() {
const [users, setUsers] = useState<UserInfo[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<UserInfo | null>(null);
const [form] = Form.useForm();
const loadUsers = async () => {
setLoading(true);
try {
const data = await userApi.list();
setUsers(data);
} catch (e: any) {
message.error(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
const handleCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ role: 'support' });
setModalOpen(true);
};
const handleEdit = (user: UserInfo) => {
setEditing(user);
form.setFieldsValue({
username: user.username,
role: user.role,
remark: user.remark,
});
setModalOpen(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
if (editing) {
const updateData: any = { role: values.role, remark: values.remark };
if (values.password) updateData.password = values.password;
await userApi.update(editing.id, updateData);
message.success('已更新');
} else {
await userApi.create(values);
message.success('已创建');
}
setModalOpen(false);
loadUsers();
} catch (e: any) {
message.error(e.message);
}
};
const handleDelete = async (id: number) => {
try {
await userApi.delete(id);
message.success('已删除');
loadUsers();
} catch (e: any) {
message.error(e.message);
}
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' },
{
title: '角色',
dataIndex: 'role',
render: (role: string) => {
const label = ROLE_OPTIONS.find((r) => r.value === role)?.label || role;
return <Tag color={ROLE_COLORS[role] || 'default'}>{label}</Tag>;
},
},
{
title: '状态',
dataIndex: 'is_active',
render: (active: boolean) => active ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{ title: '备注', dataIndex: 'remark' },
{
title: '操作',
width: 160,
render: (_: any, record: UserInfo) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
{record.role !== 'super_admin' && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />}></Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 20 }}
/>
<Modal
title={editing ? '编辑用户' : '新建用户'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
okText="保存"
>
<Form form={form} layout="vertical">
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input disabled={!!editing} />
</Form.Item>
<Form.Item
name="password"
label={editing ? '新密码(留空不修改)' : '密码'}
rules={editing ? [] : [{ required: true, message: '请输入密码' }]}
>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { authApi } from '../api/modules';
export interface AuthUser {
id: number;
username: string;
role: string;
role_label?: string;
permissions: string[];
}
const TOKEN_KEY = 'token';
const USER_KEY = 'user';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getUser(): AuthUser | null {
const raw = localStorage.getItem(USER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export function setAuth(token: string, user: AuthUser) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export async function fetchCurrentUser(): Promise<AuthUser | null> {
const token = getToken();
if (!token) return null;
try {
const data = await authApi.me();
return {
id: data.id,
username: data.username,
role: data.role,
role_label: data.role_label,
permissions: data.permissions,
};
} catch {
clearAuth();
return null;
}
}
export function hasPerm(user: AuthUser | null, perm: string): boolean {
if (!user) return false;
return user.permissions.includes(perm);
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
ws: true,
},
},
},
})