diff --git a/web/frontend/src/index.css b/web/frontend/src/index.css index b5bdcd8..65cf442 100644 --- a/web/frontend/src/index.css +++ b/web/frontend/src/index.css @@ -12,3 +12,11 @@ body { html[data-theme='dark'] body { color-scheme: dark; } + +.huya-task-account-table .ant-table-placeholder > .ant-table-cell { + border-bottom: 0; +} + +.huya-task-account-table .ant-table-placeholder:hover > .ant-table-cell { + background: transparent; +} diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 63200e6..a27886a 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -7,7 +7,7 @@ import type { Dayjs } from 'dayjs'; import type { MouseEvent as ReactMouseEvent } from 'react'; import { AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, - LinkOutlined, MoreOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, + ImportOutlined, LinkOutlined, MoreOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, } from '@ant-design/icons'; import { huyaApi, @@ -205,12 +205,6 @@ function goodsCategoryLabel(item: HuyaGoodsItem): string { return goodsRawString(item, 'category_name') || goodsRawString(item, 'category_id') || '未分类'; } -function formatRemainText(value: string): string { - const text = String(value || '').trim(); - if (!text || text.endsWith('%')) return text; - return /^-?\d+(\.\d+)?$/.test(text) ? `${text}%` : text; -} - function formatPriceText(value: number | null | undefined): string { if (!value) return ''; return `¥${(value / 100).toFixed(2)}`; @@ -241,6 +235,7 @@ function isPaymentFinished(task: HuyaTaskItem | null | undefined): boolean { export default function HuyaTasksPage() { const { token } = theme.useToken(); const [form] = Form.useForm(); + const [accountPool, setAccountPool] = useState([]); const [accounts, setAccounts] = useState([]); const [tasks, setTasks] = useState([]); const [goods, setGoods] = useState([]); @@ -252,6 +247,10 @@ export default function HuyaTasksPage() { const [searchText, setSearchText] = useState(''); const [tagFilter, setTagFilter] = useState(''); const [accountContextMenu, setAccountContextMenu] = useState<{ account: HuyaAccountItem; accountIds: number[]; x: number; y: number } | null>(null); + const [importOpen, setImportOpen] = useState(false); + const [importSearchText, setImportSearchText] = useState(''); + const [importTagFilter, setImportTagFilter] = useState(''); + const [importSelectedIds, setImportSelectedIds] = useState([]); const [selectedGoodsCategory, setSelectedGoodsCategory] = useState(''); const [selectedExchangeGoodsId, setSelectedExchangeGoodsId] = useState(''); const [exchangeAt, setExchangeAt] = useState(null); @@ -265,6 +264,7 @@ export default function HuyaTasksPage() { const [loading, setLoading] = useState(false); const [starting, setStarting] = useState(false); const [savingConfig, setSavingConfig] = useState(false); + const [taskRecordsVisible, setTaskRecordsVisible] = useState(false); const [batchId, setBatchId] = useState(null); const [qrTask, setQrTask] = useState(null); const [payTask, setPayTask] = useState(null); @@ -274,6 +274,8 @@ export default function HuyaTasksPage() { const autoOpenedPayTaskIds = useRef>(new Set()); const autoOpenPayReady = useRef(false); const notifiedPaidTaskIds = useRef>(new Set()); + const accountTableAreaRef = useRef(null); + const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460); const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs(); const { can } = usePermissions(); @@ -284,6 +286,25 @@ export default function HuyaTasksPage() { localStorage.setItem('huya_task_concurrency', String(concurrency)); }, [concurrency]); + useEffect(() => { + const node = accountTableAreaRef.current; + if (!node) return; + + const updateHeight = () => { + setAccountTableAreaHeight(Math.max(360, Math.floor(node.getBoundingClientRect().height))); + }; + updateHeight(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateHeight); + return () => window.removeEventListener('resize', updateHeight); + } + + const observer = new ResizeObserver(updateHeight); + observer.observe(node); + return () => observer.disconnect(); + }, []); + const rememberExistingQrcodes = useCallback((items: HuyaTaskItem[]) => { if (autoOpenQrReady.current) return; items.filter(hasMiniQrcode).forEach((task) => autoOpenedQrTaskIds.current.add(task.id)); @@ -319,7 +340,17 @@ export default function HuyaTasksPage() { huyaApi.listTags(), ]); - if (accountResult.status === 'fulfilled') setAccounts(accountResult.value); + if (accountResult.status === 'fulfilled') { + const nextPool = accountResult.value; + const nextPoolById = new Map(nextPool.map((account) => [account.id, account])); + setAccountPool(nextPool); + setAccounts((prev) => ( + prev + .map((account) => nextPoolById.get(account.id)) + .filter((account): account is HuyaAccountItem => Boolean(account)) + )); + setSelectedIds((prev) => prev.filter((id) => nextPoolById.has(id))); + } if (taskResult.status === 'fulfilled') { rememberExistingQrcodes(taskResult.value); rememberExistingPaymentQrcodes(taskResult.value); @@ -452,6 +483,27 @@ export default function HuyaTasksPage() { }); }, [accounts, searchText, tagFilter]); + const importFilteredAccounts = useMemo(() => { + const importedIds = new Set(accounts.map((account) => account.id)); + const keyword = importSearchText.trim().toLowerCase(); + return accountPool.filter((account) => { + if (importedIds.has(account.id)) return false; + if (importTagFilter && account.tag !== importTagFilter) 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) + ); + }); + }, [accountPool, accounts, importSearchText, importTagFilter]); + const latestTaskByAccount = useMemo(() => { const map = new Map(); tasks.forEach((task) => { @@ -546,11 +598,6 @@ export default function HuyaTasksPage() { } }, [goodsCategories, selectedGoodsCategory]); - const filteredGoods = useMemo(() => { - if (!selectedGoodsCategory) return sortedGoods; - return sortedGoods.filter((item) => goodsCategoryKey(item) === selectedGoodsCategory); - }, [sortedGoods, selectedGoodsCategory]); - const goodsOptions = useMemo(() => { return sortedGoods.map((item) => ({ value: item.product_id, @@ -637,6 +684,47 @@ export default function HuyaTasksPage() { label: taskTypes[item.key] || item.key, })); + const importAccountsToWorkbench = (ids: number[]) => { + const accountById = new Map(accountPool.map((account) => [account.id, account])); + const uniqueIds = Array.from(new Set(ids)).filter((id) => accountById.has(id)); + if (uniqueIds.length === 0) { + message.warning('请先选择要导入的账号'); + return; + } + setAccounts((prev) => { + const prevIds = new Set(prev.map((account) => account.id)); + const nextAccounts = [...prev]; + uniqueIds.forEach((id) => { + if (!prevIds.has(id)) { + const account = accountById.get(id); + if (account) nextAccounts.push(account); + } + }); + return nextAccounts; + }); + setSelectedIds(uniqueIds); + setImportSelectedIds([]); + setImportOpen(false); + message.success(`已导入 ${uniqueIds.length} 个账号到操作台`); + }; + + const removeSelectedAccounts = () => { + if (selectedIds.length === 0) { + message.warning('请先选择要移出的账号'); + return; + } + const selected = new Set(selectedIds); + setAccounts((prev) => prev.filter((account) => !selected.has(account.id))); + setSelectedIds([]); + setAccountContextMenu(null); + }; + + const clearWorkbenchAccounts = () => { + setAccounts([]); + setSelectedIds([]); + setAccountContextMenu(null); + }; + const runSingleAccountAction = (taskType: string, account: HuyaAccountItem) => { setSelectedIds([account.id]); setAccountContextMenu(null); @@ -955,6 +1043,48 @@ export default function HuyaTasksPage() { }, ]; + const importAccountColumns: TableProps['columns'] = [ + { title: 'ID', dataIndex: 'id', width: 76, align: 'center' }, + { + title: '虎牙 CK', + width: 230, + render: (_: unknown, record) => ( + + + {accountDisplayName(record)} + {record.tag ? {record.tag} : null} + + UID {record.uid || record.yyuid || '-'} + + ), + }, + { + title: '手机', + dataIndex: 'game_phone', + width: 130, + ellipsis: true, + render: (value: string) => value || -, + }, + { + title: '游戏名', + dataIndex: 'game_name', + ellipsis: true, + render: (value: string, record) => value ? ( + + {value} + {record.game_channel ? {record.game_channel} : null} + + ) : 未查, + }, + { + title: '积分', + dataIndex: 'points', + width: 86, + align: 'center', + render: (points: number | null) => points ?? 未查, + }, + ]; + const taskColumns: TableProps['columns'] = [ { title: 'ID', dataIndex: 'id', width: 70, align: 'center' }, { @@ -993,88 +1123,6 @@ export default function HuyaTasksPage() { }, ]; - const goodsColumns: TableProps['columns'] = [ - { title: '商品ID', dataIndex: 'product_id', width: 88, ellipsis: true }, - { - title: '名称', - dataIndex: 'name', - width: 230, - render: (value: string) => ( - - {value || '-'} - - ), - }, - { - title: '分类', - width: 96, - render: (_: unknown, record) => { - const label = goodsCategoryLabel(record); - return label ? {label} : -; - }, - }, - { - title: '价格', - dataIndex: 'price', - width: 78, - align: 'center', - render: (value: number | null) => value ?? -, - }, - { - title: '库存', - dataIndex: 'remain_text', - width: 72, - render: (value: string) => formatRemainText(value) || -, - }, - { - title: '更新时间', - dataIndex: 'updated_at', - width: 154, - render: (value: string | null) => value ? formatTime(value) : -, - }, - ]; - - const rechargeGoodsColumns: TableProps['columns'] = [ - { title: 'SPU', dataIndex: 'spu_id', width: 120, ellipsis: true }, - { title: 'SKU', dataIndex: 'sku_id', width: 100, ellipsis: true }, - { title: '名称', dataIndex: 'name', ellipsis: true }, - { - title: '单价', - dataIndex: 'price', - width: 90, - align: 'center', - render: (value: number | null) => formatPriceText(value) || -, - }, - { - title: '库存', - dataIndex: 'stock', - width: 90, - align: 'center', - render: (value: number | null) => value ?? -, - }, - { - title: '来源', - dataIndex: 'task_name', - width: 140, - ellipsis: true, - render: (value: string) => value || 商品详情, - }, - { - title: '更新时间', - dataIndex: 'updated_at', - width: 160, - render: (value: string | null) => value ? formatTime(value) : -, - }, - ]; - const exchangeRecordColumns: TableProps>['columns'] = [ { title: '序号', @@ -1118,15 +1166,6 @@ export default function HuyaTasksPage() { }, ]; - const taskSummary = ( -
- {tasks.length} 个任务 - 已计划 {plannedCount} - 成功 {successCount} - 失败 {failedCount} -
- ); - const renderTaskTable = (pageSize = 12) => (

虎牙任务操作台

- 账号 {accounts.length} + 操作台 {accounts.length} + 账号库 {accountPool.length} 已选 {selectedIds.length} 已查积分 {accounts.filter((item) => item.points !== null && item.points !== undefined).length} 已绑定 {accounts.filter((item) => item.game_name || item.game_channel || item.game_phone).length} @@ -1216,20 +1257,28 @@ export default function HuyaTasksPage() { overflow: 'hidden', }} > -
+
+ - + )} + style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }} + styles={{ body: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } }} >
{filteredAccounts.length} / {accounts.length}
-
setSelectedIds(keys.map((key) => Number(key))), - }} - columns={accountColumns} - dataSource={filteredAccounts} - rowKey="id" - loading={loading} - size="small" - pagination={{ pageSize: 12, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} - scroll={{ x: 1520 }} - rowClassName={(record) => selectedIds.includes(record.id) ? 'ant-table-row-selected' : ''} - onRow={(record) => ({ - onClick: (event) => handleAccountRowClick(record, event), - onContextMenu: (event) => handleAccountRowContextMenu(record, event), - style: { cursor: 'pointer' }, - title: '点击选中,右键打开账号动作', - })} - /> +
+
setSelectedIds(keys.map((key) => Number(key))), + }} + columns={accountColumns} + dataSource={filteredAccounts} + rowKey="id" + loading={loading} + size="small" + className="huya-task-account-table" + pagination={false} + scroll={filteredAccounts.length > 0 ? { x: 1520, y: accountTableBodyHeight } : undefined} + locale={{ + emptyText: ( +
+ 操作台暂无账号,请点击右上角导入账号 +
+ ), + }} + rowClassName={(record) => selectedIds.includes(record.id) ? 'ant-table-row-selected' : ''} + onRow={(record) => ({ + onClick: (event) => handleAccountRowClick(record, event), + onContextMenu: (event) => handleAccountRowContextMenu(record, event), + style: { cursor: 'pointer' }, + title: '点击选中,右键打开账号动作', + })} + /> + - - - 兑换商品}> -
({ - onClick: () => setSelectedExchangeGoodsId(record.product_id), - })} - rowClassName={(record) => record.product_id === selectedExchangeGoodsId ? 'ant-table-row-selected' : ''} - /> - - - - 充值商品}> -
({ - onClick: () => setSelectedRechargeGoodsId(record.spu_id), - })} - rowClassName={(record) => record.spu_id === selectedRechargeGoodsId ? 'ant-table-row-selected' : ''} - /> - - - - - 任务记录} style={{ marginTop: 12 }}> - {taskSummary} - {renderTaskTable(8)} - + 任务记录} + extra={( + + 共 {tasks.length} + 已计划 {plannedCount} + 成功 {successCount} + 失败 {failedCount} + + + )} + style={{ marginTop: 12 }} + styles={{ body: { padding: taskRecordsVisible ? 12 : 0 } }} + > + {taskRecordsVisible ? renderTaskTable(5) : null} +
@@ -1529,6 +1576,88 @@ export default function HuyaTasksPage() {
+ { + setImportOpen(false); + setImportSelectedIds([]); + }} + footer={[ + , + , + , + ]} + width={860} + > + +
+ setImportSearchText(event.target.value)} + style={{ width: 330 }} + prefix={} + /> +
setImportSelectedIds(keys.map((key) => Number(key))), + }} + columns={importAccountColumns} + dataSource={importFilteredAccounts} + rowKey="id" + size="small" + pagination={{ pageSize: 8, showSizeChanger: false, showTotal: (total) => `共 ${total} 条` }} + scroll={{ x: 720, y: 360 }} + locale={{ emptyText: '没有可导入账号' }} + rowClassName={(record) => importSelectedIds.includes(record.id) ? 'ant-table-row-selected' : ''} + onRow={(record) => ({ + onClick: (event) => { + if (isInteractiveTarget(event.target)) return; + setImportSelectedIds((prev) => ( + prev.includes(record.id) + ? prev.filter((id) => id !== record.id) + : [...prev, record.id] + )); + }, + style: { cursor: 'pointer' }, + })} + /> + + +