diff --git a/core/huya/cookie_utils.py b/core/huya/cookie_utils.py new file mode 100644 index 0000000..832a176 --- /dev/null +++ b/core/huya/cookie_utils.py @@ -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 diff --git a/core/huya/http_client.py b/core/huya/http_client.py index 7062452..8c7aeec 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -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, diff --git a/core/huya/login.py b/core/huya/login.py index c4b4685..c7ac607 100644 --- a/core/huya/login.py +++ b/core/huya/login.py @@ -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]: diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 451a93f..baa11ae 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -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", ], } diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 190f26d..2929aad 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -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), diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index 3ea3fd7..5710ca9 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -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() diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index 7ce26a9..2ccb02d 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -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 diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index d27a68a..34c0c1e 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -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() { )} /> )} /> )} /> + )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 6545e2b..9714fb3 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -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>('/huya/task-types'), - listAccounts: (params?: { tag?: string }) => + listAccounts: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) => api.get('/huya/accounts', { params }), importCookies: (text: string, tag: string = '') => api.post('/huya/accounts/import-cookies', { text, tag }), passwordLogin: (data: HuyaPasswordLoginRequest) => api.post('/huya/accounts/password-login', data), + assign: (id: number, assigned_to: number | null) => + api.put(`/huya/accounts/${id}/assign`, { assigned_to }), + batchAssign: (account_ids: number[], assigned_to: number | null) => + api.post('/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(`/huya/accounts/${id}/tag`, { tag }), + batchTag: (account_ids: number[], tag: string) => + api.put('/huya/accounts/batch-tag', { account_ids, tag }), + listTags: () => api.get('/huya/accounts/tags/list'), deleteAccount: (id: number) => api.delete(`/huya/accounts/${id}`), deleteAccounts: (accountIds: number[]) => api.delete('/huya/accounts/batch', { params: { account_ids: accountIds.join(',') } }), + listCookies: () => api.get('/huya/cookies'), + exportCookies: (format?: string) => api.get('/huya/cookies/export', { responseType: 'blob', params: format ? { format } : {} }), + deleteCookie: (id: number) => api.delete(`/huya/cookies/${id}`), + deleteCookies: (accountIds: number[]) => + api.delete('/huya/cookies/batch', { params: { account_ids: accountIds.join(',') } }), getConfig: () => api.get('/huya/config'), updateConfig: (data: Partial) => api.put('/huya/config', data), listGoods: () => api.get('/huya/goods'), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 0d338c9..51d0589 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index daafea0..6f54671 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -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: }); } + if (canAny(['huya:account', 'huya:assign'])) { + huyaItems.push({ key: '/huya/assignments', label: '分配管理', icon: }); + } + if (canAny(['huya:account', 'huya:cookie:view', 'huya:cookie:export'])) { + huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: }); + } if (can('huya:task')) { huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: }); } diff --git a/web/frontend/src/pages/DashboardPage.tsx b/web/frontend/src/pages/DashboardPage.tsx index 0105fe3..917d09d 100644 --- a/web/frontend/src/pages/DashboardPage.tsx +++ b/web/frontend/src/pages/DashboardPage.tsx @@ -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(() => { diff --git a/web/frontend/src/pages/HuyaAccountsPage.tsx b/web/frontend/src/pages/HuyaAccountsPage.tsx index bf70ccb..c06a4cc 100644 --- a/web/frontend/src/pages/HuyaAccountsPage.tsx +++ b/web/frontend/src/pages/HuyaAccountsPage.tsx @@ -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([]); + const [users, setUsers] = useState([]); + const [tags, setTags] = useState([]); 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([]); + 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 = {}; + 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['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} : -, + render: (tag: string, record) => { + if (!tag) { + if (canImport) { + return ( + { + 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 -; + } + if (canImport) { + return ( + { + e.preventDefault(); + handleSetTag(record.id, ''); + }} + > + {tag} + + ); + } + return {tag}; + }, }, { title: '积分', @@ -224,10 +339,30 @@ export default function HuyaAccountsPage() { ellipsis: true, render: (value: string) => ( - {value || '-'} + {canViewCookie ? (value || '-') : '***'} ), }, + { + title: '分配给', + dataIndex: 'assigned_username', + width: 150, + render: (_: unknown, record) => { + if (canAssign) { + return ( + setTagFilter(value || '')} + options={tags.map((tag) => ({ value: tag, label: tag }))} + prefix={} + /> - {selectedRowKeys.length > 0 && ( + {canImport && ( + + )} + {canDelete && selectedRowKeys.length > 0 && ( )} - {canManage && ( + {canImport && ( )} - {canManage && ( + {canImport && ( @@ -286,14 +444,23 @@ export default function HuyaAccountsPage() { - + - + + + + - + + + + + + +
@@ -313,10 +480,10 @@ export default function HuyaAccountsPage() {
setSelectedRowKeys(keys), - }} + } : undefined} columns={columns} dataSource={filteredAccounts} rowKey="id" @@ -366,6 +533,25 @@ export default function HuyaAccountsPage() { + setBatchTagVisible(false)} + onOk={handleBatchTag} + okText="确定" + width={400} + > +

为选中的 {selectedRowKeys.length} 个虎牙账号设置标签:

+ setFilterTag(value || undefined)} + style={{ width: 150 }} + size="small" + options={allTags.map((tag) => ({ value: tag, label: tag }))} + /> + + + { 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, + }, + ]} + /> + +
+ + {activeTab === 'unassigned' && selectedUser && ( + <> + + + + )} + {activeTab === 'assigned' && selectedUser && ( + + )} + +
+ +
`共 ${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)' }} + /> + + + + + ); +} diff --git a/web/frontend/src/pages/HuyaCookiePage.tsx b/web/frontend/src/pages/HuyaCookiePage.tsx new file mode 100644 index 0000000..c36f721 --- /dev/null +++ b/web/frontend/src/pages/HuyaCookiePage.tsx @@ -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 { + 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([]); + const [loading, setLoading] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + 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 ? {name} : 未分配, + }, + { + title: 'Cookie', + dataIndex: 'cookie_preview', + ellipsis: true, + render: (value: string) => { + if (!canView) return ***; + return {value}; + }, + }, + { + title: '更新时间', + dataIndex: 'created_at', + width: 180, + align: 'center' as const, + render: (value: string | null) => value ? formatTime(value) : -, + }, + { + title: '操作', + width: 130, + align: 'center' as const, + fixed: 'right' as const, + render: (_: unknown, record: HuyaCookieItem) => ( + + + {canExport && ( + handleDelete(record.id)}> + + {canExport && ( + + + + )} + {canExport && ( + handleExport('csv') }, + { key: 'custom', label: '自定义(账号----ck)', onClick: () => handleExport('custom') }, + ], + }} + > + + + )} + + + + + + + + + + + + + + + + + + + +
+ setSearchText(e.target.value)} + style={{ width: 300 }} + size="small" + prefix={} + /> +
+ +
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 }} + /> + + ); +}