完善虎牙账号管理并规范Cookie

This commit is contained in:
yml2213
2026-07-05 13:13:38 +08:00
parent 8f48b96fdc
commit 984096f758
15 changed files with 1320 additions and 59 deletions
+15 -1
View File
@@ -23,7 +23,14 @@ PERMISSIONS = {
"cookie:view": "查看 Cookie",
"cookie:export": "导出 Cookie",
# 虎牙
"huya:account": "虎牙 CK 管理",
"huya:account": "虎牙账号管理(兼容旧权限)",
"huya:view_all": "查看所有虎牙账号",
"huya:view_assigned": "查看分配给自己的虎牙账号",
"huya:import": "导入/登录虎牙账号",
"huya:assign": "分配虎牙账号",
"huya:delete": "删除虎牙账号",
"huya:cookie:view": "查看虎牙 Cookie",
"huya:cookie:export": "导出/清除虎牙 Cookie",
"huya:task": "虎牙任务管理",
"huya:bind": "虎牙绑定操作",
"huya:recharge": "虎牙充值操作",
@@ -49,6 +56,12 @@ ROLE_PERMISSIONS = {
"cookie:view",
"cookie:export",
"huya:account",
"huya:view_all",
"huya:import",
"huya:assign",
"huya:delete",
"huya:cookie:view",
"huya:cookie:export",
"huya:task",
"huya:bind",
"huya:recharge",
@@ -59,6 +72,7 @@ ROLE_PERMISSIONS = {
],
"support": [
"account:view_assigned",
"huya:view_assigned",
],
}
+298 -12
View File
@@ -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),
+2 -1
View File
@@ -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()
+3 -7
View File
@@ -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
+4
View File
@@ -16,6 +16,8 @@ const ProxyPage = lazy(() => import('./pages/ProxyPage'));
const UsersPage = lazy(() => import('./pages/UsersPage'));
const CookiePage = lazy(() => import('./pages/CookiePage'));
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
function RouteFallback() {
@@ -64,6 +66,8 @@ function AppContent() {
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
<Route path="users" element={lazyRoute(<UsersPage />)} />
+19 -1
View File
@@ -2,6 +2,7 @@ import api from './client';
import type {
HuyaAccountItem,
HuyaConfig,
HuyaCookieItem,
HuyaCookieImportResult,
HuyaGoodsItem,
HuyaPasswordLoginRequest,
@@ -10,21 +11,38 @@ import type {
HuyaTaskBatchRequest,
HuyaTaskBatchResult,
HuyaTaskItem,
MessageCountResponse,
MessageDeletedResponse,
MessageResponse,
} from './types';
export const huyaApi = {
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/huya/task-types'),
listAccounts: (params?: { tag?: string }) =>
listAccounts: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) =>
api.get<HuyaAccountItem[], HuyaAccountItem[]>('/huya/accounts', { params }),
importCookies: (text: string, tag: string = '') =>
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
passwordLogin: (data: HuyaPasswordLoginRequest) =>
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/password-login', data),
assign: (id: number, assigned_to: number | null) =>
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/assign`, { assigned_to }),
batchAssign: (account_ids: number[], assigned_to: number | null) =>
api.post<MessageCountResponse, MessageCountResponse>('/huya/accounts/batch-assign', { account_ids, assigned_to }),
assignmentsSummary: () =>
api.get<{ support_users: { id: number; username: string; assigned_count: number }[]; unassigned_count: number }, { support_users: { id: number; username: string; assigned_count: number }[]; unassigned_count: number }>('/huya/accounts/assignments/summary'),
setTag: (id: number, tag: string) =>
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/tag`, { tag }),
batchTag: (account_ids: number[], tag: string) =>
api.put<MessageCountResponse, MessageCountResponse>('/huya/accounts/batch-tag', { account_ids, tag }),
listTags: () => api.get<string[], string[]>('/huya/accounts/tags/list'),
deleteAccount: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/accounts/${id}`),
deleteAccounts: (accountIds: number[]) =>
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/accounts/batch', { params: { account_ids: accountIds.join(',') } }),
listCookies: () => api.get<HuyaCookieItem[], HuyaCookieItem[]>('/huya/cookies'),
exportCookies: (format?: string) => api.get<Blob, Blob>('/huya/cookies/export', { responseType: 'blob', params: format ? { format } : {} }),
deleteCookie: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/cookies/${id}`),
deleteCookies: (accountIds: number[]) =>
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/cookies/batch', { params: { account_ids: accountIds.join(',') } }),
getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'),
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
+13
View File
@@ -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;
+7 -1
View File
@@ -68,9 +68,15 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
}
// 虎牙
if (can('huya:account')) {
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: <GiftOutlined /> });
}
if (canAny(['huya:account', 'huya:assign'])) {
huyaItems.push({ key: '/huya/assignments', label: '分配管理', icon: <SwapOutlined /> });
}
if (canAny(['huya:account', 'huya:cookie:view', 'huya:cookie:export'])) {
huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
if (can('huya:task')) {
huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
}
+1 -1
View File
@@ -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(() => {
+208 -22
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button, Card, Col, Form, Input, message, Modal, Popconfirm, Row, Space, Statistic, Table, Tag, Typography,
Button, Card, Col, Form, Input, message, Modal, Popconfirm, Row, Select, Space, Statistic, Table, Tag, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import { DeleteOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem } from '../api/modules';
import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type SupportUserItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
@@ -37,6 +37,8 @@ interface PasswordLoginFormValues {
export default function HuyaAccountsPage() {
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [users, setUsers] = useState<SupportUserItem[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState('');
@@ -45,7 +47,10 @@ export default function HuyaAccountsPage() {
const [passwordLoginOpen, setPasswordLoginOpen] = useState(false);
const [passwordLogging, setPasswordLogging] = useState(false);
const [searchText, setSearchText] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchTagInput, setBatchTagInput] = useState('');
const [batchTagVisible, setBatchTagVisible] = useState(false);
const [pageSize, setPageSize] = useState(() => {
const v = localStorage.getItem('huya_account_page_size');
return v ? Number(v) || 20 : 20;
@@ -55,26 +60,56 @@ export default function HuyaAccountsPage() {
const { can } = usePermissions();
const canManage = can('huya:account');
const canImport = can('huya:import') || canManage;
const canAssign = can('huya:assign') || canManage;
const canDelete = can('huya:delete') || canManage;
const canViewCookie = can('huya:cookie:view') || can('huya:cookie:export') || canManage;
const loadAccounts = useCallback(async () => {
setLoading(true);
try {
const data = await huyaApi.listAccounts();
const params: { tag?: string } = {};
if (tagFilter) params.tag = tagFilter;
const data = await huyaApi.listAccounts(params);
setAccounts(data);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, [tagFilter]);
const loadUsers = useCallback(async () => {
try {
const data = await huyaApi.assignmentsSummary();
setUsers(data.support_users);
} catch {
// 忽略客服列表加载失败,账号列表仍可继续使用。
}
}, []);
const loadTags = useCallback(async () => {
try {
const data = await huyaApi.listTags();
setTags(data);
} catch {
// 忽略标签加载失败,页面会退化为无标签筛选。
}
}, []);
useEffect(() => {
loadAccounts();
}, [loadAccounts]);
if (canAssign) loadUsers();
loadTags();
}, [loadAccounts, canAssign, loadUsers, loadTags]);
const tags = useMemo(() => {
return [...new Set(accounts.map((item) => item.tag.trim()).filter(Boolean))].sort();
}, [accounts]);
const tagColorMap = useMemo(() => {
const map: Record<string, string> = {};
tags.forEach((tag, index) => {
map[tag] = ['blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano'][index % 8];
});
return map;
}, [tags]);
const filteredAccounts = useMemo(() => {
const s = searchText.trim().toLowerCase();
@@ -103,6 +138,7 @@ export default function HuyaAccountsPage() {
setImportText('');
setImportTag('');
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
@@ -134,6 +170,7 @@ export default function HuyaAccountsPage() {
setPasswordLoginOpen(false);
passwordLoginForm.resetFields();
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
@@ -147,6 +184,7 @@ export default function HuyaAccountsPage() {
message.success('已删除');
setSelectedRowKeys((prev) => prev.filter((key) => key !== id));
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
@@ -162,6 +200,46 @@ export default function HuyaAccountsPage() {
message.success(result.message);
setSelectedRowKeys([]);
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleAssign = async (accountId: number, assignedTo: number | null) => {
try {
await huyaApi.assign(accountId, assignedTo);
message.success('已分配');
loadAccounts();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleSetTag = async (accountId: number, tag: string) => {
try {
await huyaApi.setTag(accountId, tag);
message.success('标签已更新');
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleBatchTag = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙账号');
return;
}
try {
await huyaApi.batchTag(selectedRowKeys.map((key) => Number(key)), batchTagInput);
message.success(`已为 ${selectedRowKeys.length} 个虎牙账号设置标签`);
setBatchTagVisible(false);
setBatchTagInput('');
setSelectedRowKeys([]);
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
@@ -169,6 +247,7 @@ export default function HuyaAccountsPage() {
const boundCount = accounts.filter((item) => item.game_name || item.game_channel || item.game_phone).length;
const pointCount = accounts.filter((item) => item.points !== null && item.points !== undefined).length;
const assignedCount = accounts.filter((item) => item.assigned_to).length;
const columns: TableProps<HuyaAccountItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
@@ -188,7 +267,43 @@ export default function HuyaAccountsPage() {
title: '标签',
dataIndex: 'tag',
width: 110,
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
render: (tag: string, record) => {
if (!tag) {
if (canImport) {
return (
<Input
size="small"
placeholder="输入标签"
style={{ width: 90 }}
onPressEnter={(e) => {
const value = (e.target as HTMLInputElement).value.trim();
if (value) handleSetTag(record.id, value);
}}
onBlur={(e) => {
const value = e.target.value.trim();
if (value) handleSetTag(record.id, value);
}}
/>
);
}
return <Text type="secondary">-</Text>;
}
if (canImport) {
return (
<Tag
color={tagColorMap[tag]}
closable
onClose={(e) => {
e.preventDefault();
handleSetTag(record.id, '');
}}
>
{tag}
</Tag>
);
}
return <Tag color={tagColorMap[tag]}>{tag}</Tag>;
},
},
{
title: '积分',
@@ -224,10 +339,30 @@ export default function HuyaAccountsPage() {
ellipsis: true,
render: (value: string) => (
<Text code style={{ fontSize: 12 }}>
{value || '-'}
{canViewCookie ? (value || '-') : '***'}
</Text>
),
},
{
title: '分配给',
dataIndex: 'assigned_username',
width: 150,
render: (_: unknown, record) => {
if (canAssign) {
return (
<Select
style={{ width: 130 }}
allowClear
placeholder="未分配"
value={record.assigned_to}
onChange={(value) => handleAssign(record.id, value ?? null)}
options={users.map((user) => ({ value: user.id, label: user.username }))}
/>
);
}
return record.assigned_username || <Text type="secondary"></Text>;
},
},
{
title: '状态',
dataIndex: 'status',
@@ -249,9 +384,11 @@ export default function HuyaAccountsPage() {
fixed: 'right',
align: 'center',
render: (_: unknown, record) => (
<Popconfirm title="确认删除这条虎牙 CK" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
canDelete ? (
<Popconfirm title="确认删除这条虎牙账号?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
) : null
),
},
];
@@ -259,24 +396,45 @@ export default function HuyaAccountsPage() {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}> CK </h2>
<h2 style={{ margin: 0 }}></h2>
<Space wrap>
<Select
allowClear
placeholder="按标签筛选"
style={{ width: 150 }}
value={tagFilter || undefined}
onChange={(value) => setTagFilter(value || '')}
options={tags.map((tag) => ({ value: tag, label: tag }))}
prefix={<FilterOutlined />}
/>
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
</Button>
{selectedRowKeys.length > 0 && (
{canImport && (
<Button
disabled={selectedRowKeys.length === 0}
icon={<TagOutlined />}
onClick={() => {
setBatchTagInput('');
setBatchTagVisible(true);
}}
>
</Button>
)}
{canDelete && selectedRowKeys.length > 0 && (
<Popconfirm title={`确认删除选中的 ${selectedRowKeys.length} 条虎牙 CK`} onConfirm={handleDeleteSelected}>
<Button danger icon={<DeleteOutlined />}>
({selectedRowKeys.length})
</Button>
</Popconfirm>
)}
{canManage && (
{canImport && (
<Button icon={<LoginOutlined />} onClick={openPasswordLogin}>
</Button>
)}
{canManage && (
{canImport && (
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
CK
</Button>
@@ -286,14 +444,23 @@ export default function HuyaAccountsPage() {
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="CK 总数" value={accounts.length} /></Card>
<Card size="small"><Statistic title="账号总数" value={accounts.length} /></Card>
</Col>
<Col xs={24} sm={8}>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="标签数" value={tags.length} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已查积分" value={pointCount} /></Card>
</Col>
<Col xs={24} sm={8}>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已分配" value={assignedCount} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="未分配" value={accounts.length - assignedCount} /></Card>
</Col>
</Row>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
@@ -313,10 +480,10 @@ export default function HuyaAccountsPage() {
</div>
<Table
rowSelection={{
rowSelection={(canImport || canDelete || canAssign) ? {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
} : undefined}
columns={columns}
dataSource={filteredAccounts}
rowKey="id"
@@ -366,6 +533,25 @@ export default function HuyaAccountsPage() {
</Space>
</Modal>
<Modal
title="批量设置标签"
open={batchTagVisible}
onCancel={() => setBatchTagVisible(false)}
onOk={handleBatchTag}
okText="确定"
width={400}
>
<p> {selectedRowKeys.length} </p>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="输入或选择标签"
value={batchTagInput ? [batchTagInput] : []}
onChange={(values) => setBatchTagInput(values.length > 0 ? values[values.length - 1] : '')}
options={tags.map((tag) => ({ value: tag, label: tag }))}
/>
</Modal>
<Modal
title="虎牙密码登录"
open={passwordLoginOpen}
@@ -0,0 +1,382 @@
import { useEffect, useMemo, useState } from 'react';
import {
Badge, Button, Card, Col, Input, message, Row, Select, Space, Statistic, Table, Tabs, Tag, theme, Typography,
} from 'antd';
import {
CheckCircleOutlined, ClearOutlined, SwapOutlined, TeamOutlined, UsergroupAddOutlined, UserOutlined,
} from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type SupportUserItem } from '../api/modules';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
const STORAGE_KEY_SELECTED_USER = 'huya_assignments_selected_user_id';
function accountName(account: HuyaAccountItem) {
return account.nickname || account.username || account.uid || `#${account.id}`;
}
export default function HuyaAssignmentsPage() {
const { token } = theme.useToken();
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [searchText, setSearchText] = useState('');
const [filterTag, setFilterTag] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState('unassigned');
const [assigning, setAssigning] = useState(false);
const [pageSize, setPageSize] = useState(() => {
const value = localStorage.getItem('huya_assignment_page_size');
return value ? Number(value) || 15 : 15;
});
const [currentPage, setCurrentPage] = useState(1);
const loadSummary = async () => {
try {
const data = await huyaApi.assignmentsSummary();
setSupportUsers(data.support_users);
return data.support_users;
} catch (e: unknown) {
message.error(getErrorMessage(e));
return [];
}
};
const loadAccounts = async () => {
try {
const data = await huyaApi.listAccounts({ has_cookie: true });
setAccounts(data);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
useEffect(() => {
const init = async () => {
void loadAccounts();
const users = await loadSummary();
if (users.length > 0) {
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
const savedUser = savedId ? users.find((user) => user.id === Number(savedId)) : null;
setSelectedUser(savedUser || users[0]);
}
};
void init();
}, []);
const allTags = useMemo(() => {
return [...new Set(accounts.map((account) => (account.tag || '').trim()).filter(Boolean))].sort();
}, [accounts]);
const unassignedAccounts = useMemo(() => accounts.filter((account) => !account.assigned_to), [accounts]);
const assignedAccounts = useMemo(() => {
if (!selectedUser) return [];
return accounts.filter((account) => account.assigned_to === selectedUser.id);
}, [accounts, selectedUser]);
const displayAccounts = useMemo(() => {
let list = activeTab === 'unassigned' ? unassignedAccounts : assignedAccounts;
if (searchText) {
const search = searchText.toLowerCase();
list = list.filter((account) => (
accountName(account).toLowerCase().includes(search) ||
account.uid.toLowerCase().includes(search) ||
account.yyuid.toLowerCase().includes(search) ||
account.game_phone.toLowerCase().includes(search)
));
}
if (filterTag) {
list = list.filter((account) => (account.tag || '').trim() === filterTag);
}
return list;
}, [activeTab, assignedAccounts, filterTag, searchText, unassignedAccounts]);
const handleSelectUser = (user: SupportUserItem) => {
setSelectedUser(user);
setSelectedRowKeys([]);
setActiveTab('unassigned');
setSearchText('');
setFilterTag(undefined);
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
};
const refreshAfterAssign = async () => {
await loadAccounts();
const users = await loadSummary();
const refreshed = users.find((user) => user.id === selectedUser?.id);
if (refreshed) setSelectedUser(refreshed);
};
const handleBatchAssign = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙账号');
return;
}
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
setAssigning(true);
try {
await huyaApi.batchAssign(selectedRowKeys, selectedUser.id);
message.success(`已将 ${selectedRowKeys.length} 个虎牙账号分配给 ${selectedUser.username}`);
setSelectedRowKeys([]);
await refreshAfterAssign();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
const handleBatchUnassign = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙账号');
return;
}
setAssigning(true);
try {
await huyaApi.batchAssign(selectedRowKeys, null);
message.success(`已取消 ${selectedRowKeys.length} 个虎牙账号的分配`);
setSelectedRowKeys([]);
await refreshAfterAssign();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
const handleAssignAllUnassigned = async () => {
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
const ids = displayAccounts.map((account) => account.id);
if (ids.length === 0) {
message.info('没有未分配的虎牙账号');
return;
}
setAssigning(true);
try {
await huyaApi.batchAssign(ids, selectedUser.id);
message.success(`已将 ${ids.length} 个虎牙账号分配给 ${selectedUser.username}`);
await refreshAfterAssign();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
const assignedCount = accounts.filter((account) => !!account.assigned_to).length;
const unassignedCount = accounts.length - assignedCount;
const columns = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{
title: '虎牙账号',
render: (_: unknown, record: HuyaAccountItem) => (
<Space direction="vertical" size={0}>
<Text strong>{accountName(record)}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>UID {record.uid || record.yyuid || '-'}</Text>
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 100,
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
{
title: '手机号',
dataIndex: 'game_phone',
width: 130,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<h2 style={{ margin: 0, flexShrink: 0 }}></h2>
<Row gutter={12} style={{ flexShrink: 0 }}>
<Col span={6}>
<Card size="small"><Statistic title="虎牙账号" value={accounts.length} prefix={<TeamOutlined />} /></Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="已分配" value={assignedCount} styles={{ content: { color: token.colorSuccess } }} prefix={<CheckCircleOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="未分配" value={unassignedCount} styles={{ content: { color: token.colorError } }} prefix={<SwapOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="客服人数" value={supportUsers.length} prefix={<UsergroupAddOutlined />} /></Card>
</Col>
</Row>
<Row gutter={12} style={{ flex: 1, minHeight: 0 }}>
<Col span={8} style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Card
title="客服列表"
size="small"
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
styles={{ body: { flex: 1, overflow: 'auto', padding: 8 } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{supportUsers.map((user) => {
const isSelected = selectedUser?.id === user.id;
return (
<div
key={user.id}
onClick={() => handleSelectUser(user)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderRadius: 6,
cursor: 'pointer',
border: `1px solid ${isSelected ? token.colorPrimary : token.colorBorderSecondary}`,
background: isSelected ? token.colorPrimaryBg : token.colorBgContainer,
transition: 'all 0.2s',
}}
>
<Space>
<UserOutlined style={{ color: token.colorPrimary }} />
<Text strong>{user.username}</Text>
</Space>
<Badge count={user.assigned_count} showZero style={{ backgroundColor: token.colorPrimary }} />
</div>
);
})}
{supportUsers.length === 0 && (
<Text type="secondary" style={{ textAlign: 'center', padding: 20, display: 'block' }}>
</Text>
)}
</div>
</Card>
</Col>
<Col span={16} style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Card
size="small"
title={selectedUser ? (
<Space>
<span></span>
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
{selectedUser.username} {selectedUser.assigned_count}
</Tag>
</Space>
) : <Text type="secondary"></Text>}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 } }}
>
<div style={{ padding: '8px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<Input.Search
placeholder="搜索昵称、UID、手机号"
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 220 }}
size="small"
/>
<Select
allowClear
placeholder="按标签筛选"
value={filterTag}
onChange={(value) => setFilterTag(value || undefined)}
style={{ width: 150 }}
size="small"
options={allTags.map((tag) => ({ value: tag, label: tag }))}
/>
</div>
<Tabs
size="small"
activeKey={activeTab}
onChange={(key) => { setActiveTab(key); setSelectedRowKeys([]); }}
style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }}
items={[
{ key: 'unassigned', label: `未分配 (${unassignedAccounts.length})` },
{
key: 'assigned',
label: selectedUser ? `已分配给 ${selectedUser.username} (${assignedAccounts.length})` : '已分配账号',
disabled: !selectedUser,
},
]}
/>
<div style={{ padding: '0 16px 8px', flexShrink: 0 }}>
<Space>
{activeTab === 'unassigned' && selectedUser && (
<>
<Button
type="primary"
size="small"
icon={<SwapOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchAssign}
>
({selectedRowKeys.length}) {selectedUser.username}
</Button>
<Button size="small" loading={assigning} onClick={handleAssignAllUnassigned}>
{selectedUser.username}
</Button>
</>
)}
{activeTab === 'assigned' && selectedUser && (
<Button
danger
size="small"
icon={<ClearOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchUnassign}
>
({selectedRowKeys.length})
</Button>
)}
</Space>
</div>
<Table
columns={columns}
dataSource={displayAccounts}
rowKey="id"
size="small"
pagination={{
current: currentPage,
pageSize,
size: 'small',
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem('huya_assignment_page_size', String(size));
setCurrentPage(1);
}
},
}}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
}}
scroll={{ y: 'calc(100vh - 500px)' }}
/>
</Card>
</Col>
</Row>
</div>
);
}
+301
View File
@@ -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>
);
}