完善虎牙账号管理并规范Cookie
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
"""虎牙 Cookie 规范化工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def cookie_pairs(cookie: str) -> list[tuple[str, str]]:
|
||||
"""把浏览器 Cookie 字符串拆成 key/value 对。"""
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for part in (cookie or "").split(";"):
|
||||
item = part.strip()
|
||||
if not item or "=" not in item:
|
||||
continue
|
||||
key, value = item.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key:
|
||||
continue
|
||||
pairs.append((key, value.strip()))
|
||||
return pairs
|
||||
|
||||
|
||||
def normalize_cookie_pairs(pairs: Iterable[tuple[str, str]]) -> str:
|
||||
"""按 Cookie key 去重,保留最后一次出现的值。"""
|
||||
ordered_keys: list[str] = []
|
||||
values: dict[str, str] = {}
|
||||
for raw_key, raw_value in pairs:
|
||||
key = str(raw_key).strip()
|
||||
if not key:
|
||||
continue
|
||||
if key not in values:
|
||||
ordered_keys.append(key)
|
||||
values[key] = "" if raw_value is None else str(raw_value).strip()
|
||||
return "; ".join(f"{key}={values[key]}" for key in ordered_keys)
|
||||
|
||||
|
||||
def normalize_huya_cookie(cookie: str | Mapping[str, str] | requests.cookies.RequestsCookieJar | None) -> str:
|
||||
"""把 Cookie 字符串、dict 或 CookieJar 转成去重后的浏览器 Cookie 字符串。"""
|
||||
if not cookie:
|
||||
return ""
|
||||
if isinstance(cookie, str):
|
||||
return normalize_cookie_pairs(cookie_pairs(cookie))
|
||||
return normalize_cookie_pairs(cookie.items())
|
||||
|
||||
|
||||
def cookie_value(cookie: str | Mapping[str, str] | requests.cookies.RequestsCookieJar | None, key: str) -> str:
|
||||
"""从 Cookie 中读取 key;有重复时以最后一次出现为准。"""
|
||||
if not cookie or not key:
|
||||
return ""
|
||||
if isinstance(cookie, str):
|
||||
pairs = cookie_pairs(cookie)
|
||||
else:
|
||||
pairs = list(cookie.items())
|
||||
value = ""
|
||||
for item_key, item_value in pairs:
|
||||
if item_key == key:
|
||||
value = "" if item_value is None else str(item_value).strip()
|
||||
return value
|
||||
@@ -14,6 +14,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Optional, Callable
|
||||
|
||||
from .cookie_utils import cookie_pairs, normalize_cookie_pairs, normalize_huya_cookie
|
||||
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
|
||||
from .wup_protocol import WupRequest, WupResponse
|
||||
|
||||
@@ -122,16 +123,8 @@ class HuyaHttpClient:
|
||||
@staticmethod
|
||||
def _normalize_cookie(cookie: str) -> str:
|
||||
"""HTTP 业务 UserId.sCookie 需要带 huya_ua 前缀,避免重复写入。"""
|
||||
import re
|
||||
cookie = (cookie or "").strip()
|
||||
normalized = f"huya_ua={HTTP_HUYA_UA}"
|
||||
if re.search(r"(?:^|;\s*)huya_ua=", cookie):
|
||||
return re.sub(r"(^|;\s*)huya_ua=[^;]*",
|
||||
lambda m: f"{m.group(1)}{normalized}",
|
||||
cookie, count=1)
|
||||
if not cookie:
|
||||
return normalized
|
||||
return f"{normalized}; {cookie}"
|
||||
pairs = [(key, value) for key, value in cookie_pairs(normalize_huya_cookie(cookie)) if key != "huya_ua"]
|
||||
return normalize_cookie_pairs([("huya_ua", HTTP_HUYA_UA), *pairs])
|
||||
|
||||
@staticmethod
|
||||
def _build_user(uid: int, guid: str, cookie: str):
|
||||
@@ -176,7 +169,7 @@ class HuyaHttpClient:
|
||||
user = ActivityUserId()
|
||||
user.lUid = uid
|
||||
user.sHuYaUA = HTTP_HUYA_UA
|
||||
user.sCookie = cookie or ""
|
||||
user.sCookie = normalize_huya_cookie(cookie)
|
||||
return user
|
||||
|
||||
def call_rpc(self, service: str, method: str,
|
||||
|
||||
+3
-2
@@ -16,6 +16,8 @@ from urllib.parse import quote, urlsplit, urlunsplit
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from .cookie_utils import normalize_huya_cookie
|
||||
|
||||
|
||||
APP_ID = "5002"
|
||||
APP_VERSION = "2.6"
|
||||
@@ -63,8 +65,7 @@ def password_sha1(password: str) -> str:
|
||||
|
||||
def cookie_string(cookies: requests.cookies.RequestsCookieJar | Mapping[str, str]) -> str:
|
||||
"""把 CookieJar/dict 转成浏览器 Cookie 字符串。"""
|
||||
items = cookies.items() if isinstance(cookies, Mapping) else cookies.items()
|
||||
return "; ".join(f"{key}={value}" for key, value in items if value is not None)
|
||||
return normalize_huya_cookie(cookies)
|
||||
|
||||
|
||||
def cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]:
|
||||
|
||||
@@ -23,7 +23,14 @@ PERMISSIONS = {
|
||||
"cookie:view": "查看 Cookie",
|
||||
"cookie:export": "导出 Cookie",
|
||||
# 虎牙
|
||||
"huya:account": "虎牙 CK 管理",
|
||||
"huya:account": "虎牙账号管理(兼容旧权限)",
|
||||
"huya:view_all": "查看所有虎牙账号",
|
||||
"huya:view_assigned": "查看分配给自己的虎牙账号",
|
||||
"huya:import": "导入/登录虎牙账号",
|
||||
"huya:assign": "分配虎牙账号",
|
||||
"huya:delete": "删除虎牙账号",
|
||||
"huya:cookie:view": "查看虎牙 Cookie",
|
||||
"huya:cookie:export": "导出/清除虎牙 Cookie",
|
||||
"huya:task": "虎牙任务管理",
|
||||
"huya:bind": "虎牙绑定操作",
|
||||
"huya:recharge": "虎牙充值操作",
|
||||
@@ -49,6 +56,12 @@ ROLE_PERMISSIONS = {
|
||||
"cookie:view",
|
||||
"cookie:export",
|
||||
"huya:account",
|
||||
"huya:view_all",
|
||||
"huya:import",
|
||||
"huya:assign",
|
||||
"huya:delete",
|
||||
"huya:cookie:view",
|
||||
"huya:cookie:export",
|
||||
"huya:task",
|
||||
"huya:bind",
|
||||
"huya:recharge",
|
||||
@@ -59,6 +72,7 @@ ROLE_PERMISSIONS = {
|
||||
],
|
||||
"support": [
|
||||
"account:view_assigned",
|
||||
"huya:view_assigned",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
+298
-12
@@ -1,18 +1,27 @@
|
||||
"""虎牙基础管理路由"""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from core.huya import HuyaCredentialError, HuyaLoginError, login_huya_password
|
||||
from core.huya.cookie_utils import normalize_huya_cookie
|
||||
|
||||
from ..database import SessionLocal, get_db
|
||||
from ..deps import authenticate_websocket, require_permission
|
||||
from ..deps import authenticate_websocket, get_current_user, require_permission
|
||||
from ..models import HuyaAccount, HuyaConfig, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaTask, User
|
||||
from ..permissions import user_has_permission
|
||||
from ..schemas import (
|
||||
AccountAssign,
|
||||
AccountTag,
|
||||
BatchAssign,
|
||||
HuyaAccountOut,
|
||||
HuyaConfigOut,
|
||||
HuyaConfigUpdate,
|
||||
@@ -39,22 +48,54 @@ from ..services.huya_runner import HuyaBatchRunner, huya_batch_registry
|
||||
router = APIRouter(prefix="/api/huya", tags=["虎牙"])
|
||||
|
||||
|
||||
def _has_huya_perm(user: User, permission: str) -> bool:
|
||||
"""虎牙新权限兼容旧的 huya:account 大权限。"""
|
||||
return user_has_permission(user, permission) or user_has_permission(user, "huya:account")
|
||||
|
||||
|
||||
def _require_huya_perm(user: User, permission: str) -> None:
|
||||
if not _has_huya_perm(user, permission):
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
|
||||
|
||||
def _can_view_huya_all(user: User) -> bool:
|
||||
return _has_huya_perm(user, "huya:view_all")
|
||||
|
||||
|
||||
def _can_view_huya_assigned(user: User) -> bool:
|
||||
return _has_huya_perm(user, "huya:view_assigned")
|
||||
|
||||
|
||||
def _can_view_huya_cookie(user: User) -> bool:
|
||||
return _has_huya_perm(user, "huya:cookie:view") or _has_huya_perm(user, "huya:cookie:export")
|
||||
|
||||
|
||||
def _visible_huya_accounts_query(db: Session, current: User):
|
||||
"""返回当前用户可见的虎牙账号查询。"""
|
||||
query = db.query(HuyaAccount).options(joinedload(HuyaAccount.assigned_user))
|
||||
if _can_view_huya_all(current):
|
||||
return query
|
||||
if _can_view_huya_assigned(current):
|
||||
return query.filter(HuyaAccount.assigned_to == current.id)
|
||||
raise HTTPException(status_code=403, detail="无权查看虎牙账号")
|
||||
|
||||
|
||||
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 ""
|
||||
def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccountOut:
|
||||
cookie = normalize_huya_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),
|
||||
cookie=cookie if include_cookie else "",
|
||||
cookie_preview=_fmt_cookie_preview(cookie) if include_cookie else "***",
|
||||
tag=account.tag or "",
|
||||
remark=account.remark or "",
|
||||
status=account.status or "",
|
||||
@@ -98,6 +139,23 @@ def _config_out(config: HuyaConfig) -> HuyaConfigOut:
|
||||
)
|
||||
|
||||
|
||||
def _cookie_out(account: HuyaAccount, include_cookie: bool) -> dict:
|
||||
cookie = normalize_huya_cookie(account.cookie or "")
|
||||
account_name = account.nickname or account.username or account.uid or str(account.id)
|
||||
return {
|
||||
"id": account.id,
|
||||
"account_id": account.id,
|
||||
"account_username": account_name,
|
||||
"uid": account.uid or "",
|
||||
"yyuid": account.yyuid or "",
|
||||
"assigned_to": account.assigned_to,
|
||||
"assigned_username": account.assigned_user.username if account.assigned_user else None,
|
||||
"created_at": account.updated_at.isoformat() if account.updated_at else None,
|
||||
"cookie": cookie if include_cookie else "",
|
||||
"cookie_preview": _fmt_cookie_preview(cookie) if include_cookie else "***",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/task-types")
|
||||
def task_types(current: User = Depends(require_permission("huya:task"))):
|
||||
"""返回当前规划的虎牙任务类型。"""
|
||||
@@ -106,25 +164,33 @@ def task_types(current: User = Depends(require_permission("huya:task"))):
|
||||
|
||||
@router.get("/accounts", response_model=list[HuyaAccountOut])
|
||||
def list_accounts(
|
||||
assigned_only: bool = Query(False),
|
||||
tag: str | None = Query(None),
|
||||
has_cookie: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:account")),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""查看虎牙 CK 账号。"""
|
||||
query = db.query(HuyaAccount).options(joinedload(HuyaAccount.assigned_user))
|
||||
query = _visible_huya_accounts_query(db, current)
|
||||
if assigned_only and _can_view_huya_all(current):
|
||||
query = query.filter(HuyaAccount.assigned_to.isnot(None))
|
||||
if tag:
|
||||
query = query.filter(HuyaAccount.tag == tag)
|
||||
if has_cookie:
|
||||
query = query.filter(HuyaAccount.cookie != "")
|
||||
accounts = query.order_by(HuyaAccount.id.desc()).all()
|
||||
return [_account_out(account) for account in accounts]
|
||||
include_cookie = _can_view_huya_cookie(current)
|
||||
return [_account_out(account, include_cookie=include_cookie) 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")),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""粘贴并导入虎牙 Cookie。"""
|
||||
_require_huya_perm(current, "huya:import")
|
||||
count, skipped = import_huya_cookies(db, req.text, req.tag)
|
||||
return {
|
||||
"message": f"导入/更新 {count} 条,跳过 {skipped} 条",
|
||||
@@ -138,9 +204,10 @@ def import_cookies(
|
||||
def password_login_account(
|
||||
req: HuyaPasswordLoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:account")),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""使用账号密码登录虎牙,成功后保存 Cookie。"""
|
||||
_require_huya_perm(current, "huya:import")
|
||||
try:
|
||||
result = login_huya_password(
|
||||
username=req.username.strip(),
|
||||
@@ -173,9 +240,10 @@ def password_login_account(
|
||||
def delete_accounts_batch(
|
||||
account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:account")),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""批量删除虎牙 CK 账号及任务记录。"""
|
||||
_require_huya_perm(current, "huya:delete")
|
||||
ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="无效的账号ID")
|
||||
@@ -189,9 +257,10 @@ def delete_accounts_batch(
|
||||
def delete_account(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:account")),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""删除单个虎牙 CK 账号。"""
|
||||
_require_huya_perm(current, "huya:delete")
|
||||
account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
@@ -201,6 +270,223 @@ def delete_account(
|
||||
return {"message": "已删除", "success": True}
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}/assign")
|
||||
def assign_account(
|
||||
account_id: int,
|
||||
req: AccountAssign,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""分配单个虎牙账号给客服。"""
|
||||
_require_huya_perm(current, "huya:assign")
|
||||
account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first()
|
||||
if not account:
|
||||
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="只能分配给客服角色")
|
||||
account.assigned_to = req.assigned_to
|
||||
db.commit()
|
||||
return {"message": "已分配", "success": True}
|
||||
|
||||
|
||||
@router.post("/accounts/batch-assign")
|
||||
def batch_assign_accounts(
|
||||
req: BatchAssign,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""批量分配/取消分配虎牙账号给客服。"""
|
||||
_require_huya_perm(current, "huya:assign")
|
||||
if not req.account_ids:
|
||||
raise HTTPException(status_code=400, detail="请选择账号")
|
||||
if req.assigned_to is not None:
|
||||
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="只能分配给客服角色")
|
||||
|
||||
count = db.query(HuyaAccount).filter(HuyaAccount.id.in_(req.account_ids)).update(
|
||||
{HuyaAccount.assigned_to: req.assigned_to},
|
||||
synchronize_session=False,
|
||||
)
|
||||
db.commit()
|
||||
action = "分配" if req.assigned_to else "取消分配"
|
||||
return {"message": f"已批量{action} {count} 个虎牙账号", "success": True, "count": count}
|
||||
|
||||
|
||||
@router.get("/accounts/assignments/summary")
|
||||
def assignments_summary(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""虎牙分配概览:每个客服分配了多少虎牙账号。"""
|
||||
_require_huya_perm(current, "huya:assign")
|
||||
results = (
|
||||
db.query(User.id, User.username, func.count(HuyaAccount.id).label("count"))
|
||||
.outerjoin(HuyaAccount, HuyaAccount.assigned_to == User.id)
|
||||
.filter(User.role == "support")
|
||||
.group_by(User.id, User.username)
|
||||
.order_by(func.count(HuyaAccount.id).desc())
|
||||
.all()
|
||||
)
|
||||
total_unassigned = (
|
||||
db.query(func.count(HuyaAccount.id))
|
||||
.filter(HuyaAccount.assigned_to.is_(None))
|
||||
.scalar()
|
||||
) or 0
|
||||
return {
|
||||
"support_users": [
|
||||
{"id": uid, "username": uname, "assigned_count": cnt}
|
||||
for uid, uname, cnt in results
|
||||
],
|
||||
"unassigned_count": total_unassigned,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}/tag")
|
||||
def set_account_tag(
|
||||
account_id: int,
|
||||
req: AccountTag,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""设置单个虎牙账号标签。"""
|
||||
_require_huya_perm(current, "huya:import")
|
||||
account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
account.tag = (req.tag or "").strip()
|
||||
db.commit()
|
||||
return {"message": "标签已更新", "success": True}
|
||||
|
||||
|
||||
@router.put("/accounts/batch-tag")
|
||||
def batch_tag(
|
||||
req: AccountTag,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""批量设置虎牙账号标签。"""
|
||||
_require_huya_perm(current, "huya:import")
|
||||
if not req.account_ids:
|
||||
raise HTTPException(status_code=400, detail="请选择账号")
|
||||
tag = (req.tag or "").strip()
|
||||
count = db.query(HuyaAccount).filter(HuyaAccount.id.in_(req.account_ids)).update(
|
||||
{HuyaAccount.tag: tag},
|
||||
synchronize_session=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"message": f"已为 {count} 个虎牙账号设置标签", "success": True, "count": count}
|
||||
|
||||
|
||||
@router.get("/accounts/tags/list")
|
||||
def list_tags(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取虎牙账号标签列表。"""
|
||||
tag_query = db.query(HuyaAccount.tag)
|
||||
if not _can_view_huya_all(current):
|
||||
if _can_view_huya_assigned(current):
|
||||
tag_query = tag_query.filter(HuyaAccount.assigned_to == current.id)
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="无权查看虎牙账号")
|
||||
tags = tag_query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)).distinct().all()
|
||||
return [item[0] for item in tags if item[0]]
|
||||
|
||||
|
||||
@router.get("/cookies")
|
||||
def list_cookies(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""查看当前用户可见的虎牙 Cookie。"""
|
||||
include_cookie = _can_view_huya_cookie(current)
|
||||
accounts = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "").order_by(HuyaAccount.updated_at.desc()).all()
|
||||
return [_cookie_out(account, include_cookie=include_cookie) for account in accounts]
|
||||
|
||||
|
||||
@router.get("/cookies/export")
|
||||
def export_cookies(
|
||||
format: str = "csv",
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""导出虎牙 Cookie,支持 csv 和 custom 格式。"""
|
||||
_require_huya_perm(current, "huya:cookie:export")
|
||||
accounts = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "").order_by(HuyaAccount.updated_at.desc()).all()
|
||||
if format == "custom":
|
||||
lines = []
|
||||
for account in accounts:
|
||||
username = account.username or account.nickname or account.uid or ""
|
||||
lines.append(f"{username}----{normalize_huya_cookie(account.cookie or '')}")
|
||||
content = "\r\n".join(lines)
|
||||
filename = "huya_cookies_custom.txt"
|
||||
media = "text/plain"
|
||||
else:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["账号", "UID", "YYUID", "Cookie", "时间"])
|
||||
for account in accounts:
|
||||
username = account.nickname or account.username or account.uid or ""
|
||||
writer.writerow([
|
||||
username,
|
||||
account.uid or "",
|
||||
account.yyuid or "",
|
||||
normalize_huya_cookie(account.cookie or ""),
|
||||
account.updated_at.isoformat() if account.updated_at else "",
|
||||
])
|
||||
content = output.getvalue()
|
||||
filename = "huya_cookies.csv"
|
||||
media = "text/csv"
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type=media,
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/cookies/batch")
|
||||
def delete_cookies_batch(
|
||||
account_ids: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""批量清除虎牙 Cookie,保留账号记录。"""
|
||||
_require_huya_perm(current, "huya:cookie:export")
|
||||
ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="无效的账号ID")
|
||||
accounts = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(ids)).all()
|
||||
for account in accounts:
|
||||
account.cookie = ""
|
||||
account.status = "invalid"
|
||||
db.commit()
|
||||
return {"message": f"已清除 {len(accounts)} 条虎牙 Cookie", "deleted": len(accounts), "success": True}
|
||||
|
||||
|
||||
@router.delete("/cookies/{account_id}")
|
||||
def delete_cookie(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""清除单个虎牙 Cookie,保留账号记录。"""
|
||||
_require_huya_perm(current, "huya:cookie:export")
|
||||
account = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
account.cookie = ""
|
||||
account.status = "invalid"
|
||||
db.commit()
|
||||
return {"message": "已清除", "success": True}
|
||||
|
||||
|
||||
@router.get("/config", response_model=HuyaConfigOut)
|
||||
def get_config(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -11,6 +11,7 @@ from loguru import logger
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from core.huya import HuyaHttpClient
|
||||
from core.huya.cookie_utils import normalize_huya_cookie
|
||||
from ..database import SessionLocal
|
||||
from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaTask
|
||||
from .huya_service import HUYA_CONFIG_FIELDS, cookie_value, ensure_huya_config, huya_config_value
|
||||
@@ -1196,7 +1197,7 @@ class HuyaBatchRunner:
|
||||
"yyuid": account.yyuid or "",
|
||||
"username": account.username or "",
|
||||
"nickname": account.nickname or "",
|
||||
"cookie": account.cookie or "",
|
||||
"cookie": normalize_huya_cookie(account.cookie or ""),
|
||||
},
|
||||
})
|
||||
self.db.commit()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""虎牙基础业务服务。"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.huya.cookie_utils import cookie_value, normalize_huya_cookie
|
||||
|
||||
from ..huya_defaults import HUYA_CONFIG_DEFAULTS, HUYA_CONFIG_FIELDS
|
||||
from ..models import HuyaAccount, HuyaConfig, HuyaTask
|
||||
|
||||
@@ -44,12 +45,6 @@ def apply_huya_config_defaults(config: HuyaConfig) -> bool:
|
||||
return changed
|
||||
|
||||
|
||||
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
|
||||
@@ -78,6 +73,7 @@ def parse_huya_cookie_line(line: str) -> dict | None:
|
||||
|
||||
if not _looks_like_huya_cookie(cookie):
|
||||
return None
|
||||
cookie = normalize_huya_cookie(cookie)
|
||||
|
||||
yyuid = cookie_value(cookie, "yyuid")
|
||||
uid = cookie_value(cookie, "udb_uid") or yyuid
|
||||
|
||||
@@ -16,6 +16,8 @@ const ProxyPage = lazy(() => import('./pages/ProxyPage'));
|
||||
const UsersPage = lazy(() => import('./pages/UsersPage'));
|
||||
const CookiePage = lazy(() => import('./pages/CookiePage'));
|
||||
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
|
||||
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
|
||||
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
|
||||
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
|
||||
|
||||
function RouteFallback() {
|
||||
@@ -64,6 +66,8 @@ function AppContent() {
|
||||
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
|
||||
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
|
||||
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
|
||||
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
|
||||
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
|
||||
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
|
||||
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
||||
<Route path="users" element={lazyRoute(<UsersPage />)} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import api from './client';
|
||||
import type {
|
||||
HuyaAccountItem,
|
||||
HuyaConfig,
|
||||
HuyaCookieItem,
|
||||
HuyaCookieImportResult,
|
||||
HuyaGoodsItem,
|
||||
HuyaPasswordLoginRequest,
|
||||
@@ -10,21 +11,38 @@ import type {
|
||||
HuyaTaskBatchRequest,
|
||||
HuyaTaskBatchResult,
|
||||
HuyaTaskItem,
|
||||
MessageCountResponse,
|
||||
MessageDeletedResponse,
|
||||
MessageResponse,
|
||||
} from './types';
|
||||
|
||||
export const huyaApi = {
|
||||
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/huya/task-types'),
|
||||
listAccounts: (params?: { tag?: string }) =>
|
||||
listAccounts: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) =>
|
||||
api.get<HuyaAccountItem[], HuyaAccountItem[]>('/huya/accounts', { params }),
|
||||
importCookies: (text: string, tag: string = '') =>
|
||||
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
|
||||
passwordLogin: (data: HuyaPasswordLoginRequest) =>
|
||||
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/password-login', data),
|
||||
assign: (id: number, assigned_to: number | null) =>
|
||||
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/assign`, { assigned_to }),
|
||||
batchAssign: (account_ids: number[], assigned_to: number | null) =>
|
||||
api.post<MessageCountResponse, MessageCountResponse>('/huya/accounts/batch-assign', { account_ids, assigned_to }),
|
||||
assignmentsSummary: () =>
|
||||
api.get<{ support_users: { id: number; username: string; assigned_count: number }[]; unassigned_count: number }, { support_users: { id: number; username: string; assigned_count: number }[]; unassigned_count: number }>('/huya/accounts/assignments/summary'),
|
||||
setTag: (id: number, tag: string) =>
|
||||
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/tag`, { tag }),
|
||||
batchTag: (account_ids: number[], tag: string) =>
|
||||
api.put<MessageCountResponse, MessageCountResponse>('/huya/accounts/batch-tag', { account_ids, tag }),
|
||||
listTags: () => api.get<string[], string[]>('/huya/accounts/tags/list'),
|
||||
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(',') } }),
|
||||
listCookies: () => api.get<HuyaCookieItem[], HuyaCookieItem[]>('/huya/cookies'),
|
||||
exportCookies: (format?: string) => api.get<Blob, Blob>('/huya/cookies/export', { responseType: 'blob', params: format ? { format } : {} }),
|
||||
deleteCookie: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/cookies/${id}`),
|
||||
deleteCookies: (accountIds: number[]) =>
|
||||
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/cookies/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'),
|
||||
|
||||
@@ -148,6 +148,19 @@ export interface HuyaPasswordLoginResult extends MessageResponse {
|
||||
sdid: string;
|
||||
}
|
||||
|
||||
export interface HuyaCookieItem {
|
||||
id: number;
|
||||
account_id: number;
|
||||
account_username: string;
|
||||
uid: string;
|
||||
yyuid: string;
|
||||
assigned_to: number | null;
|
||||
assigned_username: string | null;
|
||||
created_at: string | null;
|
||||
cookie: string;
|
||||
cookie_preview: string;
|
||||
}
|
||||
|
||||
export interface HuyaConfig {
|
||||
room_pid: string;
|
||||
sid: string;
|
||||
|
||||
@@ -68,9 +68,15 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
}
|
||||
|
||||
// 虎牙
|
||||
if (can('huya:account')) {
|
||||
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
|
||||
huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: <GiftOutlined /> });
|
||||
}
|
||||
if (canAny(['huya:account', 'huya:assign'])) {
|
||||
huyaItems.push({ key: '/huya/assignments', label: '分配管理', icon: <SwapOutlined /> });
|
||||
}
|
||||
if (canAny(['huya:account', 'huya:cookie:view', 'huya:cookie:export'])) {
|
||||
huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
|
||||
}
|
||||
if (can('huya:task')) {
|
||||
huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function DashboardPage() {
|
||||
const canViewDouyuAccounts = canAny(['account:view_all', 'account:view_assigned']);
|
||||
const canViewDouyuTasks = canAny(['login:batch', 'login:view_all', 'login:view_assigned']);
|
||||
const canViewCookies = can('cookie:view');
|
||||
const canViewHuyaAccounts = can('huya:account');
|
||||
const canViewHuyaAccounts = canAny(['huya:account', 'huya:view_all', 'huya:view_assigned']);
|
||||
const canViewHuyaTasks = can('huya:task');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Col, Form, Input, message, Modal, Popconfirm, Row, Space, Statistic, Table, Tag, Typography,
|
||||
Button, Card, Col, Form, Input, message, Modal, Popconfirm, Row, Select, Space, Statistic, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { DeleteOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { huyaApi, type HuyaAccountItem } from '../api/modules';
|
||||
import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons';
|
||||
import { huyaApi, type HuyaAccountItem, type SupportUserItem } from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
@@ -37,6 +37,8 @@ interface PasswordLoginFormValues {
|
||||
|
||||
export default function HuyaAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
||||
const [users, setUsers] = useState<SupportUserItem[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importText, setImportText] = useState('');
|
||||
@@ -45,7 +47,10 @@ export default function HuyaAccountsPage() {
|
||||
const [passwordLoginOpen, setPasswordLoginOpen] = useState(false);
|
||||
const [passwordLogging, setPasswordLogging] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [tagFilter, setTagFilter] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [batchTagInput, setBatchTagInput] = useState('');
|
||||
const [batchTagVisible, setBatchTagVisible] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const v = localStorage.getItem('huya_account_page_size');
|
||||
return v ? Number(v) || 20 : 20;
|
||||
@@ -55,26 +60,56 @@ export default function HuyaAccountsPage() {
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canManage = can('huya:account');
|
||||
const canImport = can('huya:import') || canManage;
|
||||
const canAssign = can('huya:assign') || canManage;
|
||||
const canDelete = can('huya:delete') || canManage;
|
||||
const canViewCookie = can('huya:cookie:view') || can('huya:cookie:export') || canManage;
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await huyaApi.listAccounts();
|
||||
const params: { tag?: string } = {};
|
||||
if (tagFilter) params.tag = tagFilter;
|
||||
const data = await huyaApi.listAccounts(params);
|
||||
setAccounts(data);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tagFilter]);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await huyaApi.assignmentsSummary();
|
||||
setUsers(data.support_users);
|
||||
} catch {
|
||||
// 忽略客服列表加载失败,账号列表仍可继续使用。
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
const data = await huyaApi.listTags();
|
||||
setTags(data);
|
||||
} catch {
|
||||
// 忽略标签加载失败,页面会退化为无标签筛选。
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
if (canAssign) loadUsers();
|
||||
loadTags();
|
||||
}, [loadAccounts, canAssign, loadUsers, loadTags]);
|
||||
|
||||
const tags = useMemo(() => {
|
||||
return [...new Set(accounts.map((item) => item.tag.trim()).filter(Boolean))].sort();
|
||||
}, [accounts]);
|
||||
const tagColorMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
tags.forEach((tag, index) => {
|
||||
map[tag] = ['blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano'][index % 8];
|
||||
});
|
||||
return map;
|
||||
}, [tags]);
|
||||
|
||||
const filteredAccounts = useMemo(() => {
|
||||
const s = searchText.trim().toLowerCase();
|
||||
@@ -103,6 +138,7 @@ export default function HuyaAccountsPage() {
|
||||
setImportText('');
|
||||
setImportTag('');
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
@@ -134,6 +170,7 @@ export default function HuyaAccountsPage() {
|
||||
setPasswordLoginOpen(false);
|
||||
passwordLoginForm.resetFields();
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
@@ -147,6 +184,7 @@ export default function HuyaAccountsPage() {
|
||||
message.success('已删除');
|
||||
setSelectedRowKeys((prev) => prev.filter((key) => key !== id));
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
@@ -162,6 +200,46 @@ export default function HuyaAccountsPage() {
|
||||
message.success(result.message);
|
||||
setSelectedRowKeys([]);
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async (accountId: number, assignedTo: number | null) => {
|
||||
try {
|
||||
await huyaApi.assign(accountId, assignedTo);
|
||||
message.success('已分配');
|
||||
loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetTag = async (accountId: number, tag: string) => {
|
||||
try {
|
||||
await huyaApi.setTag(accountId, tag);
|
||||
message.success('标签已更新');
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTag = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择虎牙账号');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await huyaApi.batchTag(selectedRowKeys.map((key) => Number(key)), batchTagInput);
|
||||
message.success(`已为 ${selectedRowKeys.length} 个虎牙账号设置标签`);
|
||||
setBatchTagVisible(false);
|
||||
setBatchTagInput('');
|
||||
setSelectedRowKeys([]);
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
@@ -169,6 +247,7 @@ export default function HuyaAccountsPage() {
|
||||
|
||||
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 assignedCount = accounts.filter((item) => item.assigned_to).length;
|
||||
|
||||
const columns: TableProps<HuyaAccountItem>['columns'] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
|
||||
@@ -188,7 +267,43 @@ export default function HuyaAccountsPage() {
|
||||
title: '标签',
|
||||
dataIndex: 'tag',
|
||||
width: 110,
|
||||
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
|
||||
render: (tag: string, record) => {
|
||||
if (!tag) {
|
||||
if (canImport) {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="输入标签"
|
||||
style={{ width: 90 }}
|
||||
onPressEnter={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
if (value) handleSetTag(record.id, value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) handleSetTag(record.id, value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
if (canImport) {
|
||||
return (
|
||||
<Tag
|
||||
color={tagColorMap[tag]}
|
||||
closable
|
||||
onClose={(e) => {
|
||||
e.preventDefault();
|
||||
handleSetTag(record.id, '');
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return <Tag color={tagColorMap[tag]}>{tag}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '积分',
|
||||
@@ -224,10 +339,30 @@ export default function HuyaAccountsPage() {
|
||||
ellipsis: true,
|
||||
render: (value: string) => (
|
||||
<Text code style={{ fontSize: 12 }}>
|
||||
{value || '-'}
|
||||
{canViewCookie ? (value || '-') : '***'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分配给',
|
||||
dataIndex: 'assigned_username',
|
||||
width: 150,
|
||||
render: (_: unknown, record) => {
|
||||
if (canAssign) {
|
||||
return (
|
||||
<Select
|
||||
style={{ width: 130 }}
|
||||
allowClear
|
||||
placeholder="未分配"
|
||||
value={record.assigned_to}
|
||||
onChange={(value) => handleAssign(record.id, value ?? null)}
|
||||
options={users.map((user) => ({ value: user.id, label: user.username }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return record.assigned_username || <Text type="secondary">未分配</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -249,9 +384,11 @@ export default function HuyaAccountsPage() {
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
render: (_: unknown, record) => (
|
||||
<Popconfirm title="确认删除这条虎牙 CK?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
canDelete ? (
|
||||
<Popconfirm title="确认删除这条虎牙账号?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
) : null
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -259,24 +396,45 @@ export default function HuyaAccountsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<h2 style={{ margin: 0 }}>虎牙 CK 管理</h2>
|
||||
<h2 style={{ margin: 0 }}>虎牙账号管理</h2>
|
||||
<Space wrap>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按标签筛选"
|
||||
style={{ width: 150 }}
|
||||
value={tagFilter || undefined}
|
||||
onChange={(value) => setTagFilter(value || '')}
|
||||
options={tags.map((tag) => ({ value: tag, label: tag }))}
|
||||
prefix={<FilterOutlined />}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
{canImport && (
|
||||
<Button
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
icon={<TagOutlined />}
|
||||
onClick={() => {
|
||||
setBatchTagInput('');
|
||||
setBatchTagVisible(true);
|
||||
}}
|
||||
>
|
||||
批量打标签
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && selectedRowKeys.length > 0 && (
|
||||
<Popconfirm title={`确认删除选中的 ${selectedRowKeys.length} 条虎牙 CK?`} onConfirm={handleDeleteSelected}>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
删除选中 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{canManage && (
|
||||
{canImport && (
|
||||
<Button icon={<LoginOutlined />} onClick={openPasswordLogin}>
|
||||
密码登录
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
{canImport && (
|
||||
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
|
||||
粘贴 CK
|
||||
</Button>
|
||||
@@ -286,14 +444,23 @@ export default function HuyaAccountsPage() {
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small"><Statistic title="CK 总数" value={accounts.length} /></Card>
|
||||
<Card size="small"><Statistic title="账号总数" value={accounts.length} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Col xs={24} sm={4}>
|
||||
<Card size="small"><Statistic title="标签数" value={tags.length} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={4}>
|
||||
<Card size="small"><Statistic title="已查积分" value={pointCount} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Col xs={24} sm={4}>
|
||||
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={4}>
|
||||
<Card size="small"><Statistic title="已分配" value={assignedCount} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={4}>
|
||||
<Card size="small"><Statistic title="未分配" value={accounts.length - assignedCount} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
@@ -313,10 +480,10 @@ export default function HuyaAccountsPage() {
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowSelection={{
|
||||
rowSelection={(canImport || canDelete || canAssign) ? {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
} : undefined}
|
||||
columns={columns}
|
||||
dataSource={filteredAccounts}
|
||||
rowKey="id"
|
||||
@@ -366,6 +533,25 @@ export default function HuyaAccountsPage() {
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量设置标签"
|
||||
open={batchTagVisible}
|
||||
onCancel={() => setBatchTagVisible(false)}
|
||||
onOk={handleBatchTag}
|
||||
okText="确定"
|
||||
width={400}
|
||||
>
|
||||
<p>为选中的 {selectedRowKeys.length} 个虎牙账号设置标签:</p>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="输入或选择标签"
|
||||
value={batchTagInput ? [batchTagInput] : []}
|
||||
onChange={(values) => setBatchTagInput(values.length > 0 ? values[values.length - 1] : '')}
|
||||
options={tags.map((tag) => ({ value: tag, label: tag }))}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="虎牙密码登录"
|
||||
open={passwordLoginOpen}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Badge, Button, Card, Col, Input, message, Row, Select, Space, Statistic, Table, Tabs, Tag, theme, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, ClearOutlined, SwapOutlined, TeamOutlined, UsergroupAddOutlined, UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { huyaApi, type HuyaAccountItem, type SupportUserItem } from '../api/modules';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { Text } = Typography;
|
||||
const STORAGE_KEY_SELECTED_USER = 'huya_assignments_selected_user_id';
|
||||
|
||||
function accountName(account: HuyaAccountItem) {
|
||||
return account.nickname || account.username || account.uid || `#${account.id}`;
|
||||
}
|
||||
|
||||
export default function HuyaAssignmentsPage() {
|
||||
const { token } = theme.useToken();
|
||||
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
||||
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterTag, setFilterTag] = useState<string | undefined>(undefined);
|
||||
const [activeTab, setActiveTab] = useState('unassigned');
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const value = localStorage.getItem('huya_assignment_page_size');
|
||||
return value ? Number(value) || 15 : 15;
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const data = await huyaApi.assignmentsSummary();
|
||||
setSupportUsers(data.support_users);
|
||||
return data.support_users;
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await huyaApi.listAccounts({ has_cookie: true });
|
||||
setAccounts(data);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
void loadAccounts();
|
||||
const users = await loadSummary();
|
||||
if (users.length > 0) {
|
||||
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
|
||||
const savedUser = savedId ? users.find((user) => user.id === Number(savedId)) : null;
|
||||
setSelectedUser(savedUser || users[0]);
|
||||
}
|
||||
};
|
||||
void init();
|
||||
}, []);
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
return [...new Set(accounts.map((account) => (account.tag || '').trim()).filter(Boolean))].sort();
|
||||
}, [accounts]);
|
||||
|
||||
const unassignedAccounts = useMemo(() => accounts.filter((account) => !account.assigned_to), [accounts]);
|
||||
|
||||
const assignedAccounts = useMemo(() => {
|
||||
if (!selectedUser) return [];
|
||||
return accounts.filter((account) => account.assigned_to === selectedUser.id);
|
||||
}, [accounts, selectedUser]);
|
||||
|
||||
const displayAccounts = useMemo(() => {
|
||||
let list = activeTab === 'unassigned' ? unassignedAccounts : assignedAccounts;
|
||||
if (searchText) {
|
||||
const search = searchText.toLowerCase();
|
||||
list = list.filter((account) => (
|
||||
accountName(account).toLowerCase().includes(search) ||
|
||||
account.uid.toLowerCase().includes(search) ||
|
||||
account.yyuid.toLowerCase().includes(search) ||
|
||||
account.game_phone.toLowerCase().includes(search)
|
||||
));
|
||||
}
|
||||
if (filterTag) {
|
||||
list = list.filter((account) => (account.tag || '').trim() === filterTag);
|
||||
}
|
||||
return list;
|
||||
}, [activeTab, assignedAccounts, filterTag, searchText, unassignedAccounts]);
|
||||
|
||||
const handleSelectUser = (user: SupportUserItem) => {
|
||||
setSelectedUser(user);
|
||||
setSelectedRowKeys([]);
|
||||
setActiveTab('unassigned');
|
||||
setSearchText('');
|
||||
setFilterTag(undefined);
|
||||
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
|
||||
};
|
||||
|
||||
const refreshAfterAssign = async () => {
|
||||
await loadAccounts();
|
||||
const users = await loadSummary();
|
||||
const refreshed = users.find((user) => user.id === selectedUser?.id);
|
||||
if (refreshed) setSelectedUser(refreshed);
|
||||
};
|
||||
|
||||
const handleBatchAssign = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择虎牙账号');
|
||||
return;
|
||||
}
|
||||
if (!selectedUser) {
|
||||
message.warning('请先选择客服');
|
||||
return;
|
||||
}
|
||||
setAssigning(true);
|
||||
try {
|
||||
await huyaApi.batchAssign(selectedRowKeys, selectedUser.id);
|
||||
message.success(`已将 ${selectedRowKeys.length} 个虎牙账号分配给 ${selectedUser.username}`);
|
||||
setSelectedRowKeys([]);
|
||||
await refreshAfterAssign();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchUnassign = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择虎牙账号');
|
||||
return;
|
||||
}
|
||||
setAssigning(true);
|
||||
try {
|
||||
await huyaApi.batchAssign(selectedRowKeys, null);
|
||||
message.success(`已取消 ${selectedRowKeys.length} 个虎牙账号的分配`);
|
||||
setSelectedRowKeys([]);
|
||||
await refreshAfterAssign();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignAllUnassigned = async () => {
|
||||
if (!selectedUser) {
|
||||
message.warning('请先选择客服');
|
||||
return;
|
||||
}
|
||||
const ids = displayAccounts.map((account) => account.id);
|
||||
if (ids.length === 0) {
|
||||
message.info('没有未分配的虎牙账号');
|
||||
return;
|
||||
}
|
||||
setAssigning(true);
|
||||
try {
|
||||
await huyaApi.batchAssign(ids, selectedUser.id);
|
||||
message.success(`已将 ${ids.length} 个虎牙账号分配给 ${selectedUser.username}`);
|
||||
await refreshAfterAssign();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assignedCount = accounts.filter((account) => !!account.assigned_to).length;
|
||||
const unassignedCount = accounts.length - assignedCount;
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '虎牙账号',
|
||||
render: (_: unknown, record: HuyaAccountItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{accountName(record)}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>UID {record.uid || record.yyuid || '-'}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tag',
|
||||
width: 100,
|
||||
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'game_phone',
|
||||
width: 130,
|
||||
render: (value: string) => value || <Text type="secondary">-</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h2 style={{ margin: 0, flexShrink: 0 }}>虎牙分配管理</h2>
|
||||
|
||||
<Row gutter={12} style={{ flexShrink: 0 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="虎牙账号" value={accounts.length} prefix={<TeamOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="已分配" value={assignedCount} styles={{ content: { color: token.colorSuccess } }} prefix={<CheckCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="未分配" value={unassignedCount} styles={{ content: { color: token.colorError } }} prefix={<SwapOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="客服人数" value={supportUsers.length} prefix={<UsergroupAddOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={12} style={{ flex: 1, minHeight: 0 }}>
|
||||
<Col span={8} style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<Card
|
||||
title="客服列表"
|
||||
size="small"
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
styles={{ body: { flex: 1, overflow: 'auto', padding: 8 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{supportUsers.map((user) => {
|
||||
const isSelected = selectedUser?.id === user.id;
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => handleSelectUser(user)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${isSelected ? token.colorPrimary : token.colorBorderSecondary}`,
|
||||
background: isSelected ? token.colorPrimaryBg : token.colorBgContainer,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<UserOutlined style={{ color: token.colorPrimary }} />
|
||||
<Text strong>{user.username}</Text>
|
||||
</Space>
|
||||
<Badge count={user.assigned_count} showZero style={{ backgroundColor: token.colorPrimary }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{supportUsers.length === 0 && (
|
||||
<Text type="secondary" style={{ textAlign: 'center', padding: 20, display: 'block' }}>
|
||||
暂无客服用户
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={16} style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={selectedUser ? (
|
||||
<Space>
|
||||
<span>已选中客服:</span>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
|
||||
{selectedUser.username}(已分配 {selectedUser.assigned_count} 个)
|
||||
</Tag>
|
||||
</Space>
|
||||
) : <Text type="secondary">请选择左侧客服进行操作</Text>}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 } }}
|
||||
>
|
||||
<div style={{ padding: '8px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||
<Input.Search
|
||||
placeholder="搜索昵称、UID、手机号"
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 220 }}
|
||||
size="small"
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按标签筛选"
|
||||
value={filterTag}
|
||||
onChange={(value) => setFilterTag(value || undefined)}
|
||||
style={{ width: 150 }}
|
||||
size="small"
|
||||
options={allTags.map((tag) => ({ value: tag, label: tag }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => { setActiveTab(key); setSelectedRowKeys([]); }}
|
||||
style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }}
|
||||
items={[
|
||||
{ key: 'unassigned', label: `未分配 (${unassignedAccounts.length})` },
|
||||
{
|
||||
key: 'assigned',
|
||||
label: selectedUser ? `已分配给 ${selectedUser.username} (${assignedAccounts.length})` : '已分配账号',
|
||||
disabled: !selectedUser,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div style={{ padding: '0 16px 8px', flexShrink: 0 }}>
|
||||
<Space>
|
||||
{activeTab === 'unassigned' && selectedUser && (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
loading={assigning}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={handleBatchAssign}
|
||||
>
|
||||
分配选中 ({selectedRowKeys.length}) 给 {selectedUser.username}
|
||||
</Button>
|
||||
<Button size="small" loading={assigning} onClick={handleAssignAllUnassigned}>
|
||||
全部分配给 {selectedUser.username}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'assigned' && selectedUser && (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<ClearOutlined />}
|
||||
loading={assigning}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={handleBatchUnassign}
|
||||
>
|
||||
取消分配选中 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={displayAccounts}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
size: 'small',
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
if (size !== pageSize) {
|
||||
setPageSize(size);
|
||||
localStorage.setItem('huya_assignment_page_size', String(size));
|
||||
setCurrentPage(1);
|
||||
}
|
||||
},
|
||||
}}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
}}
|
||||
scroll={{ y: 'calc(100vh - 500px)' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Col, Dropdown, Input, message, Popconfirm, Row, Space, Statistic, Table, Tag, theme, Typography,
|
||||
} from 'antd';
|
||||
import { CopyOutlined, DeleteOutlined, DownloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { huyaApi, type HuyaCookieItem } from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function copyToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (ok) resolve();
|
||||
else reject(new Error('execCommand copy failed'));
|
||||
} catch (e) {
|
||||
document.body.removeChild(textarea);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function HuyaCookiePage() {
|
||||
const { token } = theme.useToken();
|
||||
const [cookies, setCookies] = useState<HuyaCookieItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const value = localStorage.getItem('huya_cookie_page_size');
|
||||
return value ? Number(value) || 20 : 20;
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canManage = can('huya:account');
|
||||
const canView = can('huya:cookie:view') || can('huya:cookie:export') || canManage;
|
||||
const canExport = can('huya:cookie:export') || canManage;
|
||||
|
||||
const loadCookies = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await huyaApi.listCookies();
|
||||
setCookies(data);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCookies();
|
||||
}, [loadCookies]);
|
||||
|
||||
const handleExport = async (format: string = 'csv') => {
|
||||
try {
|
||||
const blob = await huyaApi.exportCookies(format);
|
||||
const url = URL.createObjectURL(blob instanceof Blob ? blob : new Blob([blob]));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = format === 'custom' ? 'huya_cookies_custom.txt' : 'huya_cookies.csv';
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('已导出');
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCookie = (record: HuyaCookieItem) => {
|
||||
if (!record.cookie) {
|
||||
message.warning('Cookie 为空');
|
||||
return;
|
||||
}
|
||||
const text = `${record.account_username}----${record.cookie}`;
|
||||
copyToClipboard(text).then(() => {
|
||||
message.success(`已复制 ${record.account_username} 的 Cookie`);
|
||||
}).catch(() => {
|
||||
message.error('复制失败');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopySelected = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择 Cookie');
|
||||
return;
|
||||
}
|
||||
const selected = cookies.filter((item) => selectedRowKeys.includes(item.id));
|
||||
const text = selected.map((item) => `${item.account_username}----${item.cookie || ''}`).join('\r\n');
|
||||
copyToClipboard(text).then(() => {
|
||||
message.success(`已复制 ${selected.length} 条 Cookie`);
|
||||
}).catch(() => {
|
||||
message.error('复制失败');
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await huyaApi.deleteCookie(id);
|
||||
message.success('已清除');
|
||||
setSelectedRowKeys((prev) => prev.filter((key) => key !== id));
|
||||
loadCookies();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择要清除的 Cookie');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await huyaApi.deleteCookies(selectedRowKeys.map((key) => Number(key)));
|
||||
message.success(result.message);
|
||||
setSelectedRowKeys([]);
|
||||
loadCookies();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCookies = cookies.filter((item) => {
|
||||
if (!searchText) return true;
|
||||
const search = searchText.toLowerCase();
|
||||
return (
|
||||
item.account_username.toLowerCase().includes(search) ||
|
||||
item.uid.toLowerCase().includes(search) ||
|
||||
item.yyuid.toLowerCase().includes(search) ||
|
||||
(item.assigned_username || '').toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' as const },
|
||||
{
|
||||
title: '虎牙账号',
|
||||
dataIndex: 'account_username',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'UID',
|
||||
dataIndex: 'uid',
|
||||
width: 130,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '分配',
|
||||
dataIndex: 'assigned_username',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (name: string | null) =>
|
||||
name ? <Tag color="green">{name}</Tag> : <Text type="secondary" style={{ fontSize: 12 }}>未分配</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Cookie',
|
||||
dataIndex: 'cookie_preview',
|
||||
ellipsis: true,
|
||||
render: (value: string) => {
|
||||
if (!canView) return <Tag>***</Tag>;
|
||||
return <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{value}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
align: 'center' as const,
|
||||
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 130,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: HuyaCookieItem) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopyCookie(record)}>
|
||||
复制
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Popconfirm title="确认清除这条 Cookie?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const assignedCount = cookies.filter((item) => item.assigned_to).length;
|
||||
const unassignedCount = cookies.length - assignedCount;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0 }}>虎牙 Cookie 管理</h2>
|
||||
<Space>
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopySelected} disabled={selectedRowKeys.length === 0}>
|
||||
复制选中 {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Popconfirm
|
||||
title={`确认清除选中的 ${selectedRowKeys.length} 条虎牙 Cookie?`}
|
||||
onConfirm={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0}>
|
||||
清除选中 {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{canExport && (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'csv', label: 'CSV(账号, UID, Cookie, 时间)', onClick: () => handleExport('csv') },
|
||||
{ key: 'custom', label: '自定义(账号----ck)', onClick: () => handleExport('custom') },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<DownloadOutlined />}>
|
||||
导出
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="Cookie 总数" value={cookies.length} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="已分配" value={assignedCount} styles={{ content: { color: token.colorSuccess } }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="未分配" value={unassignedCount} styles={{ content: { color: token.colorError } }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Input.Search
|
||||
placeholder="搜索账号、UID 或分配客服"
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
size="small"
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
columns={columns}
|
||||
dataSource={filteredCookies}
|
||||
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_cookie_page_size', String(size));
|
||||
setCurrentPage(1);
|
||||
}
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 960 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user