diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 9891f57..0343d49 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -1,8 +1,8 @@ """账号管理路由""" from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func -from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session, defer, joinedload from ..database import get_db from ..models import User, Account, AuditLog, LoginTask @@ -16,11 +16,15 @@ from ..services.account_service import ( router = APIRouter(prefix="/api/accounts", tags=["账号管理"]) -@router.get("", response_model=list[AccountOut]) +@router.get("") def list_accounts( assigned_only: bool = Query(False), tag: str = Query(None), has_cookie: bool = Query(False), + search: str = Query(""), + page: int | None = Query(None, ge=1), + page_size: int = Query(20, ge=1, le=200), + include_sensitive: bool = Query(True), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): @@ -44,7 +48,31 @@ def list_accounts( if tag: query = query.filter(Account.tag == tag) - accounts = query.order_by(Account.id).options(joinedload(Account.assigned_user)).all() + search_text = (search or "").strip() + if search_text: + pattern = f"%{search_text}%" + query = query.filter(or_( + Account.username.ilike(pattern), + Account.tag.ilike(pattern), + Account.remark.ilike(pattern), + )) + + total = None + if page is not None: + total = query.order_by(None).count() + query = query.order_by(Account.id) + if page is not None: + query = query.offset((page - 1) * page_size).limit(page_size) + + can_include_sensitive = include_sensitive and user_has_permission(current, "account:view_full") + options = [joinedload(Account.assigned_user)] + if not can_include_sensitive: + options.extend([ + defer(Account.password), + defer(Account.email), + defer(Account.email_password), + ]) + accounts = query.options(*options).all() result = [] for acc in accounts: item = AccountOut( @@ -55,14 +83,45 @@ def list_accounts( created_at=acc.created_at, ) # 只有管理员可看完整字段(密码、邮箱等) - if user_has_permission(current, "account:view_full"): + if can_include_sensitive: item.password = acc.password item.email = acc.email item.email_password = acc.email_password result.append(item) + if page is not None: + return {"items": result, "total": total or 0, "page": page, "page_size": page_size} return result +@router.get("/summary") +def accounts_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """账号管理统计,避免前端为了卡片统计拉全量账号。""" + query = db.query(Account) + if not user_has_permission(current, "account:view_all"): + if user_has_permission(current, "account:view_assigned"): + query = query.filter(Account.assigned_to == current.id) + else: + raise HTTPException(status_code=403, detail="无权查看账号") + + total = query.count() + assigned_count = query.filter(Account.assigned_to.isnot(None)).count() + tag_count = ( + query.filter(Account.tag != "", Account.tag.isnot(None)) + .with_entities(Account.tag) + .distinct() + .count() + ) + return { + "total": total, + "assigned_count": assigned_count, + "unassigned_count": max(0, total - assigned_count), + "tag_count": tag_count, + } + + @router.post("/import") def import_accounts( req: AccountImport, diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index f54d503..c9643af 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -1,9 +1,10 @@ """Cookie 管理路由""" from datetime import timezone -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session +from sqlalchemy import or_ +from sqlalchemy.orm import Session, defer, joinedload import io import csv @@ -38,17 +39,52 @@ def _visible_cookie_tasks_query(db: Session, current: User): @router.get("") def list_cookies( + search: str = Query(""), + page: int | None = Query(None, ge=1), + page_size: int = Query(20, ge=1, le=200), + include_cookie: bool = Query(True), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """查看登录成功的 Cookie 列表。""" - tasks = _visible_cookie_tasks_query(db, current).order_by(LoginTask.finished_at.desc()).all() + if not include_cookie: + query = _visible_cookie_tasks_query(db, current).options(defer(LoginTask.cookie)) + else: + query = _visible_cookie_tasks_query(db, current).options( + joinedload(LoginTask.account).joinedload(Account.assigned_user), + ) + search_text = (search or "").strip() + if search_text: + pattern = f"%{search_text}%" + if user_has_permission(current, "login:view_all"): + query = query.join(Account, LoginTask.account_id == Account.id) + query = query.outerjoin(User, Account.assigned_to == User.id).filter(or_( + Account.username.ilike(pattern), + User.username.ilike(pattern), + )) + + total = None + if page is not None: + total = query.order_by(None).count() + query = query.order_by(LoginTask.finished_at.desc()) + if page is not None: + query = query.offset((page - 1) * page_size).limit(page_size) + + tasks = query.all() # 批量查账号,避免 N+1 查询 account_ids = [t.account_id for t in tasks] accounts_map = {} if account_ids: - accs = db.query(Account).filter(Account.id.in_(account_ids)).all() + account_query = db.query(Account).filter(Account.id.in_(account_ids)) + if not include_cookie: + account_query = account_query.options( + joinedload(Account.assigned_user), + defer(Account.password), + defer(Account.email), + defer(Account.email_password), + ) + accs = account_query.all() accounts_map = {a.id: a for a in accs} result = [] @@ -63,8 +99,8 @@ def list_cookies( "assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None, "created_at": _fmt_dt(t.finished_at), } - # 只有有 cookie:view 权限才返回 cookie 和密码(用于自定义格式复制) - if user_has_permission(current, "cookie:view"): + # 分页列表默认只返回预览,复制/导出时再获取完整敏感字段。 + if include_cookie and user_has_permission(current, "cookie:view"): cookie = t.cookie or "" item["cookie"] = cookie item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie @@ -74,9 +110,60 @@ def list_cookies( item["cookie_preview"] = "***" item["account_password"] = "" result.append(item) + if page is not None: + return {"items": result, "total": total or 0, "page": page, "page_size": page_size} return result +@router.get("/summary") +def cookies_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """Cookie 管理统计,避免前端为了卡片统计拉全量 Cookie。""" + query = _visible_cookie_tasks_query(db, current) + if user_has_permission(current, "login:view_all"): + query = query.join(Account, LoginTask.account_id == Account.id) + total = query.count() + assigned_count = query.filter(Account.assigned_to.isnot(None)).count() + return { + "total": total, + "assigned_count": assigned_count, + "unassigned_count": max(0, total - assigned_count), + } + + +@router.get("/{task_id}") +def get_cookie( + task_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。""" + task = _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="记录不存在") + acc = db.query(Account).filter(Account.id == task.account_id).first() + item = { + "id": task.id, + "batch_id": task.batch_id, + "account_id": task.account_id, + "account_username": acc.username if acc else "", + "assigned_to": acc.assigned_to if acc else None, + "assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None, + "created_at": _fmt_dt(task.finished_at), + "cookie": "", + "cookie_preview": "***", + "account_password": "", + } + if user_has_permission(current, "cookie:view"): + cookie = task.cookie or "" + item["cookie"] = cookie + item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie + item["account_password"] = acc.password if acc else "" + return item + + @router.get("/export") def export_cookies( format: str = "csv", diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 6e99037..52c79b4 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -8,8 +8,8 @@ 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 sqlalchemy import func, or_ +from sqlalchemy.orm import Session, defer, joinedload from core.huya import ( HuyaCredentialError, @@ -134,6 +134,31 @@ def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccou ) +def _account_out_light(account: HuyaAccount, *, has_password: bool = False) -> HuyaAccountOut: + """虎牙账号列表轻量输出,不读取加密字段。""" + return HuyaAccountOut( + id=account.id, + uid=account.uid or "", + yyuid=account.yyuid or "", + username=account.username or "", + has_password=has_password, + nickname=account.nickname or "", + cookie="", + cookie_preview="***", + tag=account.tag or "", + remark=account.remark or "", + status=account.status or "", + points=account.points, + game_name=account.game_name or "", + game_channel=account.game_channel or "", + game_phone=account.game_phone or "", + assigned_to=account.assigned_to, + assigned_username=account.assigned_user.username if account.assigned_user else None, + created_at=account.created_at, + updated_at=account.updated_at, + ) + + def _sanitize_task_result(result: dict | None, *, include_images: bool = False) -> dict | None: """列表接口默认剥离 base64 图片,避免轮询每次传 1MB+ 数据。""" if not isinstance(result, dict): @@ -196,17 +221,50 @@ def _cookie_out(account: HuyaAccount, include_cookie: bool) -> dict: } +def _cookie_out_light(account: HuyaAccount) -> dict: + """虎牙 Cookie 列表轻量输出,不读取完整加密 Cookie。""" + 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_preview": "***", + } + + +def _huya_password_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]: + """不解密密码字段,仅判断密文字段是否非空。""" + if not account_ids: + return {} + rows = ( + db.query(HuyaAccount.id, (HuyaAccount.account_password != "").label("has_password")) + .filter(HuyaAccount.id.in_(account_ids)) + .all() + ) + return {account_id: bool(has_password) for account_id, has_password in rows} + + @router.get("/task-types") def task_types(current: User = Depends(require_permission("huya:task"))): """返回当前规划的虎牙任务类型。""" return SUPPORTED_TASK_TYPES -@router.get("/accounts", response_model=list[HuyaAccountOut]) +@router.get("/accounts") def list_accounts( assigned_only: bool = Query(False), tag: str | None = Query(None), has_cookie: bool = Query(False), + search: str = Query(""), + page: int | None = Query(None, ge=1), + page_size: int = Query(20, ge=1, le=200), + include_cookie: bool | None = Query(None), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): @@ -218,9 +276,71 @@ def list_accounts( query = query.filter(HuyaAccount.tag == tag) if has_cookie: query = query.filter(HuyaAccount.cookie != "") - accounts = query.order_by(HuyaAccount.id.desc()).all() - include_cookie = _can_view_huya_cookie(current) - return [_account_out(account, include_cookie=include_cookie) for account in accounts] + search_text = (search or "").strip() + if search_text: + pattern = f"%{search_text}%" + query = query.filter(or_( + HuyaAccount.uid.ilike(pattern), + HuyaAccount.yyuid.ilike(pattern), + HuyaAccount.username.ilike(pattern), + HuyaAccount.nickname.ilike(pattern), + HuyaAccount.tag.ilike(pattern), + HuyaAccount.game_name.ilike(pattern), + HuyaAccount.game_phone.ilike(pattern), + )) + + total = None + if page is not None: + total = query.order_by(None).count() + should_include_cookie = _can_view_huya_cookie(current) if include_cookie is None else (include_cookie and _can_view_huya_cookie(current)) + query = query.order_by(HuyaAccount.id.desc()) + if page is not None: + query = query.offset((page - 1) * page_size).limit(page_size) + + if not should_include_cookie: + query = query.options(defer(HuyaAccount.cookie), defer(HuyaAccount.account_password)) + accounts = query.all() + if should_include_cookie: + result = [_account_out(account, include_cookie=True) for account in accounts] + else: + password_map = _huya_password_state_map(db, [account.id for account in accounts]) + result = [_account_out_light(account, has_password=password_map.get(account.id, False)) for account in accounts] + if page is not None: + return {"items": result, "total": total or 0, "page": page, "page_size": page_size} + return result + + +@router.get("/accounts/summary") +def accounts_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """虎牙账号统计,避免前端为了卡片统计拉全量账号。""" + query = _visible_huya_accounts_query(db, current) + total = query.count() + assigned_count = query.filter(HuyaAccount.assigned_to.isnot(None)).count() + tag_count = ( + query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)) + .with_entities(HuyaAccount.tag) + .distinct() + .count() + ) + password_ready_count = query.filter(HuyaAccount.account_password != "").count() + point_count = query.filter(HuyaAccount.points.isnot(None)).count() + bound_count = query.filter(or_( + HuyaAccount.game_name != "", + HuyaAccount.game_channel != "", + HuyaAccount.game_phone != "", + )).count() + return { + "total": total, + "assigned_count": assigned_count, + "unassigned_count": max(0, total - assigned_count), + "tag_count": tag_count, + "password_ready_count": password_ready_count, + "point_count": point_count, + "bound_count": bound_count, + } @router.post("/accounts/import-cookies") @@ -826,13 +946,59 @@ def list_tags( @router.get("/cookies") def list_cookies( + search: str = Query(""), + page: int | None = Query(None, ge=1), + page_size: int = Query(20, ge=1, le=200), + include_cookie: bool = Query(True), 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] + should_include_cookie = include_cookie and _can_view_huya_cookie(current) + query = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "") + search_text = (search or "").strip() + if search_text: + pattern = f"%{search_text}%" + query = query.outerjoin(User, HuyaAccount.assigned_to == User.id).filter(or_( + HuyaAccount.uid.ilike(pattern), + HuyaAccount.yyuid.ilike(pattern), + HuyaAccount.username.ilike(pattern), + HuyaAccount.nickname.ilike(pattern), + User.username.ilike(pattern), + )) + total = None + if page is not None: + total = query.order_by(None).count() + query = query.order_by(HuyaAccount.updated_at.desc()) + if page is not None: + query = query.offset((page - 1) * page_size).limit(page_size) + if not should_include_cookie: + query = query.options(defer(HuyaAccount.cookie), defer(HuyaAccount.account_password)) + accounts = query.all() + result = ( + [_cookie_out(account, include_cookie=True) for account in accounts] + if should_include_cookie + else [_cookie_out_light(account) for account in accounts] + ) + if page is not None: + return {"items": result, "total": total or 0, "page": page, "page_size": page_size} + return result + + +@router.get("/cookies/summary") +def cookies_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """虎牙 Cookie 统计,避免前端为了卡片统计拉全量 Cookie。""" + query = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "") + total = query.count() + assigned_count = query.filter(HuyaAccount.assigned_to.isnot(None)).count() + return { + "total": total, + "assigned_count": assigned_count, + "unassigned_count": max(0, total - assigned_count), + } @router.get("/cookies/export") @@ -875,6 +1041,19 @@ def export_cookies( ) +@router.get("/cookies/{account_id}") +def get_cookie( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """获取单条虎牙 Cookie 详情,供复制操作按需读取完整敏感字段。""" + account = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id == account_id, HuyaAccount.cookie != "").first() + if not account: + raise HTTPException(status_code=404, detail="记录不存在") + return _cookie_out(account, include_cookie=_can_view_huya_cookie(current)) + + @router.delete("/cookies/batch") def delete_cookies_batch( account_ids: str = "", diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index 1854ec5..270b680 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -2,14 +2,20 @@ import api from './client'; import type { AccountItem, AssignmentsSummary, + BasicSummary, MessageCountResponse, MessageDeletedResponse, MessageResponse, + PageParams, + PaginatedResponse, } from './types'; export const accountApi = { - list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) => + list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean }) => api.get('/accounts', { params }), + listPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean }) => + api.get, PaginatedResponse>('/accounts', { params }), + summary: () => api.get('/accounts/summary'), import: (text: string) => api.post('/accounts/import', { text }), assign: (id: number, assigned_to: number | null) => api.put(`/accounts/${id}/assign`, { assigned_to }), diff --git a/web/frontend/src/api/cookies.ts b/web/frontend/src/api/cookies.ts index 1fd4b07..cf310be 100644 --- a/web/frontend/src/api/cookies.ts +++ b/web/frontend/src/api/cookies.ts @@ -1,8 +1,12 @@ import api from './client'; -import type { CookieItem, MessageDeletedResponse, MessageResponse } from './types'; +import type { BasicSummary, CookieItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types'; export const cookieApi = { list: () => api.get('/cookies'), + listPaged: (params: PageParams & { include_cookie?: boolean }) => + api.get, PaginatedResponse>('/cookies', { params }), + summary: () => api.get('/cookies/summary'), + get: (id: number) => api.get(`/cookies/${id}`), exportCsv: (format?: string) => api.get('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }), delete: (id: number) => api.delete(`/cookies/${id}`), deleteBatch: (ids: number[]) => api.delete('/cookies/batch', { params: { task_ids: ids.join(',') } }), diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 1f07f73..54e9021 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -1,6 +1,8 @@ import api from './client'; import type { + BasicSummary, HuyaAccountItem, + HuyaAccountSummary, HuyaAutoRegisterBatch, HuyaAutoRegisterRequest, HuyaAutoRegisterRetryRequest, @@ -25,12 +27,17 @@ import type { MessageCountResponse, MessageDeletedResponse, MessageResponse, + PageParams, + PaginatedResponse, } from './types'; export const huyaApi = { taskTypes: () => api.get, Record>('/huya/task-types'), - listAccounts: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) => + listAccounts: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_cookie?: boolean }) => api.get('/huya/accounts', { params }), + listAccountsPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_cookie?: boolean }) => + api.get, PaginatedResponse>('/huya/accounts', { params }), + accountsSummary: () => api.get('/huya/accounts/summary'), importCookies: (text: string, tag: string = '') => api.post('/huya/accounts/import-cookies', { text, tag }), importPasswordAccounts: (text: string, tag: string = '') => @@ -76,6 +83,10 @@ export const huyaApi = { deleteAccounts: (accountIds: number[]) => api.delete('/huya/accounts/batch', { params: { account_ids: accountIds.join(',') } }), listCookies: () => api.get('/huya/cookies'), + listCookiesPaged: (params: PageParams & { include_cookie?: boolean }) => + api.get, PaginatedResponse>('/huya/cookies', { params }), + cookiesSummary: () => api.get('/huya/cookies/summary'), + getCookie: (id: number) => api.get(`/huya/cookies/${id}`), exportCookies: (format?: string) => api.get('/huya/cookies/export', { responseType: 'blob', params: format ? { format } : {} }), deleteCookie: (id: number) => api.delete(`/huya/cookies/${id}`), deleteCookies: (accountIds: number[]) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 5693ef8..442ac10 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -17,6 +17,26 @@ export interface AppInfo { version: string; } +export interface PageParams { + page: number; + page_size: number; + search?: string; +} + +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + page_size: number; +} + +export interface BasicSummary { + total: number; + assigned_count: number; + unassigned_count: number; + tag_count?: number; +} + // ==================== Auth ==================== export interface LoginResult { @@ -172,6 +192,12 @@ export interface HuyaAccountItem { updated_at: string | null; } +export interface HuyaAccountSummary extends BasicSummary { + password_ready_count: number; + point_count: number; + bound_count: number; +} + export interface HuyaCookieImportResult extends MessageCountResponse { skipped: number; } diff --git a/web/frontend/src/hooks/useWebSocketLogs.ts b/web/frontend/src/hooks/useWebSocketLogs.ts index a0db087..4065dff 100644 --- a/web/frontend/src/hooks/useWebSocketLogs.ts +++ b/web/frontend/src/hooks/useWebSocketLogs.ts @@ -12,6 +12,8 @@ interface ConnectOptions { onResult?: () => void; } +const MAX_LOGS = 1000; + function toWebSocketUrl(pathOrUrl: string): string { if (pathOrUrl.startsWith('ws://') || pathOrUrl.startsWith('wss://')) { return pathOrUrl; @@ -63,9 +65,9 @@ export function useWebSocketLogs() { callbacksRef.current.onResult?.(); return; } - setLogs((prev) => [...prev, msg]); + setLogs((prev) => [...prev, msg].slice(-MAX_LOGS)); } catch { - setLogs((prev) => [...prev, { level: 'info', message: String(event.data) }]); + setLogs((prev) => [...prev, { level: 'info', message: String(event.data) }].slice(-MAX_LOGS)); } }; diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index c55195d..6dfc8ba 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -6,7 +6,7 @@ import { import { message } from '../utils/antdMessage'; import type { TableProps } from 'antd'; import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons'; -import { accountApi, userApi, type AccountItem, type UserInfo } from '../api/modules'; +import { accountApi, userApi, type AccountItem, type BasicSummary, type UserInfo } from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; import { formatTime } from '../utils/time'; import { getErrorMessage } from '../utils/error'; @@ -27,9 +27,12 @@ export default function AccountsPage() { const [importText, setImportText] = useState(''); const [importing, setImporting] = useState(false); const [tagFilter, setTagFilter] = useState(''); + const [searchText, setSearchText] = useState(''); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchTagInput, setBatchTagInput] = useState(''); const [batchTagVisible, setBatchTagVisible] = useState(false); + const [total, setTotal] = useState(0); + const [summary, setSummary] = useState({ total: 0, assigned_count: 0, unassigned_count: 0, tag_count: 0 }); const [pageSize, setPageSize] = useState(() => { const v = localStorage.getItem('account_page_size'); return v ? Number(v) || 20 : 20; @@ -47,14 +50,30 @@ export default function AccountsPage() { try { const params: { tag?: string } = {}; if (tagFilter) params.tag = tagFilter; - const data = await accountApi.list(params); - setAccounts(data); + const data = await accountApi.listPaged({ + ...params, + page: currentPage, + page_size: pageSize, + search: searchText.trim() || undefined, + include_sensitive: canViewFull, + }); + setAccounts(data.items); + setTotal(data.total); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { setLoading(false); } - }, [tagFilter]); + }, [canViewFull, currentPage, pageSize, searchText, tagFilter]); + + const loadSummary = useCallback(async () => { + try { + const data = await accountApi.summary(); + setSummary(data); + } catch { + // 统计加载失败时不影响主列表操作。 + } + }, []); const loadUsers = useCallback(async () => { try { @@ -76,9 +95,13 @@ export default function AccountsPage() { useEffect(() => { loadAccounts(); + }, [loadAccounts]); + + useEffect(() => { if (canAssign) loadUsers(); loadTags(); - }, [loadAccounts, canAssign, loadUsers, loadTags]); + loadSummary(); + }, [canAssign, loadUsers, loadTags, loadSummary]); const tagColorMap = useMemo(() => { const map: Record = {}; @@ -101,6 +124,7 @@ export default function AccountsPage() { setImportText(''); loadAccounts(); loadTags(); + loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { @@ -142,6 +166,7 @@ export default function AccountsPage() { setSelectedRowKeys([]); loadAccounts(); loadTags(); + loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } @@ -154,6 +179,7 @@ export default function AccountsPage() { setSelectedRowKeys((prev) => prev.filter((k) => k !== id)); loadAccounts(); loadTags(); + loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } @@ -170,6 +196,7 @@ export default function AccountsPage() { setSelectedRowKeys([]); loadAccounts(); loadTags(); + loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } @@ -279,10 +306,24 @@ export default function AccountsPage() { placeholder="按标签筛选" style={{ width: 150 }} value={tagFilter || undefined} - onChange={(val) => setTagFilter(val || '')} + onChange={(val) => { + setTagFilter(val || ''); + setCurrentPage(1); + }} options={tags.map((t) => ({ value: t, label: t }))} prefix={} /> + { + setSearchText(e.target.value); + setCurrentPage(1); + }} + /> {canImport && ( <>