初步增加 web 界面
This commit is contained in:
BIN
Binary file not shown.
+71
-20
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from datetime import datetime
|
||||
@@ -566,17 +567,15 @@ class DouyuLoginApp:
|
||||
self.whitelist_status_var.set(status)
|
||||
self.whitelist_test_btn.configure(state='normal' if enabled else 'disabled')
|
||||
|
||||
@staticmethod
|
||||
def _get_exit_ip_for_test(proxy_config: ProxyConfig) -> Optional[str]:
|
||||
def _get_exit_ip_for_test(self, proxy_config: ProxyConfig) -> Optional[str]:
|
||||
"""获取出口IP用于白名单测试"""
|
||||
proxy_url = None
|
||||
if proxy_config.enabled:
|
||||
if proxy_config.api_url:
|
||||
try:
|
||||
resp = requests.get(proxy_config.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)}"
|
||||
resp.raise_for_status()
|
||||
proxy_url = DouyuLoginApp._parse_proxy_url(resp.text.strip())
|
||||
except Exception:
|
||||
pass
|
||||
if not proxy_url:
|
||||
@@ -755,15 +754,37 @@ class DouyuLoginApp:
|
||||
self.proxy_test_queue.put(result)
|
||||
|
||||
@staticmethod
|
||||
def _fetch_proxy_from_api(api_url: str) -> str:
|
||||
"""从代理API获取一个代理地址。"""
|
||||
response = requests.get(api_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
text = response.text.strip()
|
||||
def _parse_proxy_url(text: str) -> str:
|
||||
"""从代理API响应中解析代理地址。"""
|
||||
# ip:port 格式
|
||||
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text)
|
||||
if not match:
|
||||
return ''
|
||||
return f'http://{match.group(1)}:{match.group(2)}'
|
||||
if match:
|
||||
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
|
||||
def _extract_origin_ip(response: requests.Response) -> str:
|
||||
@@ -795,7 +816,7 @@ class DouyuLoginApp:
|
||||
'http': proxy_config.http or proxy_url,
|
||||
'https': proxy_config.https or proxy_url,
|
||||
}
|
||||
last_error = ''
|
||||
errors = []
|
||||
|
||||
for name, url in PROXY_TEST_TARGETS:
|
||||
try:
|
||||
@@ -809,28 +830,58 @@ class DouyuLoginApp:
|
||||
origin = DouyuLoginApp._extract_origin_ip(response)
|
||||
return True, f'代理可用,出口IP: {origin}({name})'
|
||||
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:
|
||||
"""获取并验证可用代理,成功后返回可用于登录的代理配置。"""
|
||||
attempts = PROXY_API_TEST_ATTEMPTS if proxy_config.api_url else PROXY_STATIC_TEST_ATTEMPTS
|
||||
last_message = ''
|
||||
synced_whitelist = False
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
proxy_url = proxy_config.http or proxy_config.https
|
||||
if proxy_config.api_url:
|
||||
self.log_queue.put(('info', f'代理预检 {attempt}/{attempts}: 正在获取代理'))
|
||||
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:
|
||||
last_message = f'代理API请求失败: {exc}'
|
||||
self.log_queue.put(('warning', f'代理预检 {attempt}/{attempts}: {last_message}'))
|
||||
continue
|
||||
|
||||
if not proxy_url:
|
||||
last_message = '代理API未返回有效的 ip:port'
|
||||
self.log_queue.put(('warning', f'代理预检 {attempt}/{attempts}: {last_message}'))
|
||||
continue
|
||||
else:
|
||||
|
||||
@@ -118,6 +118,7 @@ def main():
|
||||
parser.add_argument('-i', '--index', type=int, default=0, help='单账号登录时的账号索引')
|
||||
parser.add_argument('-b', '--batch', action='store_true', help='批量登录模式')
|
||||
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='详细日志')
|
||||
|
||||
args = parser.parse_args()
|
||||
@@ -133,6 +134,12 @@ def main():
|
||||
app.run()
|
||||
return
|
||||
|
||||
# 启动Web后台
|
||||
if args.web:
|
||||
from web.backend.main import run
|
||||
run()
|
||||
return
|
||||
|
||||
# 加载配置
|
||||
try:
|
||||
config = Config(args.config)
|
||||
|
||||
+8
-1
@@ -12,6 +12,13 @@ dependencies = [
|
||||
"Pillow>=10.0.0",
|
||||
"PyYAML>=6.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]
|
||||
@@ -22,4 +29,4 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["douyu", "geetest", "utils", "gui"]
|
||||
packages = ["douyu", "geetest", "utils", "gui", "web"]
|
||||
|
||||
@@ -5,3 +5,11 @@ opencv-python-headless>=4.8.0
|
||||
Pillow>=10.0.0
|
||||
PyYAML>=6.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
@@ -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
|
||||
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.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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}
|
||||
@@ -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": "已登出"}
|
||||
@@ -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)
|
||||
@@ -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}"}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
@@ -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} 完成")
|
||||
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+4084
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,9 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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="用户名|密码|邮箱|邮箱密码 用户名|密码|邮箱|邮箱密码"
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user