新增虎牙账号和任务基础功能
This commit is contained in:
+2
-1
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .database import init_db
|
||||
from .routers import auth, users, accounts, login, proxy, cookies
|
||||
from .routers import auth, users, accounts, login, proxy, cookies, huya
|
||||
from .schemas import AppInfo
|
||||
from .version import get_app_version
|
||||
from utils import setup_logger
|
||||
@@ -69,6 +69,7 @@ app.include_router(accounts.router)
|
||||
app.include_router(login.router)
|
||||
app.include_router(proxy.router)
|
||||
app.include_router(cookies.router)
|
||||
app.include_router(huya.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""新增虎牙基础数据表
|
||||
|
||||
Revision ID: 20260704_0004
|
||||
Revises: 20260624_0003
|
||||
Create Date: 2026-07-04
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260704_0004"
|
||||
down_revision: Union[str, None] = "20260624_0003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_table(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _indexes(bind, table_name: str) -> set[str]:
|
||||
if not _has_table(bind, table_name):
|
||||
return set()
|
||||
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None:
|
||||
if name not in _indexes(bind, table_name):
|
||||
op.create_index(name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _has_table(bind, "huya_accounts"):
|
||||
op.create_table(
|
||||
"huya_accounts",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("uid", sa.String(length=32), nullable=True),
|
||||
sa.Column("yyuid", sa.String(length=32), nullable=True),
|
||||
sa.Column("username", sa.String(length=128), nullable=True),
|
||||
sa.Column("nickname", sa.String(length=128), nullable=True),
|
||||
sa.Column("cookie", sa.Text(), nullable=False),
|
||||
sa.Column("tag", sa.String(length=64), nullable=True),
|
||||
sa.Column("remark", sa.String(length=256), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=True),
|
||||
sa.Column("points", sa.Integer(), nullable=True),
|
||||
sa.Column("game_name", sa.String(length=128), nullable=True),
|
||||
sa.Column("game_channel", sa.String(length=64), nullable=True),
|
||||
sa.Column("game_phone", sa.String(length=64), nullable=True),
|
||||
sa.Column("assigned_to", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["assigned_to"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_create_index_if_missing(bind, "ix_huya_accounts_uid", "huya_accounts", ["uid"])
|
||||
_create_index_if_missing(bind, "ix_huya_accounts_yyuid", "huya_accounts", ["yyuid"])
|
||||
_create_index_if_missing(bind, "ix_huya_accounts_assigned_to", "huya_accounts", ["assigned_to"])
|
||||
|
||||
if not _has_table(bind, "huya_tasks"):
|
||||
op.create_table(
|
||||
"huya_tasks",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("batch_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||
sa.Column("task_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=True),
|
||||
sa.Column("message", sa.String(length=512), nullable=True),
|
||||
sa.Column("result", sa.JSON(), nullable=True),
|
||||
sa.Column("created_by", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["huya_accounts.id"]),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_create_index_if_missing(bind, "ix_huya_tasks_batch_id", "huya_tasks", ["batch_id"])
|
||||
_create_index_if_missing(bind, "ix_huya_tasks_task_type", "huya_tasks", ["task_type"])
|
||||
|
||||
if not _has_table(bind, "huya_config"):
|
||||
op.create_table(
|
||||
"huya_config",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("room_pid", sa.String(length=64), nullable=True),
|
||||
sa.Column("sid", sa.String(length=32), nullable=True),
|
||||
sa.Column("outer_act_id", sa.String(length=32), nullable=True),
|
||||
sa.Column("bind_act_id", sa.String(length=32), nullable=True),
|
||||
sa.Column("pay_channel", sa.String(length=16), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if not _has_table(bind, "huya_goods_snapshot"):
|
||||
op.create_table(
|
||||
"huya_goods_snapshot",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("product_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("name", sa.String(length=256), nullable=True),
|
||||
sa.Column("price", sa.Integer(), nullable=True),
|
||||
sa.Column("remain_text", sa.String(length=64), nullable=True),
|
||||
sa.Column("raw", sa.JSON(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_create_index_if_missing(bind, "ix_huya_goods_snapshot_product_id", "huya_goods_snapshot", ["product_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _has_table(bind, "huya_goods_snapshot"):
|
||||
op.drop_index("ix_huya_goods_snapshot_product_id", table_name="huya_goods_snapshot")
|
||||
op.drop_table("huya_goods_snapshot")
|
||||
if _has_table(bind, "huya_config"):
|
||||
op.drop_table("huya_config")
|
||||
if _has_table(bind, "huya_tasks"):
|
||||
op.drop_index("ix_huya_tasks_task_type", table_name="huya_tasks")
|
||||
op.drop_index("ix_huya_tasks_batch_id", table_name="huya_tasks")
|
||||
op.drop_table("huya_tasks")
|
||||
if _has_table(bind, "huya_accounts"):
|
||||
op.drop_index("ix_huya_accounts_assigned_to", table_name="huya_accounts")
|
||||
op.drop_index("ix_huya_accounts_yyuid", table_name="huya_accounts")
|
||||
op.drop_index("ix_huya_accounts_uid", table_name="huya_accounts")
|
||||
op.drop_table("huya_accounts")
|
||||
@@ -30,6 +30,7 @@ class User(Base):
|
||||
|
||||
# 客服被分配的账号
|
||||
assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to")
|
||||
huya_accounts = relationship("HuyaAccount", back_populates="assigned_user", foreign_keys="HuyaAccount.assigned_to")
|
||||
|
||||
|
||||
class Account(Base):
|
||||
@@ -70,6 +71,75 @@ class LoginTask(Base):
|
||||
account = relationship("Account", back_populates="login_tasks")
|
||||
|
||||
|
||||
class HuyaAccount(Base):
|
||||
"""虎牙 Cookie 账号"""
|
||||
__tablename__ = "huya_accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
uid = Column(String(32), default="", index=True)
|
||||
yyuid = Column(String(32), default="", index=True)
|
||||
username = Column(String(128), default="")
|
||||
nickname = Column(String(128), default="")
|
||||
cookie = Column(EncryptedText(), nullable=False)
|
||||
tag = Column(String(64), default="")
|
||||
remark = Column(String(256), default="")
|
||||
status = Column(String(32), default="imported")
|
||||
points = Column(Integer, nullable=True)
|
||||
game_name = Column(String(128), default="")
|
||||
game_channel = Column(String(64), default="")
|
||||
game_phone = Column(String(64), default="")
|
||||
assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
assigned_user = relationship("User", back_populates="huya_accounts", foreign_keys=[assigned_to])
|
||||
tasks = relationship("HuyaTask", back_populates="account")
|
||||
|
||||
|
||||
class HuyaTask(Base):
|
||||
"""虎牙业务任务"""
|
||||
__tablename__ = "huya_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
batch_id = Column(String(64), nullable=False, index=True)
|
||||
account_id = Column(Integer, ForeignKey("huya_accounts.id"), nullable=False)
|
||||
task_type = Column(String(64), nullable=False, index=True)
|
||||
status = Column(String(32), default="pending")
|
||||
message = Column(String(512), default="")
|
||||
result = Column(JSON, nullable=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
|
||||
account = relationship("HuyaAccount", back_populates="tasks")
|
||||
|
||||
|
||||
class HuyaConfig(Base):
|
||||
"""虎牙业务配置"""
|
||||
__tablename__ = "huya_config"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
room_pid = Column(String(64), default="")
|
||||
sid = Column(String(32), default="")
|
||||
outer_act_id = Column(String(32), default="9504")
|
||||
bind_act_id = Column(String(32), default="17096")
|
||||
pay_channel = Column(String(16), default="Zfb")
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
class HuyaGoodsSnapshot(Base):
|
||||
"""虎牙兑换商品快照"""
|
||||
__tablename__ = "huya_goods_snapshot"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
product_id = Column(String(64), nullable=False, index=True)
|
||||
name = Column(String(256), default="")
|
||||
price = Column(Integer, nullable=True)
|
||||
remain_text = Column(String(64), default="")
|
||||
raw = Column(JSON, nullable=True)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
class ProxyConfig(Base):
|
||||
"""代理配置(全局单条记录)"""
|
||||
__tablename__ = "proxy_config"
|
||||
|
||||
@@ -22,6 +22,12 @@ PERMISSIONS = {
|
||||
# Cookie
|
||||
"cookie:view": "查看 Cookie",
|
||||
"cookie:export": "导出 Cookie",
|
||||
# 虎牙
|
||||
"huya:account": "虎牙 CK 管理",
|
||||
"huya:task": "虎牙任务管理",
|
||||
"huya:bind": "虎牙绑定操作",
|
||||
"huya:recharge": "虎牙充值操作",
|
||||
"huya:config": "虎牙配置管理",
|
||||
# 代理 & 白名单
|
||||
"proxy:manage": "代理配置管理",
|
||||
"whitelist:manage": "白名单配置管理",
|
||||
@@ -42,6 +48,11 @@ ROLE_PERMISSIONS = {
|
||||
"login:view_all",
|
||||
"cookie:view",
|
||||
"cookie:export",
|
||||
"huya:account",
|
||||
"huya:task",
|
||||
"huya:bind",
|
||||
"huya:recharge",
|
||||
"huya:config",
|
||||
"proxy:manage",
|
||||
"whitelist:manage",
|
||||
"whitelist:test",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""虎牙基础管理路由"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import authenticate_websocket, require_permission
|
||||
from ..models import HuyaAccount, HuyaConfig, HuyaGoodsSnapshot, HuyaTask, User
|
||||
from ..schemas import (
|
||||
HuyaAccountOut,
|
||||
HuyaConfigOut,
|
||||
HuyaConfigUpdate,
|
||||
HuyaCookieImport,
|
||||
HuyaGoodsOut,
|
||||
HuyaTaskBatchRequest,
|
||||
HuyaTaskOut,
|
||||
)
|
||||
from ..services.huya_service import (
|
||||
SUPPORTED_TASK_TYPES,
|
||||
create_planned_tasks,
|
||||
ensure_huya_config,
|
||||
import_huya_cookies,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/huya", tags=["虎牙"])
|
||||
|
||||
|
||||
def _fmt_cookie_preview(cookie: str) -> str:
|
||||
if not cookie:
|
||||
return ""
|
||||
return cookie[:50] + "..." if len(cookie) > 50 else cookie
|
||||
|
||||
|
||||
def _account_out(account: HuyaAccount) -> HuyaAccountOut:
|
||||
cookie = account.cookie or ""
|
||||
return HuyaAccountOut(
|
||||
id=account.id,
|
||||
uid=account.uid or "",
|
||||
yyuid=account.yyuid or "",
|
||||
username=account.username or "",
|
||||
nickname=account.nickname or "",
|
||||
cookie=cookie,
|
||||
cookie_preview=_fmt_cookie_preview(cookie),
|
||||
tag=account.tag or "",
|
||||
remark=account.remark or "",
|
||||
status=account.status or "",
|
||||
points=account.points,
|
||||
game_name=account.game_name or "",
|
||||
game_channel=account.game_channel or "",
|
||||
game_phone=account.game_phone or "",
|
||||
assigned_to=account.assigned_to,
|
||||
assigned_username=account.assigned_user.username if account.assigned_user else None,
|
||||
created_at=account.created_at,
|
||||
updated_at=account.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _task_out(task: HuyaTask) -> HuyaTaskOut:
|
||||
account = task.account
|
||||
return HuyaTaskOut(
|
||||
id=task.id,
|
||||
batch_id=task.batch_id,
|
||||
account_id=task.account_id,
|
||||
account_uid=account.uid if account else "",
|
||||
account_nickname=account.nickname if account else "",
|
||||
task_type=task.task_type,
|
||||
status=task.status or "",
|
||||
message=task.message or "",
|
||||
result=task.result,
|
||||
created_by=task.created_by,
|
||||
created_at=task.created_at,
|
||||
finished_at=task.finished_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/task-types")
|
||||
def task_types(current: User = Depends(require_permission("huya:task"))):
|
||||
"""返回当前规划的虎牙任务类型。"""
|
||||
return SUPPORTED_TASK_TYPES
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=list[HuyaAccountOut])
|
||||
def list_accounts(
|
||||
tag: str | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:account")),
|
||||
):
|
||||
"""查看虎牙 CK 账号。"""
|
||||
query = db.query(HuyaAccount).options(joinedload(HuyaAccount.assigned_user))
|
||||
if tag:
|
||||
query = query.filter(HuyaAccount.tag == tag)
|
||||
accounts = query.order_by(HuyaAccount.id.desc()).all()
|
||||
return [_account_out(account) for account in accounts]
|
||||
|
||||
|
||||
@router.post("/accounts/import-cookies")
|
||||
def import_cookies(
|
||||
req: HuyaCookieImport,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:account")),
|
||||
):
|
||||
"""粘贴并导入虎牙 Cookie。"""
|
||||
count, skipped = import_huya_cookies(db, req.text, req.tag)
|
||||
return {
|
||||
"message": f"导入/更新 {count} 条,跳过 {skipped} 条",
|
||||
"success": True,
|
||||
"count": count,
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/accounts/batch")
|
||||
def delete_accounts_batch(
|
||||
account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:account")),
|
||||
):
|
||||
"""批量删除虎牙 CK 账号及任务记录。"""
|
||||
ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="无效的账号ID")
|
||||
db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete(synchronize_session=False)
|
||||
deleted = db.query(HuyaAccount).filter(HuyaAccount.id.in_(ids)).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return {"message": f"已删除 {deleted} 个虎牙账号", "deleted": deleted, "success": True}
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}")
|
||||
def delete_account(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:account")),
|
||||
):
|
||||
"""删除单个虎牙 CK 账号。"""
|
||||
account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete(synchronize_session=False)
|
||||
db.delete(account)
|
||||
db.commit()
|
||||
return {"message": "已删除", "success": True}
|
||||
|
||||
|
||||
@router.get("/config", response_model=HuyaConfigOut)
|
||||
def get_config(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:config")),
|
||||
):
|
||||
"""获取虎牙配置。"""
|
||||
config = ensure_huya_config(db)
|
||||
return HuyaConfigOut(
|
||||
room_pid=config.room_pid or "",
|
||||
sid=config.sid or "",
|
||||
outer_act_id=config.outer_act_id or "9504",
|
||||
bind_act_id=config.bind_act_id or "17096",
|
||||
pay_channel=config.pay_channel or "Zfb",
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/config", response_model=HuyaConfigOut)
|
||||
def update_config(
|
||||
req: HuyaConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:config")),
|
||||
):
|
||||
"""更新虎牙配置。"""
|
||||
config = ensure_huya_config(db)
|
||||
for field in ("room_pid", "sid", "outer_act_id", "bind_act_id", "pay_channel"):
|
||||
value = getattr(req, field)
|
||||
if value is not None:
|
||||
setattr(config, field, value.strip())
|
||||
config.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return HuyaConfigOut(
|
||||
room_pid=config.room_pid or "",
|
||||
sid=config.sid or "",
|
||||
outer_act_id=config.outer_act_id or "9504",
|
||||
bind_act_id=config.bind_act_id or "17096",
|
||||
pay_channel=config.pay_channel or "Zfb",
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/goods", response_model=list[HuyaGoodsOut])
|
||||
def list_goods(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:task")),
|
||||
):
|
||||
"""查看已缓存的虎牙商品快照。"""
|
||||
rows = db.query(HuyaGoodsSnapshot).order_by(HuyaGoodsSnapshot.updated_at.desc()).all()
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/tasks/batch")
|
||||
def create_task_batch(
|
||||
req: HuyaTaskBatchRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:task")),
|
||||
):
|
||||
"""创建虎牙任务记录,真实执行器后续接入。"""
|
||||
if not req.account_ids:
|
||||
raise HTTPException(status_code=400, detail="请选择虎牙账号")
|
||||
try:
|
||||
batch_id, count = create_planned_tasks(
|
||||
db,
|
||||
req.account_ids,
|
||||
req.task_type,
|
||||
current.id,
|
||||
req.payload,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if count == 0:
|
||||
raise HTTPException(status_code=400, detail="没有有效的虎牙账号")
|
||||
return {"batch_id": batch_id, "count": count, "success": True}
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[HuyaTaskOut])
|
||||
def list_tasks(
|
||||
batch_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:task")),
|
||||
):
|
||||
"""查看虎牙任务记录。"""
|
||||
query = db.query(HuyaTask).options(joinedload(HuyaTask.account))
|
||||
if batch_id:
|
||||
query = query.filter(HuyaTask.batch_id == batch_id)
|
||||
tasks = query.order_by(HuyaTask.id.desc()).limit(300).all()
|
||||
return [_task_out(task) for task in tasks]
|
||||
|
||||
|
||||
@router.websocket("/ws/{batch_id}")
|
||||
async def ws_huya_logs(websocket: WebSocket, batch_id: str):
|
||||
"""虎牙实时日志占位通道。"""
|
||||
user = authenticate_websocket(websocket)
|
||||
if not user:
|
||||
await websocket.close(code=1008, reason="未授权")
|
||||
return
|
||||
await websocket.accept()
|
||||
await websocket.send_json({
|
||||
"level": "warning",
|
||||
"message": f"虎牙批次 {batch_id} 已创建,真实执行器尚未接入",
|
||||
})
|
||||
await websocket.send_json({"level": "result", "message": ""})
|
||||
await websocket.close()
|
||||
@@ -160,6 +160,152 @@ class LoginTaskOut(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
# ---- 虎牙 ----
|
||||
class HuyaCookieImport(BaseModel):
|
||||
"""批量导入虎牙 Cookie。支持纯 CK 或 账号----密码----CK。"""
|
||||
text: str
|
||||
tag: str = ""
|
||||
|
||||
|
||||
class HuyaAccountOut(BaseModel):
|
||||
id: int
|
||||
uid: str = ""
|
||||
yyuid: str = ""
|
||||
username: str = ""
|
||||
nickname: str = ""
|
||||
cookie: str = ""
|
||||
cookie_preview: str = ""
|
||||
tag: str = ""
|
||||
remark: str = ""
|
||||
status: str = ""
|
||||
points: Optional[int] = None
|
||||
game_name: str = ""
|
||||
game_channel: str = ""
|
||||
game_phone: str = ""
|
||||
assigned_to: Optional[int] = None
|
||||
assigned_username: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_serializer
|
||||
def _serialize(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"uid": self.uid,
|
||||
"yyuid": self.yyuid,
|
||||
"username": self.username,
|
||||
"nickname": self.nickname,
|
||||
"cookie": self.cookie,
|
||||
"cookie_preview": self.cookie_preview,
|
||||
"tag": self.tag,
|
||||
"remark": self.remark,
|
||||
"status": self.status,
|
||||
"points": self.points,
|
||||
"game_name": self.game_name,
|
||||
"game_channel": self.game_channel,
|
||||
"game_phone": self.game_phone,
|
||||
"assigned_to": self.assigned_to,
|
||||
"assigned_username": self.assigned_username,
|
||||
"created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None,
|
||||
"updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class HuyaConfigOut(BaseModel):
|
||||
room_pid: str = ""
|
||||
sid: str = ""
|
||||
outer_act_id: str = "9504"
|
||||
bind_act_id: str = "17096"
|
||||
pay_channel: str = "Zfb"
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@model_serializer
|
||||
def _serialize(self) -> dict[str, Any]:
|
||||
return {
|
||||
"room_pid": self.room_pid,
|
||||
"sid": self.sid,
|
||||
"outer_act_id": self.outer_act_id,
|
||||
"bind_act_id": self.bind_act_id,
|
||||
"pay_channel": self.pay_channel,
|
||||
"updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class HuyaConfigUpdate(BaseModel):
|
||||
room_pid: Optional[str] = None
|
||||
sid: Optional[str] = None
|
||||
outer_act_id: Optional[str] = None
|
||||
bind_act_id: Optional[str] = None
|
||||
pay_channel: Optional[str] = None
|
||||
|
||||
|
||||
class HuyaTaskBatchRequest(BaseModel):
|
||||
account_ids: list[int]
|
||||
task_type: str
|
||||
concurrency: int = 3
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class HuyaTaskOut(BaseModel):
|
||||
id: int
|
||||
batch_id: str
|
||||
account_id: int
|
||||
account_uid: str = ""
|
||||
account_nickname: str = ""
|
||||
task_type: str
|
||||
status: str
|
||||
message: str = ""
|
||||
result: Optional[dict[str, Any]] = None
|
||||
created_by: int
|
||||
created_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_serializer
|
||||
def _serialize(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"batch_id": self.batch_id,
|
||||
"account_id": self.account_id,
|
||||
"account_uid": self.account_uid,
|
||||
"account_nickname": self.account_nickname,
|
||||
"task_type": self.task_type,
|
||||
"status": self.status,
|
||||
"message": self.message,
|
||||
"result": self.result,
|
||||
"created_by": self.created_by,
|
||||
"created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None,
|
||||
"finished_at": _ensure_tz(self.finished_at).isoformat() if self.finished_at else None,
|
||||
}
|
||||
|
||||
|
||||
class HuyaGoodsOut(BaseModel):
|
||||
id: int
|
||||
product_id: str
|
||||
name: str = ""
|
||||
price: Optional[int] = None
|
||||
remain_text: str = ""
|
||||
raw: Optional[dict[str, Any]] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_serializer
|
||||
def _serialize(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"product_id": self.product_id,
|
||||
"name": self.name,
|
||||
"price": self.price,
|
||||
"remain_text": self.remain_text,
|
||||
"raw": self.raw,
|
||||
"updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ---- 代理配置 ----
|
||||
class ProxyConfigOut(BaseModel):
|
||||
enabled: bool = False
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""虎牙基础业务服务。"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import HuyaAccount, HuyaConfig, HuyaTask
|
||||
|
||||
|
||||
SUPPORTED_TASK_TYPES = {
|
||||
"get_bind_qr": "获取绑定二维码",
|
||||
"query_points": "一键查询积分",
|
||||
"open_elite_book": "开通精英宝典",
|
||||
"recharge_points": "充值积分",
|
||||
"query_game_name": "一键查询游戏名",
|
||||
"query_exchange_records": "一键查询兑换记录",
|
||||
"confirm_bind": "确认绑定",
|
||||
"refresh_goods": "刷新商品列表",
|
||||
}
|
||||
|
||||
|
||||
def cookie_value(cookie: str, key: str) -> str:
|
||||
"""从 Cookie 文本中提取指定 key。"""
|
||||
match = re.search(rf"(?:^|;\s*){re.escape(key)}=([^;]+)", cookie or "")
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
|
||||
def _looks_like_huya_cookie(value: str) -> bool:
|
||||
"""判断文本是否像虎牙 Cookie。"""
|
||||
return "udb_" in value or "yyuid=" in value
|
||||
|
||||
|
||||
def parse_huya_cookie_line(line: str) -> dict | None:
|
||||
"""解析单行虎牙 CK,兼容纯 CK、账号----密码----CK、CK----手机号。"""
|
||||
raw = (line or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
parts = [part.strip() for part in raw.split("----")]
|
||||
username_hint = ""
|
||||
game_phone = ""
|
||||
|
||||
if len(parts) == 1:
|
||||
cookie = raw
|
||||
elif _looks_like_huya_cookie(parts[0]):
|
||||
cookie = parts[0]
|
||||
game_phone = parts[1] if len(parts) >= 2 else ""
|
||||
elif _looks_like_huya_cookie(parts[-1]):
|
||||
cookie = parts[-1]
|
||||
username_hint = parts[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
if not _looks_like_huya_cookie(cookie):
|
||||
return None
|
||||
|
||||
yyuid = cookie_value(cookie, "yyuid")
|
||||
uid = cookie_value(cookie, "udb_uid") or yyuid
|
||||
username = cookie_value(cookie, "udb_passport") or cookie_value(cookie, "username")
|
||||
if not username and username_hint:
|
||||
username = username_hint
|
||||
|
||||
if not uid and not yyuid:
|
||||
return None
|
||||
|
||||
return {
|
||||
"uid": uid,
|
||||
"yyuid": yyuid or uid,
|
||||
"username": username or uid or yyuid,
|
||||
"cookie": cookie,
|
||||
"game_phone": game_phone,
|
||||
}
|
||||
|
||||
|
||||
def import_huya_cookies(db: Session, text: str, tag: str = "") -> tuple[int, int]:
|
||||
"""导入虎牙 Cookie,返回 (成功数, 跳过数)。"""
|
||||
created_or_updated = 0
|
||||
skipped = 0
|
||||
tag = (tag or "").strip()
|
||||
|
||||
for line in (text or "").splitlines():
|
||||
parsed = parse_huya_cookie_line(line)
|
||||
if not parsed:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
account = None
|
||||
if parsed["uid"]:
|
||||
account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first()
|
||||
if account is None and parsed["yyuid"]:
|
||||
account = db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first()
|
||||
|
||||
if account is None:
|
||||
account = HuyaAccount(
|
||||
uid=parsed["uid"],
|
||||
yyuid=parsed["yyuid"],
|
||||
username=parsed["username"],
|
||||
cookie=parsed["cookie"],
|
||||
game_phone=parsed["game_phone"],
|
||||
tag=tag,
|
||||
status="imported",
|
||||
)
|
||||
db.add(account)
|
||||
else:
|
||||
account.uid = parsed["uid"] or account.uid
|
||||
account.yyuid = parsed["yyuid"] or account.yyuid
|
||||
account.username = parsed["username"] or account.username
|
||||
account.cookie = parsed["cookie"]
|
||||
account.game_phone = parsed["game_phone"] or account.game_phone
|
||||
if tag:
|
||||
account.tag = tag
|
||||
account.status = "updated"
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
created_or_updated += 1
|
||||
|
||||
if created_or_updated:
|
||||
db.commit()
|
||||
return created_or_updated, skipped
|
||||
|
||||
|
||||
def ensure_huya_config(db: Session) -> HuyaConfig:
|
||||
"""获取单条虎牙配置,不存在则创建。"""
|
||||
config = db.query(HuyaConfig).first()
|
||||
if config:
|
||||
return config
|
||||
config = HuyaConfig()
|
||||
db.add(config)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
def create_planned_tasks(
|
||||
db: Session,
|
||||
account_ids: list[int],
|
||||
task_type: str,
|
||||
created_by: int,
|
||||
payload: dict | None = None,
|
||||
) -> tuple[str, int]:
|
||||
"""创建虎牙任务记录,真实执行器后续接入。"""
|
||||
if task_type not in SUPPORTED_TASK_TYPES:
|
||||
raise ValueError("不支持的任务类型")
|
||||
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
payload = payload or {}
|
||||
accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all()
|
||||
for account in accounts:
|
||||
db.add(HuyaTask(
|
||||
batch_id=batch_id,
|
||||
account_id=account.id,
|
||||
task_type=task_type,
|
||||
status="planned",
|
||||
message="任务已创建,等待虎牙执行器接入",
|
||||
result={"payload": payload} if payload else None,
|
||||
created_by=created_by,
|
||||
))
|
||||
db.commit()
|
||||
return batch_id, len(accounts)
|
||||
@@ -11,6 +11,8 @@ import LoginTasksPage from './pages/LoginTasksPage';
|
||||
import ProxyPage from './pages/ProxyPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import CookiePage from './pages/CookiePage';
|
||||
import HuyaAccountsPage from './pages/HuyaAccountsPage';
|
||||
import HuyaTasksPage from './pages/HuyaTasksPage';
|
||||
import { getUser } from './store/auth';
|
||||
import { ThemeProvider } from './store/theme';
|
||||
import { useTheme } from './store/useTheme';
|
||||
@@ -44,6 +46,8 @@ function AppContent() {
|
||||
<Route path="assignments" element={<AssignmentsPage />} />
|
||||
<Route path="login-tasks" element={<LoginTasksPage />} />
|
||||
<Route path="cookies" element={<CookiePage />} />
|
||||
<Route path="huya/accounts" element={<HuyaAccountsPage />} />
|
||||
<Route path="huya/tasks" element={<HuyaTasksPage />} />
|
||||
<Route path="proxy" element={<ProxyPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import api from './client';
|
||||
import type {
|
||||
HuyaAccountItem,
|
||||
HuyaConfig,
|
||||
HuyaCookieImportResult,
|
||||
HuyaGoodsItem,
|
||||
HuyaTaskBatchRequest,
|
||||
HuyaTaskBatchResult,
|
||||
HuyaTaskItem,
|
||||
MessageDeletedResponse,
|
||||
MessageResponse,
|
||||
} from './types';
|
||||
|
||||
export const huyaApi = {
|
||||
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/huya/task-types'),
|
||||
listAccounts: (params?: { tag?: string }) =>
|
||||
api.get<HuyaAccountItem[], HuyaAccountItem[]>('/huya/accounts', { params }),
|
||||
importCookies: (text: string, tag: string = '') =>
|
||||
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
|
||||
deleteAccount: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/accounts/${id}`),
|
||||
deleteAccounts: (accountIds: number[]) =>
|
||||
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/accounts/batch', { params: { account_ids: accountIds.join(',') } }),
|
||||
getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'),
|
||||
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
|
||||
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
|
||||
createTasks: (data: HuyaTaskBatchRequest) =>
|
||||
api.post<HuyaTaskBatchResult, HuyaTaskBatchResult>('/huya/tasks/batch', data),
|
||||
listTasks: (batchId?: string) =>
|
||||
api.get<HuyaTaskItem[], HuyaTaskItem[]>('/huya/tasks', { params: batchId ? { batch_id: batchId } : {} }),
|
||||
};
|
||||
@@ -3,6 +3,7 @@ export { accountApi } from './accounts';
|
||||
export { appApi } from './app';
|
||||
export { authApi } from './auth';
|
||||
export { cookieApi } from './cookies';
|
||||
export { huyaApi } from './huya';
|
||||
export { loginApi } from './login';
|
||||
export { proxyApi } from './proxy';
|
||||
export { userApi } from './users';
|
||||
|
||||
@@ -109,6 +109,80 @@ export interface CookieItem {
|
||||
account_password: string;
|
||||
}
|
||||
|
||||
// ==================== Huya ====================
|
||||
|
||||
export interface HuyaAccountItem {
|
||||
id: number;
|
||||
uid: string;
|
||||
yyuid: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
cookie: string;
|
||||
cookie_preview: string;
|
||||
tag: string;
|
||||
remark: string;
|
||||
status: string;
|
||||
points: number | null;
|
||||
game_name: string;
|
||||
game_channel: string;
|
||||
game_phone: string;
|
||||
assigned_to: number | null;
|
||||
assigned_username: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface HuyaCookieImportResult extends MessageCountResponse {
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export interface HuyaConfig {
|
||||
room_pid: string;
|
||||
sid: string;
|
||||
outer_act_id: string;
|
||||
bind_act_id: string;
|
||||
pay_channel: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface HuyaTaskBatchRequest {
|
||||
account_ids: number[];
|
||||
task_type: string;
|
||||
concurrency?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HuyaTaskBatchResult {
|
||||
batch_id: string;
|
||||
count: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface HuyaTaskItem {
|
||||
id: number;
|
||||
batch_id: string;
|
||||
account_id: number;
|
||||
account_uid: string;
|
||||
account_nickname: string;
|
||||
task_type: string;
|
||||
status: string;
|
||||
message: string;
|
||||
result: Record<string, unknown> | null;
|
||||
created_by: number;
|
||||
created_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface HuyaGoodsItem {
|
||||
id: number;
|
||||
product_id: string;
|
||||
name: string;
|
||||
price: number | null;
|
||||
remain_text: string;
|
||||
raw: Record<string, unknown> | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
// ==================== Proxy ====================
|
||||
|
||||
export interface ProxyConfig {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
DashboardOutlined, UserOutlined, LogoutOutlined,
|
||||
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
|
||||
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
|
||||
SunOutlined, MoonOutlined, DesktopOutlined,
|
||||
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
||||
@@ -69,6 +69,16 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
|
||||
}
|
||||
|
||||
// 虎牙 CK 管理
|
||||
if (can('huya:account')) {
|
||||
menuItems.push({ key: '/huya/accounts', label: '虎牙 CK', icon: <GiftOutlined /> });
|
||||
}
|
||||
|
||||
// 虎牙兑换与充值
|
||||
if (can('huya:task')) {
|
||||
menuItems.push({ key: '/huya/tasks', label: '虎牙任务', icon: <ShoppingCartOutlined /> });
|
||||
}
|
||||
|
||||
// 代理配置
|
||||
if (can('proxy:manage')) {
|
||||
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Col, Input, message, Modal, Popconfirm, Row, Space, Statistic, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { DeleteOutlined, ImportOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { huyaApi, type HuyaAccountItem } from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
imported: '已导入',
|
||||
updated: '已更新',
|
||||
active: '正常',
|
||||
invalid: '失效',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
imported: 'blue',
|
||||
updated: 'cyan',
|
||||
active: 'success',
|
||||
invalid: 'error',
|
||||
};
|
||||
|
||||
export default function HuyaAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importText, setImportText] = useState('');
|
||||
const [importTag, setImportTag] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const v = localStorage.getItem('huya_account_page_size');
|
||||
return v ? Number(v) || 20 : 20;
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canManage = can('huya:account');
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await huyaApi.listAccounts();
|
||||
setAccounts(data);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
|
||||
const tags = useMemo(() => {
|
||||
return [...new Set(accounts.map((item) => item.tag.trim()).filter(Boolean))].sort();
|
||||
}, [accounts]);
|
||||
|
||||
const filteredAccounts = useMemo(() => {
|
||||
const s = searchText.trim().toLowerCase();
|
||||
if (!s) return accounts;
|
||||
return accounts.filter((item) => (
|
||||
item.uid.toLowerCase().includes(s) ||
|
||||
item.yyuid.toLowerCase().includes(s) ||
|
||||
item.username.toLowerCase().includes(s) ||
|
||||
item.nickname.toLowerCase().includes(s) ||
|
||||
item.tag.toLowerCase().includes(s) ||
|
||||
item.game_name.toLowerCase().includes(s) ||
|
||||
item.game_phone.toLowerCase().includes(s)
|
||||
));
|
||||
}, [accounts, searchText]);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importText.trim()) {
|
||||
message.warning('请先粘贴虎牙 CK');
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
try {
|
||||
const result = await huyaApi.importCookies(importText, importTag);
|
||||
message.success(result.message);
|
||||
setImportOpen(false);
|
||||
setImportText('');
|
||||
setImportTag('');
|
||||
loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await huyaApi.deleteAccount(id);
|
||||
message.success('已删除');
|
||||
setSelectedRowKeys((prev) => prev.filter((key) => key !== id));
|
||||
loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择虎牙 CK');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await huyaApi.deleteAccounts(selectedRowKeys.map((key) => Number(key)));
|
||||
message.success(result.message);
|
||||
setSelectedRowKeys([]);
|
||||
loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const boundCount = accounts.filter((item) => item.game_name || item.game_channel || item.game_phone).length;
|
||||
const pointCount = accounts.filter((item) => item.points !== null && item.points !== undefined).length;
|
||||
|
||||
const columns: TableProps<HuyaAccountItem>['columns'] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
|
||||
{
|
||||
title: '虎牙账号',
|
||||
width: 180,
|
||||
render: (_: unknown, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{record.nickname || record.username || record.uid || '-'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
UID {record.uid || record.yyuid || '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tag',
|
||||
width: 110,
|
||||
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '积分',
|
||||
dataIndex: 'points',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (points: number | null) => points ?? <Text type="secondary">未查</Text>,
|
||||
},
|
||||
{
|
||||
title: '游戏名',
|
||||
dataIndex: 'game_name',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (value: string) => value || <Text type="secondary">未查</Text>,
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'game_phone',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (value: string) => value || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Cookie',
|
||||
dataIndex: 'cookie_preview',
|
||||
ellipsis: true,
|
||||
render: (value: string) => (
|
||||
<Text code style={{ fontSize: 12 }}>
|
||||
{value || '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (status: string) => (
|
||||
<Tag color={STATUS_COLORS[status] || 'default'}>{STATUS_LABELS[status] || status || '-'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 170,
|
||||
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
render: (_: unknown, record) => (
|
||||
<Popconfirm title="确认删除这条虎牙 CK?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<h2 style={{ margin: 0 }}>虎牙 CK 管理</h2>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<Popconfirm title={`确认删除选中的 ${selectedRowKeys.length} 条虎牙 CK?`} onConfirm={handleDeleteSelected}>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
删除选中 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
|
||||
粘贴 CK
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small"><Statistic title="CK 总数" value={accounts.length} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small"><Statistic title="已查积分" value={pointCount} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Input.Search
|
||||
placeholder="搜索 UID、昵称、标签、游戏名、手机号"
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} color="blue" onClick={() => setSearchText(tag)} style={{ cursor: 'pointer' }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
columns={columns}
|
||||
dataSource={filteredAccounts}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
if (size !== pageSize) {
|
||||
setPageSize(size);
|
||||
localStorage.setItem('huya_account_page_size', String(size));
|
||||
setCurrentPage(1);
|
||||
}
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1120 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="粘贴虎牙 CK"
|
||||
open={importOpen}
|
||||
onCancel={() => setImportOpen(false)}
|
||||
onOk={handleImport}
|
||||
okText="导入"
|
||||
confirmLoading={importing}
|
||||
width={720}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<Input
|
||||
placeholder="标签,可选"
|
||||
value={importTag}
|
||||
onChange={(e) => setImportTag(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
rows={12}
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
placeholder="每行一条,支持纯 CK、账号----密码----CK、CK----手机号"
|
||||
/>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
当前阶段会解析并保存 UID、YYUID、账号名、手机号和 CK,绑定二维码、积分、游戏名等动作在虎牙任务页创建计划任务。
|
||||
</Paragraph>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Col, Form, Input, InputNumber, message, Row, Select, Space, Table, Tag, Tooltip, Typography, theme,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
|
||||
LinkOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
huyaApi,
|
||||
type HuyaAccountItem,
|
||||
type HuyaConfig,
|
||||
type HuyaGoodsItem,
|
||||
type HuyaTaskItem,
|
||||
} from '../api/modules';
|
||||
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const FALLBACK_TASK_TYPES: Record<string, string> = {
|
||||
get_bind_qr: '获取绑定二维码',
|
||||
confirm_bind: '确认绑定',
|
||||
query_points: '一键查询积分',
|
||||
open_elite_book: '开通精英宝典',
|
||||
recharge_points: '充值积分',
|
||||
query_game_name: '一键查询游戏名',
|
||||
query_exchange_records: '一键查询兑换记录',
|
||||
refresh_goods: '刷新商品列表',
|
||||
};
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ key: 'get_bind_qr', icon: <LinkOutlined /> },
|
||||
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
|
||||
{ key: 'query_points', icon: <SearchOutlined /> },
|
||||
{ key: 'open_elite_book', icon: <GiftOutlined /> },
|
||||
{ key: 'recharge_points', icon: <CreditCardOutlined /> },
|
||||
{ key: 'query_game_name', icon: <AppstoreOutlined /> },
|
||||
{ key: 'query_exchange_records', icon: <FieldTimeOutlined /> },
|
||||
{ key: 'refresh_goods', icon: <ReloadOutlined /> },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
planned: 'default',
|
||||
pending: 'default',
|
||||
running: 'processing',
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
planned: '已计划',
|
||||
pending: '等待中',
|
||||
running: '执行中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
error: '异常',
|
||||
};
|
||||
|
||||
function accountLabel(account: HuyaAccountItem): string {
|
||||
const name = account.nickname || account.username || account.uid || `#${account.id}`;
|
||||
const tag = account.tag ? ` [${account.tag}]` : '';
|
||||
const phone = account.game_phone ? ` / ${account.game_phone}` : '';
|
||||
return `${name}${tag}${phone}`;
|
||||
}
|
||||
|
||||
export default function HuyaTasksPage() {
|
||||
const { token } = theme.useToken();
|
||||
const [form] = Form.useForm<HuyaConfig>();
|
||||
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
||||
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
|
||||
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
|
||||
const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [selectedTaskType, setSelectedTaskType] = useState('query_points');
|
||||
const [selectedGoodsId, setSelectedGoodsId] = useState<string>('');
|
||||
const [rechargeCount, setRechargeCount] = useState(1);
|
||||
const [concurrency, setConcurrency] = useState(() => {
|
||||
const v = localStorage.getItem('huya_task_concurrency');
|
||||
return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [savingConfig, setSavingConfig] = useState(false);
|
||||
const [batchId, setBatchId] = useState<string | null>(null);
|
||||
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canTask = can('huya:task');
|
||||
const canConfig = can('huya:config');
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('huya_task_concurrency', String(concurrency));
|
||||
}, [concurrency]);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [accountResult, taskResult, goodsResult, configResult, taskTypeResult] = await Promise.allSettled([
|
||||
huyaApi.listAccounts(),
|
||||
huyaApi.listTasks(),
|
||||
huyaApi.listGoods(),
|
||||
canConfig ? huyaApi.getConfig() : Promise.resolve(null),
|
||||
huyaApi.taskTypes(),
|
||||
]);
|
||||
|
||||
if (accountResult.status === 'fulfilled') setAccounts(accountResult.value);
|
||||
if (taskResult.status === 'fulfilled') setTasks(taskResult.value);
|
||||
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
|
||||
if (configResult.status === 'fulfilled' && configResult.value) form.setFieldsValue(configResult.value);
|
||||
if (taskTypeResult.status === 'fulfilled') setTaskTypes({ ...FALLBACK_TASK_TYPES, ...taskTypeResult.value });
|
||||
|
||||
const failedLabels = [
|
||||
accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '',
|
||||
taskResult.status === 'rejected' ? `任务记录: ${getErrorMessage(taskResult.reason)}` : '',
|
||||
goodsResult.status === 'rejected' ? `商品快照: ${getErrorMessage(goodsResult.reason)}` : '',
|
||||
configResult.status === 'rejected' ? `虎牙配置: ${getErrorMessage(configResult.reason)}` : '',
|
||||
taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '',
|
||||
].filter(Boolean);
|
||||
if (failedLabels.length > 0) {
|
||||
message.warning(`部分数据加载失败:${failedLabels.join(';')}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canConfig, form]);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
const data = await huyaApi.listTasks();
|
||||
setTasks(data);
|
||||
} catch {
|
||||
// 轮询失败不打扰操作,下一轮继续刷新。
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadTasks, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadTasks]);
|
||||
|
||||
const accountOptions = useMemo(() => {
|
||||
return accounts.map((account) => ({ value: account.id, label: accountLabel(account) }));
|
||||
}, [accounts]);
|
||||
|
||||
const goodsOptions = useMemo(() => {
|
||||
return goods.map((item) => ({
|
||||
value: item.product_id,
|
||||
label: `${item.name || item.product_id}${item.price ? ` / ${item.price}积分` : ''}`,
|
||||
}));
|
||||
}, [goods]);
|
||||
|
||||
const selectedGoods = useMemo(() => {
|
||||
return goods.find((item) => item.product_id === selectedGoodsId) || null;
|
||||
}, [goods, selectedGoodsId]);
|
||||
|
||||
const createPayload = (taskType: string) => {
|
||||
if (taskType !== 'recharge_points') return {};
|
||||
return {
|
||||
product_id: selectedGoods?.product_id || selectedGoodsId,
|
||||
product_name: selectedGoods?.name || '',
|
||||
count: rechargeCount,
|
||||
};
|
||||
};
|
||||
|
||||
const startTask = async (taskType = selectedTaskType) => {
|
||||
if (selectedIds.length === 0) {
|
||||
message.warning('请先选择虎牙 CK');
|
||||
return;
|
||||
}
|
||||
if (taskType === 'recharge_points' && !selectedGoodsId) {
|
||||
message.warning('请先选择充值商品');
|
||||
return;
|
||||
}
|
||||
|
||||
setStarting(true);
|
||||
try {
|
||||
const result = await huyaApi.createTasks({
|
||||
account_ids: selectedIds,
|
||||
task_type: taskType,
|
||||
concurrency,
|
||||
payload: createPayload(taskType),
|
||||
});
|
||||
setBatchId(result.batch_id);
|
||||
message.success(`已创建 ${taskTypes[taskType] || taskType},共 ${result.count} 个账号`);
|
||||
await loadTasks();
|
||||
connectLogs(`/api/huya/ws/${result.batch_id}`, {
|
||||
onClose: () => { setBatchId(null); setStarting(false); loadTasks(); },
|
||||
onResult: () => { setBatchId(null); setStarting(false); loadTasks(); },
|
||||
onError: () => { setBatchId(null); setStarting(false); loadTasks(); },
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSavingConfig(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const result = await huyaApi.updateConfig(values);
|
||||
form.setFieldsValue(result);
|
||||
message.success('虎牙配置已保存');
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setSavingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const successCount = tasks.filter((task) => task.status === 'success').length;
|
||||
const plannedCount = tasks.filter((task) => task.status === 'planned').length;
|
||||
const failedCount = tasks.filter((task) => ['failed', 'error'].includes(task.status)).length;
|
||||
|
||||
const taskColumns: TableProps<HuyaTaskItem>['columns'] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
|
||||
{
|
||||
title: '任务',
|
||||
dataIndex: 'task_type',
|
||||
width: 150,
|
||||
render: (value: string) => taskTypes[value] || value,
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
width: 160,
|
||||
render: (_: unknown, record) => record.account_nickname || record.account_uid || record.account_id,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (status: string) => (
|
||||
<Tag color={STATUS_COLORS[status] || 'default'}>{STATUS_LABELS[status] || status}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '消息', dataIndex: 'message', ellipsis: true },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'result',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (value: Record<string, unknown> | null) => (
|
||||
value ? <Text code style={{ fontSize: 12 }}>{JSON.stringify(value)}</Text> : <Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const goodsColumns: TableProps<HuyaGoodsItem>['columns'] = [
|
||||
{ title: '商品ID', dataIndex: 'product_id', width: 120, ellipsis: true },
|
||||
{ title: '名称', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '价格',
|
||||
dataIndex: 'price',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (value: number | null) => value ?? <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
dataIndex: 'remain_text',
|
||||
width: 100,
|
||||
render: (value: string) => value || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 160,
|
||||
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ flexShrink: 0, marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<h2 style={{ margin: 0 }}>虎牙兑换与充值</h2>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadAll} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
{batchId && <Tag color="processing">批次 {batchId}</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', paddingRight: 2 }}>
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} xl={10}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><SettingOutlined />虎牙配置</Space>}
|
||||
extra={canConfig && (
|
||||
<Button size="small" type="primary" onClick={saveConfig} loading={savingConfig}>
|
||||
保存
|
||||
</Button>
|
||||
)}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Form form={form} layout="vertical" disabled={!canConfig}>
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="直播间 ID" name="room_pid">
|
||||
<Input placeholder="roomPid / pid" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="SID" name="sid">
|
||||
<Input placeholder="活动 sid" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="兑换活动 ID" name="outer_act_id">
|
||||
<Input placeholder="默认 9504" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="绑定活动 ID" name="bind_act_id">
|
||||
<Input placeholder="默认 17096" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="支付渠道" name="pay_channel">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'Zfb', label: '支付宝' },
|
||||
{ value: 'Wx', label: '微信' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title={<Space><ShoppingOutlined />商品快照</Space>} style={{ marginBottom: 12 }}>
|
||||
<Space style={{ marginBottom: 8 }} wrap>
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="选择充值商品"
|
||||
value={selectedGoodsId || undefined}
|
||||
onChange={(value) => setSelectedGoodsId(value || '')}
|
||||
options={goodsOptions}
|
||||
style={{ minWidth: 240 }}
|
||||
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={99}
|
||||
value={rechargeCount}
|
||||
onChange={(value) => setRechargeCount(value || 1)}
|
||||
addonAfter="份"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
columns={goodsColumns}
|
||||
dataSource={goods}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: 620, y: 220 }}
|
||||
locale={{ emptyText: '暂无商品快照,后续接入刷新商品列表后写入' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={14}>
|
||||
<Card size="small" title="批量动作" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder="选择虎牙 CK"
|
||||
value={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
options={accountOptions}
|
||||
maxTagCount="responsive"
|
||||
style={{ width: '100%' }}
|
||||
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8 }}>
|
||||
<Button size="small" type="link" onClick={() => setSelectedIds(accounts.map((item) => item.id))}>
|
||||
全选 ({accounts.length})
|
||||
</Button>
|
||||
<Button size="small" type="link" onClick={() => setSelectedIds([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Select
|
||||
value={selectedTaskType}
|
||||
onChange={setSelectedTaskType}
|
||||
options={Object.entries(taskTypes).map(([value, label]) => ({ value, label }))}
|
||||
style={{ flex: '1 1 220px', minWidth: 180 }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={concurrency}
|
||||
onChange={(value) => setConcurrency(value || 1)}
|
||||
addonBefore="并发"
|
||||
style={{ width: 130, flexShrink: 0 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={starting}
|
||||
disabled={!canTask || selectedIds.length === 0 || wsConnected}
|
||||
onClick={() => startTask()}
|
||||
>
|
||||
创建任务
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{QUICK_ACTIONS.map((item) => (
|
||||
<Tooltip key={item.key} title={item.key === 'refresh_goods' ? '当前阶段创建计划任务,真实拉取逻辑后续接入' : undefined}>
|
||||
<Button
|
||||
icon={item.icon}
|
||||
size="small"
|
||||
onClick={() => startTask(item.key)}
|
||||
disabled={!canTask || selectedIds.length === 0 || wsConnected}
|
||||
>
|
||||
{taskTypes[item.key] || item.key}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
当前阶段只创建 planned 任务并打通日志通道,真实 WSS/HTTP 执行器后续接入。
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8, color: token.colorTextSecondary }}>
|
||||
<span>共 <b>{tasks.length}</b> 个任务</span>
|
||||
<span>已计划 <b>{plannedCount}</b></span>
|
||||
<span>成功 <b style={{ color: token.colorSuccess }}>{successCount}</b></span>
|
||||
<span>失败 <b style={{ color: token.colorError }}>{failedCount}</b></span>
|
||||
</div>
|
||||
<Table
|
||||
columns={taskColumns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{ pageSize: 12, showTotal: (total) => `共 ${total} 条` }}
|
||||
scroll={{ x: 920 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<RealtimeLogPanel
|
||||
logs={logs}
|
||||
connected={wsConnected}
|
||||
title="虎牙实时日志"
|
||||
emptyText="暂无虎牙任务日志"
|
||||
collapsible
|
||||
spinWhenEmpty
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const backendTarget = process.env.VITE_BACKEND_TARGET || 'http://127.0.0.1:8000'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -9,7 +11,7 @@ export default defineConfig({
|
||||
allowedHosts: ["www.u499731.nyat.app"],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user