完善虎牙账号管理并规范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
|
import urllib.request
|
||||||
from typing import Optional, Callable
|
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 .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
|
||||||
from .wup_protocol import WupRequest, WupResponse
|
from .wup_protocol import WupRequest, WupResponse
|
||||||
|
|
||||||
@@ -122,16 +123,8 @@ class HuyaHttpClient:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_cookie(cookie: str) -> str:
|
def _normalize_cookie(cookie: str) -> str:
|
||||||
"""HTTP 业务 UserId.sCookie 需要带 huya_ua 前缀,避免重复写入。"""
|
"""HTTP 业务 UserId.sCookie 需要带 huya_ua 前缀,避免重复写入。"""
|
||||||
import re
|
pairs = [(key, value) for key, value in cookie_pairs(normalize_huya_cookie(cookie)) if key != "huya_ua"]
|
||||||
cookie = (cookie or "").strip()
|
return normalize_cookie_pairs([("huya_ua", HTTP_HUYA_UA), *pairs])
|
||||||
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}"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_user(uid: int, guid: str, cookie: str):
|
def _build_user(uid: int, guid: str, cookie: str):
|
||||||
@@ -176,7 +169,7 @@ class HuyaHttpClient:
|
|||||||
user = ActivityUserId()
|
user = ActivityUserId()
|
||||||
user.lUid = uid
|
user.lUid = uid
|
||||||
user.sHuYaUA = HTTP_HUYA_UA
|
user.sHuYaUA = HTTP_HUYA_UA
|
||||||
user.sCookie = cookie or ""
|
user.sCookie = normalize_huya_cookie(cookie)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def call_rpc(self, service: str, method: str,
|
def call_rpc(self, service: str, method: str,
|
||||||
|
|||||||
+3
-2
@@ -16,6 +16,8 @@ from urllib.parse import quote, urlsplit, urlunsplit
|
|||||||
import requests
|
import requests
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from .cookie_utils import normalize_huya_cookie
|
||||||
|
|
||||||
|
|
||||||
APP_ID = "5002"
|
APP_ID = "5002"
|
||||||
APP_VERSION = "2.6"
|
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:
|
def cookie_string(cookies: requests.cookies.RequestsCookieJar | Mapping[str, str]) -> str:
|
||||||
"""把 CookieJar/dict 转成浏览器 Cookie 字符串。"""
|
"""把 CookieJar/dict 转成浏览器 Cookie 字符串。"""
|
||||||
items = cookies.items() if isinstance(cookies, Mapping) else cookies.items()
|
return normalize_huya_cookie(cookies)
|
||||||
return "; ".join(f"{key}={value}" for key, value in items if value is not None)
|
|
||||||
|
|
||||||
|
|
||||||
def cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]:
|
def cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]:
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ PERMISSIONS = {
|
|||||||
"cookie:view": "查看 Cookie",
|
"cookie:view": "查看 Cookie",
|
||||||
"cookie:export": "导出 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:task": "虎牙任务管理",
|
||||||
"huya:bind": "虎牙绑定操作",
|
"huya:bind": "虎牙绑定操作",
|
||||||
"huya:recharge": "虎牙充值操作",
|
"huya:recharge": "虎牙充值操作",
|
||||||
@@ -49,6 +56,12 @@ ROLE_PERMISSIONS = {
|
|||||||
"cookie:view",
|
"cookie:view",
|
||||||
"cookie:export",
|
"cookie:export",
|
||||||
"huya:account",
|
"huya:account",
|
||||||
|
"huya:view_all",
|
||||||
|
"huya:import",
|
||||||
|
"huya:assign",
|
||||||
|
"huya:delete",
|
||||||
|
"huya:cookie:view",
|
||||||
|
"huya:cookie:export",
|
||||||
"huya:task",
|
"huya:task",
|
||||||
"huya:bind",
|
"huya:bind",
|
||||||
"huya:recharge",
|
"huya:recharge",
|
||||||
@@ -59,6 +72,7 @@ ROLE_PERMISSIONS = {
|
|||||||
],
|
],
|
||||||
"support": [
|
"support": [
|
||||||
"account:view_assigned",
|
"account:view_assigned",
|
||||||
|
"huya:view_assigned",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+298
-12
@@ -1,18 +1,27 @@
|
|||||||
"""虎牙基础管理路由"""
|
"""虎牙基础管理路由"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
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 sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from core.huya import HuyaCredentialError, HuyaLoginError, login_huya_password
|
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 ..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 ..models import HuyaAccount, HuyaConfig, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaTask, User
|
||||||
|
from ..permissions import user_has_permission
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
|
AccountAssign,
|
||||||
|
AccountTag,
|
||||||
|
BatchAssign,
|
||||||
HuyaAccountOut,
|
HuyaAccountOut,
|
||||||
HuyaConfigOut,
|
HuyaConfigOut,
|
||||||
HuyaConfigUpdate,
|
HuyaConfigUpdate,
|
||||||
@@ -39,22 +48,54 @@ from ..services.huya_runner import HuyaBatchRunner, huya_batch_registry
|
|||||||
router = APIRouter(prefix="/api/huya", tags=["虎牙"])
|
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:
|
def _fmt_cookie_preview(cookie: str) -> str:
|
||||||
if not cookie:
|
if not cookie:
|
||||||
return ""
|
return ""
|
||||||
return cookie[:50] + "..." if len(cookie) > 50 else cookie
|
return cookie[:50] + "..." if len(cookie) > 50 else cookie
|
||||||
|
|
||||||
|
|
||||||
def _account_out(account: HuyaAccount) -> HuyaAccountOut:
|
def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccountOut:
|
||||||
cookie = account.cookie or ""
|
cookie = normalize_huya_cookie(account.cookie or "")
|
||||||
return HuyaAccountOut(
|
return HuyaAccountOut(
|
||||||
id=account.id,
|
id=account.id,
|
||||||
uid=account.uid or "",
|
uid=account.uid or "",
|
||||||
yyuid=account.yyuid or "",
|
yyuid=account.yyuid or "",
|
||||||
username=account.username or "",
|
username=account.username or "",
|
||||||
nickname=account.nickname or "",
|
nickname=account.nickname or "",
|
||||||
cookie=cookie,
|
cookie=cookie if include_cookie else "",
|
||||||
cookie_preview=_fmt_cookie_preview(cookie),
|
cookie_preview=_fmt_cookie_preview(cookie) if include_cookie else "***",
|
||||||
tag=account.tag or "",
|
tag=account.tag or "",
|
||||||
remark=account.remark or "",
|
remark=account.remark or "",
|
||||||
status=account.status 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")
|
@router.get("/task-types")
|
||||||
def task_types(current: User = Depends(require_permission("huya:task"))):
|
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])
|
@router.get("/accounts", response_model=list[HuyaAccountOut])
|
||||||
def list_accounts(
|
def list_accounts(
|
||||||
|
assigned_only: bool = Query(False),
|
||||||
tag: str | None = Query(None),
|
tag: str | None = Query(None),
|
||||||
|
has_cookie: bool = Query(False),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("huya:account")),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""查看虎牙 CK 账号。"""
|
"""查看虎牙 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:
|
if tag:
|
||||||
query = query.filter(HuyaAccount.tag == tag)
|
query = query.filter(HuyaAccount.tag == tag)
|
||||||
|
if has_cookie:
|
||||||
|
query = query.filter(HuyaAccount.cookie != "")
|
||||||
accounts = query.order_by(HuyaAccount.id.desc()).all()
|
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")
|
@router.post("/accounts/import-cookies")
|
||||||
def import_cookies(
|
def import_cookies(
|
||||||
req: HuyaCookieImport,
|
req: HuyaCookieImport,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("huya:account")),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""粘贴并导入虎牙 Cookie。"""
|
"""粘贴并导入虎牙 Cookie。"""
|
||||||
|
_require_huya_perm(current, "huya:import")
|
||||||
count, skipped = import_huya_cookies(db, req.text, req.tag)
|
count, skipped = import_huya_cookies(db, req.text, req.tag)
|
||||||
return {
|
return {
|
||||||
"message": f"导入/更新 {count} 条,跳过 {skipped} 条",
|
"message": f"导入/更新 {count} 条,跳过 {skipped} 条",
|
||||||
@@ -138,9 +204,10 @@ def import_cookies(
|
|||||||
def password_login_account(
|
def password_login_account(
|
||||||
req: HuyaPasswordLoginRequest,
|
req: HuyaPasswordLoginRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("huya:account")),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""使用账号密码登录虎牙,成功后保存 Cookie。"""
|
"""使用账号密码登录虎牙,成功后保存 Cookie。"""
|
||||||
|
_require_huya_perm(current, "huya:import")
|
||||||
try:
|
try:
|
||||||
result = login_huya_password(
|
result = login_huya_password(
|
||||||
username=req.username.strip(),
|
username=req.username.strip(),
|
||||||
@@ -173,9 +240,10 @@ def password_login_account(
|
|||||||
def delete_accounts_batch(
|
def delete_accounts_batch(
|
||||||
account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"),
|
account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("huya:account")),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""批量删除虎牙 CK 账号及任务记录。"""
|
"""批量删除虎牙 CK 账号及任务记录。"""
|
||||||
|
_require_huya_perm(current, "huya:delete")
|
||||||
ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()]
|
ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()]
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(status_code=400, detail="无效的账号ID")
|
raise HTTPException(status_code=400, detail="无效的账号ID")
|
||||||
@@ -189,9 +257,10 @@ def delete_accounts_batch(
|
|||||||
def delete_account(
|
def delete_account(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("huya:account")),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""删除单个虎牙 CK 账号。"""
|
"""删除单个虎牙 CK 账号。"""
|
||||||
|
_require_huya_perm(current, "huya:delete")
|
||||||
account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first()
|
account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first()
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
@@ -201,6 +270,223 @@ def delete_account(
|
|||||||
return {"message": "已删除", "success": True}
|
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)
|
@router.get("/config", response_model=HuyaConfigOut)
|
||||||
def get_config(
|
def get_config(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from loguru import logger
|
|||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from core.huya import HuyaHttpClient
|
from core.huya import HuyaHttpClient
|
||||||
|
from core.huya.cookie_utils import normalize_huya_cookie
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaTask
|
from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaTask
|
||||||
from .huya_service import HUYA_CONFIG_FIELDS, cookie_value, ensure_huya_config, huya_config_value
|
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 "",
|
"yyuid": account.yyuid or "",
|
||||||
"username": account.username or "",
|
"username": account.username or "",
|
||||||
"nickname": account.nickname or "",
|
"nickname": account.nickname or "",
|
||||||
"cookie": account.cookie or "",
|
"cookie": normalize_huya_cookie(account.cookie or ""),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""虎牙基础业务服务。"""
|
"""虎牙基础业务服务。"""
|
||||||
|
|
||||||
import re
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
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 ..huya_defaults import HUYA_CONFIG_DEFAULTS, HUYA_CONFIG_FIELDS
|
||||||
from ..models import HuyaAccount, HuyaConfig, HuyaTask
|
from ..models import HuyaAccount, HuyaConfig, HuyaTask
|
||||||
|
|
||||||
@@ -44,12 +45,6 @@ def apply_huya_config_defaults(config: HuyaConfig) -> bool:
|
|||||||
return changed
|
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:
|
def _looks_like_huya_cookie(value: str) -> bool:
|
||||||
"""判断文本是否像虎牙 Cookie。"""
|
"""判断文本是否像虎牙 Cookie。"""
|
||||||
return "udb_" in value or "yyuid=" in value
|
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):
|
if not _looks_like_huya_cookie(cookie):
|
||||||
return None
|
return None
|
||||||
|
cookie = normalize_huya_cookie(cookie)
|
||||||
|
|
||||||
yyuid = cookie_value(cookie, "yyuid")
|
yyuid = cookie_value(cookie, "yyuid")
|
||||||
uid = cookie_value(cookie, "udb_uid") or 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 UsersPage = lazy(() => import('./pages/UsersPage'));
|
||||||
const CookiePage = lazy(() => import('./pages/CookiePage'));
|
const CookiePage = lazy(() => import('./pages/CookiePage'));
|
||||||
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
|
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
|
||||||
|
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
|
||||||
|
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
|
||||||
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
|
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
|
||||||
|
|
||||||
function RouteFallback() {
|
function RouteFallback() {
|
||||||
@@ -64,6 +66,8 @@ function AppContent() {
|
|||||||
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
|
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
|
||||||
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
|
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
|
||||||
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
|
<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="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
|
||||||
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
||||||
<Route path="users" element={lazyRoute(<UsersPage />)} />
|
<Route path="users" element={lazyRoute(<UsersPage />)} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import api from './client';
|
|||||||
import type {
|
import type {
|
||||||
HuyaAccountItem,
|
HuyaAccountItem,
|
||||||
HuyaConfig,
|
HuyaConfig,
|
||||||
|
HuyaCookieItem,
|
||||||
HuyaCookieImportResult,
|
HuyaCookieImportResult,
|
||||||
HuyaGoodsItem,
|
HuyaGoodsItem,
|
||||||
HuyaPasswordLoginRequest,
|
HuyaPasswordLoginRequest,
|
||||||
@@ -10,21 +11,38 @@ import type {
|
|||||||
HuyaTaskBatchRequest,
|
HuyaTaskBatchRequest,
|
||||||
HuyaTaskBatchResult,
|
HuyaTaskBatchResult,
|
||||||
HuyaTaskItem,
|
HuyaTaskItem,
|
||||||
|
MessageCountResponse,
|
||||||
MessageDeletedResponse,
|
MessageDeletedResponse,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const huyaApi = {
|
export const huyaApi = {
|
||||||
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/huya/task-types'),
|
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 }),
|
api.get<HuyaAccountItem[], HuyaAccountItem[]>('/huya/accounts', { params }),
|
||||||
importCookies: (text: string, tag: string = '') =>
|
importCookies: (text: string, tag: string = '') =>
|
||||||
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
|
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
|
||||||
passwordLogin: (data: HuyaPasswordLoginRequest) =>
|
passwordLogin: (data: HuyaPasswordLoginRequest) =>
|
||||||
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/password-login', data),
|
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}`),
|
deleteAccount: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/accounts/${id}`),
|
||||||
deleteAccounts: (accountIds: number[]) =>
|
deleteAccounts: (accountIds: number[]) =>
|
||||||
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/accounts/batch', { params: { account_ids: accountIds.join(',') } }),
|
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'),
|
getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'),
|
||||||
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
|
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
|
||||||
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
|
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
|
||||||
|
|||||||
@@ -148,6 +148,19 @@ export interface HuyaPasswordLoginResult extends MessageResponse {
|
|||||||
sdid: string;
|
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 {
|
export interface HuyaConfig {
|
||||||
room_pid: string;
|
room_pid: string;
|
||||||
sid: 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 /> });
|
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')) {
|
if (can('huya:task')) {
|
||||||
huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
|
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 canViewDouyuAccounts = canAny(['account:view_all', 'account:view_assigned']);
|
||||||
const canViewDouyuTasks = canAny(['login:batch', 'login:view_all', 'login:view_assigned']);
|
const canViewDouyuTasks = canAny(['login:batch', 'login:view_all', 'login:view_assigned']);
|
||||||
const canViewCookies = can('cookie:view');
|
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');
|
const canViewHuyaTasks = can('huya:task');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
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';
|
} from 'antd';
|
||||||
import type { TableProps } from 'antd';
|
import type { TableProps } from 'antd';
|
||||||
import { DeleteOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons';
|
||||||
import { huyaApi, type HuyaAccountItem } from '../api/modules';
|
import { huyaApi, type HuyaAccountItem, type SupportUserItem } from '../api/modules';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { formatTime } from '../utils/time';
|
import { formatTime } from '../utils/time';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
@@ -37,6 +37,8 @@ interface PasswordLoginFormValues {
|
|||||||
|
|
||||||
export default function HuyaAccountsPage() {
|
export default function HuyaAccountsPage() {
|
||||||
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
||||||
|
const [users, setUsers] = useState<SupportUserItem[]>([]);
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [importText, setImportText] = useState('');
|
const [importText, setImportText] = useState('');
|
||||||
@@ -45,7 +47,10 @@ export default function HuyaAccountsPage() {
|
|||||||
const [passwordLoginOpen, setPasswordLoginOpen] = useState(false);
|
const [passwordLoginOpen, setPasswordLoginOpen] = useState(false);
|
||||||
const [passwordLogging, setPasswordLogging] = useState(false);
|
const [passwordLogging, setPasswordLogging] = useState(false);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [tagFilter, setTagFilter] = useState('');
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
const [batchTagInput, setBatchTagInput] = useState('');
|
||||||
|
const [batchTagVisible, setBatchTagVisible] = useState(false);
|
||||||
const [pageSize, setPageSize] = useState(() => {
|
const [pageSize, setPageSize] = useState(() => {
|
||||||
const v = localStorage.getItem('huya_account_page_size');
|
const v = localStorage.getItem('huya_account_page_size');
|
||||||
return v ? Number(v) || 20 : 20;
|
return v ? Number(v) || 20 : 20;
|
||||||
@@ -55,26 +60,56 @@ export default function HuyaAccountsPage() {
|
|||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const canManage = can('huya:account');
|
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 () => {
|
const loadAccounts = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await huyaApi.listAccounts();
|
const params: { tag?: string } = {};
|
||||||
|
if (tagFilter) params.tag = tagFilter;
|
||||||
|
const data = await huyaApi.listAccounts(params);
|
||||||
setAccounts(data);
|
setAccounts(data);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e));
|
message.error(getErrorMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
}, [loadAccounts]);
|
if (canAssign) loadUsers();
|
||||||
|
loadTags();
|
||||||
|
}, [loadAccounts, canAssign, loadUsers, loadTags]);
|
||||||
|
|
||||||
const tags = useMemo(() => {
|
const tagColorMap = useMemo(() => {
|
||||||
return [...new Set(accounts.map((item) => item.tag.trim()).filter(Boolean))].sort();
|
const map: Record<string, string> = {};
|
||||||
}, [accounts]);
|
tags.forEach((tag, index) => {
|
||||||
|
map[tag] = ['blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano'][index % 8];
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
const filteredAccounts = useMemo(() => {
|
const filteredAccounts = useMemo(() => {
|
||||||
const s = searchText.trim().toLowerCase();
|
const s = searchText.trim().toLowerCase();
|
||||||
@@ -103,6 +138,7 @@ export default function HuyaAccountsPage() {
|
|||||||
setImportText('');
|
setImportText('');
|
||||||
setImportTag('');
|
setImportTag('');
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
|
loadTags();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e));
|
message.error(getErrorMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -134,6 +170,7 @@ export default function HuyaAccountsPage() {
|
|||||||
setPasswordLoginOpen(false);
|
setPasswordLoginOpen(false);
|
||||||
passwordLoginForm.resetFields();
|
passwordLoginForm.resetFields();
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
|
loadTags();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e));
|
message.error(getErrorMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -147,6 +184,7 @@ export default function HuyaAccountsPage() {
|
|||||||
message.success('已删除');
|
message.success('已删除');
|
||||||
setSelectedRowKeys((prev) => prev.filter((key) => key !== id));
|
setSelectedRowKeys((prev) => prev.filter((key) => key !== id));
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
|
loadTags();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e));
|
message.error(getErrorMessage(e));
|
||||||
}
|
}
|
||||||
@@ -162,6 +200,46 @@ export default function HuyaAccountsPage() {
|
|||||||
message.success(result.message);
|
message.success(result.message);
|
||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
loadAccounts();
|
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) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e));
|
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 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 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'] = [
|
const columns: TableProps<HuyaAccountItem>['columns'] = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
|
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
|
||||||
@@ -188,7 +267,43 @@ export default function HuyaAccountsPage() {
|
|||||||
title: '标签',
|
title: '标签',
|
||||||
dataIndex: 'tag',
|
dataIndex: 'tag',
|
||||||
width: 110,
|
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: '积分',
|
title: '积分',
|
||||||
@@ -224,10 +339,30 @@ export default function HuyaAccountsPage() {
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (value: string) => (
|
render: (value: string) => (
|
||||||
<Text code style={{ fontSize: 12 }}>
|
<Text code style={{ fontSize: 12 }}>
|
||||||
{value || '-'}
|
{canViewCookie ? (value || '-') : '***'}
|
||||||
</Text>
|
</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: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -249,9 +384,11 @@ export default function HuyaAccountsPage() {
|
|||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_: unknown, record) => (
|
render: (_: unknown, record) => (
|
||||||
<Popconfirm title="确认删除这条虎牙 CK?" onConfirm={() => handleDelete(record.id)}>
|
canDelete ? (
|
||||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
<Popconfirm title="确认删除这条虎牙账号?" onConfirm={() => handleDelete(record.id)}>
|
||||||
</Popconfirm>
|
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
) : null
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -259,24 +396,45 @@ export default function HuyaAccountsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
<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>
|
<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 icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</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}>
|
<Popconfirm title={`确认删除选中的 ${selectedRowKeys.length} 条虎牙 CK?`} onConfirm={handleDeleteSelected}>
|
||||||
<Button danger icon={<DeleteOutlined />}>
|
<Button danger icon={<DeleteOutlined />}>
|
||||||
删除选中 ({selectedRowKeys.length})
|
删除选中 ({selectedRowKeys.length})
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
{canManage && (
|
{canImport && (
|
||||||
<Button icon={<LoginOutlined />} onClick={openPasswordLogin}>
|
<Button icon={<LoginOutlined />} onClick={openPasswordLogin}>
|
||||||
密码登录
|
密码登录
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canManage && (
|
{canImport && (
|
||||||
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
|
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
|
||||||
粘贴 CK
|
粘贴 CK
|
||||||
</Button>
|
</Button>
|
||||||
@@ -286,14 +444,23 @@ export default function HuyaAccountsPage() {
|
|||||||
|
|
||||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={24} sm={8}>
|
<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>
|
||||||
<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>
|
<Card size="small"><Statistic title="已查积分" value={pointCount} /></Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={8}>
|
<Col xs={24} sm={4}>
|
||||||
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></Card>
|
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></Card>
|
||||||
</Col>
|
</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>
|
</Row>
|
||||||
|
|
||||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
@@ -313,10 +480,10 @@ export default function HuyaAccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowSelection={{
|
rowSelection={(canImport || canDelete || canAssign) ? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys),
|
onChange: (keys) => setSelectedRowKeys(keys),
|
||||||
}}
|
} : undefined}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={filteredAccounts}
|
dataSource={filteredAccounts}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
@@ -366,6 +533,25 @@ export default function HuyaAccountsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
title="虎牙密码登录"
|
title="虎牙密码登录"
|
||||||
open={passwordLoginOpen}
|
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