diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index bcb24f6..4b1f537 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -327,8 +327,8 @@ def create_planned_tasks( batch_id = uuid.uuid4().hex[:12] payload = payload or {} accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all() - if task_type in {"refresh_goods", "refresh_recharge_goods", "exchange_goods", "create_recharge_order"} and accounts: - # 全局快照、单笔兑换和单笔支付二维码都使用一个选中的 CK 即可。 + if task_type in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"} and accounts: + # 全局快照和单笔支付二维码使用一个选中的 CK 即可;兑换商品需要保留多账号批量任务。 accounts = accounts[:1] for account in accounts: db.add(HuyaTask( diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 1053ac0..63200e6 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Button, Card, Col, DatePicker, Form, Input, InputNumber, message, Modal, QRCode, Row, Select, Space, Table, Tabs, Tag, Tooltip, Typography, theme, + Button, Card, Col, DatePicker, Dropdown, Form, Input, InputNumber, message, Modal, QRCode, Radio, Row, Select, Space, Table, Tag, Tooltip, Typography, theme, } from 'antd'; -import type { TableProps } from 'antd'; +import type { MenuProps, TableProps } from 'antd'; import type { Dayjs } from 'dayjs'; +import type { MouseEvent as ReactMouseEvent } from 'react'; import { AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, - LinkOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, + LinkOutlined, MoreOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, } from '@ant-design/icons'; import { huyaApi, @@ -66,6 +67,42 @@ const STATUS_LABELS: Record = { error: '异常', }; +const ACCOUNT_STATUS_LABELS: Record = { + imported: '已导入', + updated: '已更新', + password_imported: '待登录', + login_success: '登录成功', + password_changed: '已改密', + login_failed: '登录失败', + active: '正常', + invalid: '失效', + points_queried: '已查积分', + game_queried: '已查角色', + game_not_bound: '未绑定', + bind_qr_generated: '待扫码', + bind_confirmed: '已绑定', + goods_exchanged: '已兑换', + recharge_order_created: '待支付', +}; + +const ACCOUNT_STATUS_COLORS: Record = { + imported: 'blue', + updated: 'cyan', + password_imported: 'warning', + login_success: 'success', + password_changed: 'success', + login_failed: 'error', + active: 'success', + invalid: 'error', + points_queried: 'success', + game_queried: 'success', + game_not_bound: 'default', + bind_qr_generated: 'processing', + bind_confirmed: 'success', + goods_exchanged: 'success', + recharge_order_created: 'processing', +}; + function accountLabel(account: HuyaAccountItem): string { const name = account.nickname || account.username || account.uid || `#${account.id}`; const tag = account.tag ? ` [${account.tag}]` : ''; @@ -73,6 +110,10 @@ function accountLabel(account: HuyaAccountItem): string { return `${name}${tag}${phone}`; } +function accountDisplayName(account: HuyaAccountItem): string { + return account.nickname || account.username || account.uid || `#${account.id}`; +} + function resultText(result: Record | null | undefined, key: string): string { const value = result?.[key]; return typeof value === 'string' ? value : ''; @@ -91,6 +132,57 @@ function resultProfileNick(result: Record | null | undefined): return typeof nick === 'string' ? nick : ''; } +function taskPayloadValue(task: HuyaTaskItem | null | undefined, key: string): string { + const payload = resultObject(task?.result, 'payload'); + const value = payload?.[key]; + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return ''; +} + +function taskProductText(task: HuyaTaskItem | null | undefined): string { + const result = task?.result; + return ( + resultText(result, 'product_name') + || taskPayloadValue(task, 'product_name') + || resultText(result, 'product_id') + || taskPayloadValue(task, 'product_id') + || resultText(result, 'spu_id') + || taskPayloadValue(task, 'spu_id') + ); +} + +function isInteractiveTarget(target: EventTarget | null): boolean { + const element = target instanceof HTMLElement ? target : null; + if (!element) return false; + return Boolean(element.closest([ + 'a', + 'button', + 'input', + 'textarea', + 'select', + '[role="button"]', + '[role="menuitem"]', + '.ant-btn', + '.ant-checkbox', + '.ant-checkbox-wrapper', + '.ant-dropdown', + '.ant-dropdown-menu', + '.ant-input', + '.ant-picker', + '.ant-select', + '.ant-table-column-sorter', + ].join(','))); +} + +function contextMenuPosition(x: number, y: number) { + if (typeof window === 'undefined') return { left: x, top: y }; + return { + left: Math.max(8, Math.min(x, window.innerWidth - 220)), + top: Math.max(8, Math.min(y, window.innerHeight - 390)), + }; +} + function goodsRawString(item: HuyaGoodsItem, key: string): string { const value = item.raw?.[key]; if (typeof value === 'string') return value; @@ -154,8 +246,12 @@ export default function HuyaTasksPage() { const [goods, setGoods] = useState([]); const [rechargeGoods, setRechargeGoods] = useState([]); const [taskTypes, setTaskTypes] = useState>(FALLBACK_TASK_TYPES); + const [tags, setTags] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [selectedTaskType, setSelectedTaskType] = useState('query_points'); + const [searchText, setSearchText] = useState(''); + const [tagFilter, setTagFilter] = useState(''); + const [accountContextMenu, setAccountContextMenu] = useState<{ account: HuyaAccountItem; accountIds: number[]; x: number; y: number } | null>(null); const [selectedGoodsCategory, setSelectedGoodsCategory] = useState(''); const [selectedExchangeGoodsId, setSelectedExchangeGoodsId] = useState(''); const [exchangeAt, setExchangeAt] = useState(null); @@ -213,13 +309,14 @@ export default function HuyaTasksPage() { const loadAll = useCallback(async () => { setLoading(true); try { - const [accountResult, taskResult, goodsResult, rechargeGoodsResult, configResult, taskTypeResult] = await Promise.allSettled([ + const [accountResult, taskResult, goodsResult, rechargeGoodsResult, configResult, taskTypeResult, tagResult] = await Promise.allSettled([ huyaApi.listAccounts(), huyaApi.listTasks(), huyaApi.listGoods(), huyaApi.listRechargeGoods(), canConfig ? huyaApi.getConfig() : Promise.resolve(null), huyaApi.taskTypes(), + huyaApi.listTags(), ]); if (accountResult.status === 'fulfilled') setAccounts(accountResult.value); @@ -232,6 +329,7 @@ export default function HuyaTasksPage() { if (rechargeGoodsResult.status === 'fulfilled') setRechargeGoods(rechargeGoodsResult.value); if (configResult.status === 'fulfilled' && configResult.value) form.setFieldsValue(configResult.value); if (taskTypeResult.status === 'fulfilled') setTaskTypes({ ...FALLBACK_TASK_TYPES, ...taskTypeResult.value }); + if (tagResult.status === 'fulfilled') setTags(tagResult.value); const failedLabels = [ accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '', @@ -240,6 +338,7 @@ export default function HuyaTasksPage() { rechargeGoodsResult.status === 'rejected' ? `充值商品: ${getErrorMessage(rechargeGoodsResult.reason)}` : '', configResult.status === 'rejected' ? `虎牙配置: ${getErrorMessage(configResult.reason)}` : '', taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '', + tagResult.status === 'rejected' ? `标签: ${getErrorMessage(tagResult.reason)}` : '', ].filter(Boolean); if (failedLabels.length > 0) { message.warning(`部分数据加载失败:${failedLabels.join(';')}`); @@ -300,9 +399,86 @@ export default function HuyaTasksPage() { if (latest !== payTask) setPayTask(latest); }, [payTask, tasks]); - const accountOptions = useMemo(() => { - return accounts.map((account) => ({ value: account.id, label: accountLabel(account) })); - }, [accounts]); + useEffect(() => { + if (!accountContextMenu) return; + const close = () => setAccountContextMenu(null); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + window.addEventListener('click', close); + window.addEventListener('scroll', close, true); + window.addEventListener('resize', close); + window.addEventListener('keydown', closeOnEscape); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('scroll', close, true); + window.removeEventListener('resize', close); + window.removeEventListener('keydown', closeOnEscape); + }; + }, [accountContextMenu]); + + const toggleAccountSelection = useCallback((accountId: number) => { + setSelectedIds((prev) => ( + prev.includes(accountId) + ? prev.filter((id) => id !== accountId) + : [...prev, accountId] + )); + }, []); + + 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 keyword = searchText.trim().toLowerCase(); + return accounts.filter((account) => { + if (tagFilter && account.tag !== tagFilter) return false; + if (!keyword) return true; + return ( + account.uid.toLowerCase().includes(keyword) + || account.yyuid.toLowerCase().includes(keyword) + || account.username.toLowerCase().includes(keyword) + || account.nickname.toLowerCase().includes(keyword) + || account.tag.toLowerCase().includes(keyword) + || account.game_name.toLowerCase().includes(keyword) + || account.game_channel.toLowerCase().includes(keyword) + || account.game_phone.toLowerCase().includes(keyword) + || account.cookie_preview.toLowerCase().includes(keyword) + ); + }); + }, [accounts, searchText, tagFilter]); + + const latestTaskByAccount = useMemo(() => { + const map = new Map(); + tasks.forEach((task) => { + const current = map.get(task.account_id); + if (!current || task.id > current.id) map.set(task.account_id, task); + }); + return map; + }, [tasks]); + + const latestGoodsTaskByAccount = useMemo(() => { + const map = new Map(); + tasks.forEach((task) => { + if (!['exchange_goods', 'create_recharge_order'].includes(task.task_type)) return; + const current = map.get(task.account_id); + if (!current || task.id > current.id) map.set(task.account_id, task); + }); + return map; + }, [tasks]); + + const selectedAccounts = useMemo(() => { + const selected = new Set(selectedIds); + return accounts.filter((account) => selected.has(account.id)); + }, [accounts, selectedIds]); + + const selectedAccountText = selectedAccounts.length === 1 + ? accountLabel(selectedAccounts[0]) + : `已选 ${selectedIds.length} 个账号`; const sortedGoods = useMemo(() => { return [...goods].sort((a, b) => ( @@ -414,8 +590,8 @@ export default function HuyaTasksPage() { }; }; - const startTask = async (taskType = selectedTaskType) => { - if (selectedIds.length === 0) { + const startTask = async (taskType = selectedTaskType, accountIds = selectedIds) => { + if (accountIds.length === 0) { message.warning('请先选择虎牙 CK'); return; } @@ -431,7 +607,7 @@ export default function HuyaTasksPage() { setStarting(true); try { const result = await huyaApi.createTasks({ - account_ids: selectedIds, + account_ids: accountIds, task_type: taskType, concurrency, payload: createPayload(taskType), @@ -439,11 +615,7 @@ export default function HuyaTasksPage() { const finishTask = () => { setBatchId(null); setStarting(false); - if (taskType === 'refresh_goods' || taskType === 'refresh_recharge_goods') { - void loadAll(); - } else { - void loadTasks(); - } + void loadAll(); }; setBatchId(result.batch_id); message.success(`已创建 ${taskTypes[taskType] || taskType},共 ${result.count} 个账号`); @@ -459,6 +631,25 @@ export default function HuyaTasksPage() { } }; + const accountActionItems: MenuProps['items'] = QUICK_ACTIONS.map((item) => ({ + key: item.key, + icon: item.icon, + label: taskTypes[item.key] || item.key, + })); + + const runSingleAccountAction = (taskType: string, account: HuyaAccountItem) => { + setSelectedIds([account.id]); + setAccountContextMenu(null); + void startTask(taskType, [account.id]); + }; + + const runContextAccountAction = (taskType: string) => { + if (!accountContextMenu) return; + setSelectedIds(accountContextMenu.accountIds); + setAccountContextMenu(null); + void startTask(taskType, accountContextMenu.accountIds); + }; + const saveConfig = async () => { setSavingConfig(true); try { @@ -609,6 +800,161 @@ export default function HuyaTasksPage() { return value ? {JSON.stringify(value)} : -; }; + const handleAccountRowClick = (record: HuyaAccountItem, event: ReactMouseEvent) => { + if (isInteractiveTarget(event.target)) return; + setAccountContextMenu(null); + toggleAccountSelection(record.id); + }; + + const handleAccountRowContextMenu = (record: HuyaAccountItem, event: ReactMouseEvent) => { + if (isInteractiveTarget(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + const accountIds = selectedIds.includes(record.id) && selectedIds.length > 0 ? selectedIds : [record.id]; + setSelectedIds(accountIds); + setAccountContextMenu({ + account: record, + accountIds, + x: event.clientX, + y: event.clientY, + }); + }; + + const accountColumns: TableProps['columns'] = [ + { + title: '#', + width: 56, + align: 'center', + render: (_: unknown, record) => record.id, + }, + { + title: '虎牙 CK', + width: 250, + render: (_: unknown, record) => ( + + + {accountDisplayName(record)} + {record.tag ? {record.tag} : null} + + + UID {record.uid || record.yyuid || '-'} + + + {record.cookie_preview || '-'} + + + ), + }, + { + title: '手机', + dataIndex: 'game_phone', + width: 128, + ellipsis: true, + render: (value: string) => value || -, + }, + { + title: '游戏名', + dataIndex: 'game_name', + width: 170, + ellipsis: true, + render: (value: string, record) => value ? ( + + {value} + {record.game_channel ? {record.game_channel} : null} + + ) : 未查, + }, + { + title: '积分', + dataIndex: 'points', + width: 92, + align: 'center', + render: (points: number | null) => points ?? 未查, + sorter: (a, b) => (a.points ?? -1) - (b.points ?? -1), + }, + { + title: '兑换商品', + width: 190, + ellipsis: true, + render: (_: unknown, record) => { + const task = latestGoodsTaskByAccount.get(record.id); + const product = taskProductText(task); + if (!task || !product) return -; + return ( + + {product} + + {taskTypes[task.task_type] || task.task_type} + + + ); + }, + }, + { + title: '数据状态', + width: 150, + render: (_: unknown, record) => { + const latest = latestTaskByAccount.get(record.id); + return ( + + + {ACCOUNT_STATUS_LABELS[record.status] || record.status || '-'} + + {latest ? ( + + {STATUS_LABELS[latest.status] || latest.status} + + ) : null} + + ); + }, + }, + { + title: '最近结果', + width: 260, + ellipsis: true, + render: (_: unknown, record) => { + const latest = latestTaskByAccount.get(record.id); + if (!latest) return 暂无任务; + return ( + + + {taskTypes[latest.task_type] || latest.task_type}:{latest.message || '-'} + + {renderTaskResult(latest.result, latest)} + + ); + }, + }, + { + title: '更新时间', + dataIndex: 'updated_at', + width: 154, + render: (value: string | null) => value ? formatTime(value) : -, + }, + { + title: '操作', + width: 76, + fixed: 'right', + align: 'center', + render: (_: unknown, record) => { + return ( + { + runSingleAccountAction(String(key), record); + }, + }} + trigger={['click']} + > + @@ -805,326 +1159,374 @@ export default function HuyaTasksPage() { -
- 操作台, - children: ( - <> - -
- ({ value, label }))} - style={{ flex: '1 1 220px', minWidth: 180 }} - /> - setConcurrency(value || 1)} - addonBefore="并发" - style={{ width: 130, flexShrink: 0 }} - /> + {accountContextMenu && accountContextMenuPosition ? ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + > +
+ + {accountContextMenu.accountIds.length > 1 + ? `已选 ${accountContextMenu.accountIds.length} 个账号` + : accountDisplayName(accountContextMenu.account)} + + + {accountContextMenu.accountIds.length > 1 ? '右键批量动作' : '右键账号动作'} + +
+ + {QUICK_ACTIONS.map((item) => ( + + ))} + +
+ ) : null} + +
+
+ + + + + )} + > +
+ setSearchText(event.target.value)} + style={{ width: 320 }} + prefix={} + /> + ({ value, label }))} + style={{ width: '100%' }} + /> + +
+ {QUICK_ACTIONS + .filter((item) => !['refresh_goods', 'refresh_recharge_goods', 'exchange_goods', 'create_recharge_order'].includes(item.key)) + .map((item) => ( + -
-
- {QUICK_ACTIONS.map((item) => ( - - - - ))} -
-
-
+ + ))} +
+ + - {taskSummary} - {renderTaskTable(12)} - 兑换} + extra={( + + )} + > + + {goodsCategories.length > 0 ? ( + setSelectedExchangeGoodsId(value || '')} - options={goodsOptions} - style={{ minWidth: 220 }} - filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())} - /> - - - {goodsCategories.length > 0 && ( - ({ - key: item.key, - label: `${item.label} (${item.count})`, - }))} - /> - )} - ({ - onClick: () => setSelectedExchangeGoodsId(record.product_id), - })} - rowClassName={(record) => record.product_id === selectedExchangeGoodsId ? 'ant-table-row-selected' : ''} - /> - - + ) : null} + setSelectedExchangeGoodsId(event.target.value.trim())} + placeholder="商品 PID" + /> + + + + - - 充值商品} - extra={( - - - - - )} - style={{ marginBottom: 12 }} - > - - - -
({ - onClick: () => setSelectedRechargeGoodsId(record.spu_id), - })} - rowClassName={(record) => record.spu_id === selectedRechargeGoodsId ? 'ant-table-row-selected' : ''} + 充值支付} + extra={( + + )} + > + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - -