feat(douyu): 精英宝典工作台支持上下/左右布局切换并记忆偏好

This commit is contained in:
yml2213
2026-08-02 13:50:17 +08:00
parent 1b06694c66
commit 555f179b41
+274 -226
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Typography,
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, GiftOutlined,
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, GiftOutlined,
ImportOutlined, QrcodeOutlined, ReloadOutlined,
SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
@@ -116,6 +116,7 @@ const ESPORTS_TASK_TYPES = new Set([
]);
const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode';
const CONFIRM_FAIL_TIP_SECONDS = 6;
function resultText(result: Record<string, unknown> | null | undefined, key: string): string {
@@ -284,6 +285,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const [giftCount, setGiftCount] = useState(() => savedPositiveInteger(DOUYU_GIFT_COUNT_STORAGE_KEY));
const [selectedGoodsId, setSelectedGoodsId] = useState('');
const [accountSearch, setAccountSearch] = useState('');
const [layoutMode, setLayoutMode] = useState<'stack' | 'split'>(() => {
const v = localStorage.getItem(DOUYU_LAYOUT_MODE_STORAGE_KEY);
return v === 'split' ? 'split' : 'stack';
});
const accountTableAreaRef = useRef<HTMLDivElement | null>(null);
const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(360);
const [accountPageSize, setAccountPageSize] = useState<number>(() => {
@@ -459,6 +464,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => {
localStorage.setItem(DOUYU_LAYOUT_MODE_STORAGE_KEY, layoutMode);
}, [layoutMode]);
// 监听账号表格区域高度变化,动态计算 scroll.y 实现表体内部滚动
useEffect(() => {
const node = accountTableAreaRef.current;
@@ -472,7 +481,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
}, [layoutMode]);
useEffect(() => {
localStorage.setItem(DOUYU_GOLD_AMOUNT_STORAGE_KEY, String(goldAmount));
@@ -1236,6 +1245,222 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: 8,
};
const operationExtra = (
<Space>
<Tag color={selectedIds.length ? 'blue' : 'default'}>{selectedText}</Tag>
<Space.Compact>
<Button size="small" disabled></Button>
<InputNumber
size="small"
min={1}
max={10}
value={concurrency}
onChange={(v) => setConcurrency(v || 1)}
style={{ width: 70 }}
/>
</Space.Compact>
</Space>
);
const operationBody = (
<>
{isEsportsHandbook ? (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
{renderActionButton('create_esports_qr', 'primary')}
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<ShoppingOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Select
size="small"
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) }))}
placeholder="选择电竞皮肤"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_esports_goods')}
{renderActionButton('exchange_esports_goods', 'primary')}
</div>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<CreditCardOutlined />}
onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')}
>
</Button>
</Space.Compact>
<InputNumber
size="small"
min={1}
value={giftCount}
onChange={(v) => setGiftCount(v || 1)}
addonAfter="赠送数量"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('donate_esports_chicken_gift', 'primary')}
{renderActionButton('donate_esports_firework_gift', 'primary')}
</div>
</Space>
</div>
</div>
) : (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<ShoppingOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Select
size="small"
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) }))}
placeholder="选择兑换商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_goods')}
{renderActionButton('exchange_goods', 'primary')}
</div>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('create_elite_qr', 'primary')}
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<CreditCardOutlined />}
onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')}
>
</Button>
</Space.Compact>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={giftCount}
onChange={(v) => setGiftCount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<GiftOutlined />}
onClick={() => startTask('donate_elite_gift')}
disabled={actionDisabled('donate_elite_gift')}
>
</Button>
</Space.Compact>
</Space>
</div>
</div>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
</>
);
const accountsBody = (
<>
<Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between', flexShrink: 0 }} wrap>
<Space>
<Input
size="small" placeholder="搜索账号/昵称/游戏名" prefix={<SearchOutlined />}
value={accountSearch} onChange={(e) => setAccountSearch(e.target.value)}
style={{ width: 200 }} allowClear
/>
</Space>
<Space>
<Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}></Button>
{selectedIds.length > 0 && (
<Button size="small" danger onClick={removeSelected}></Button>
)}
</Space>
</Space>
<div ref={accountTableAreaRef} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Table
rowKey="id"
size="small"
className="douyu-task-record-table"
loading={loading}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map(Number)),
}}
columns={accountColumns}
dataSource={filteredAccounts}
pagination={{
pageSize: accountPageSize,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
size: 'small',
showLessItems: true,
showTotal: (total) => `${total}`,
onChange: (_page, size) => {
if (size !== accountPageSize) {
setAccountPageSize(size);
localStorage.setItem('douyu_task_account_page_size', String(size));
}
},
}}
tableLayout="fixed"
scroll={{ x: 1180, y: Math.max(120, accountTableAreaHeight - 80) }}
onRow={(record) => ({
onContextMenu: (e) => handleRowContextMenu(record, e),
style: { cursor: 'context-menu' },
title: '右键打开账号动作',
})}
/>
</div>
</>
);
// 任务记录表:表头固定、表体内部滚动、分页器固定显示在底部
const taskRecordTableStyle = `
.douyu-task-record-table {
@@ -1304,237 +1529,60 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<Text type="secondary">{handbookDescription}</Text>
</div>
<Space>
<Tooltip title={layoutMode === 'split' ? '切换为上下布局' : '切换为左右布局'}>
<Button
icon={<ColumnWidthOutlined />}
onClick={() => setLayoutMode((m) => (m === 'split' ? 'stack' : 'split'))}
/>
</Tooltip>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}></Button>
{canConfig && <Button icon={<SettingOutlined />} onClick={() => setConfigOpen(true)}></Button>}
{(activeBatchIds.size > 0 || runningBatchIds.size > 0) && <Button danger icon={<StopOutlined />} onClick={stopBatch}></Button>}
</Space>
</Space>
{/* 操作工具栏 */}
<Card
size="small"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'}
extra={
<Space>
<Tag color={selectedIds.length ? 'blue' : 'default'}>{selectedText}</Tag>
<Space.Compact>
<Button size="small" disabled></Button>
<InputNumber
size="small"
min={1}
max={10}
value={concurrency}
onChange={(v) => setConcurrency(v || 1)}
style={{ width: 70 }}
/>
</Space.Compact>
</Space>
}
style={{ flexShrink: 0, marginBottom: 12 }}
styles={{ body: { padding: 8 } }}
>
{isEsportsHandbook ? (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
{renderActionButton('create_esports_qr', 'primary')}
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<ShoppingOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Select
size="small"
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) }))}
placeholder="选择电竞皮肤"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_esports_goods')}
{renderActionButton('exchange_esports_goods', 'primary')}
</div>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<CreditCardOutlined />}
onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')}
>
</Button>
</Space.Compact>
<InputNumber
size="small"
min={1}
value={giftCount}
onChange={(v) => setGiftCount(v || 1)}
addonAfter="赠送数量"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('donate_esports_chicken_gift', 'primary')}
{renderActionButton('donate_esports_firework_gift', 'primary')}
</div>
</Space>
</div>
</div>
) : (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<ShoppingOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Select
size="small"
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) }))}
placeholder="选择兑换商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_goods')}
{renderActionButton('exchange_goods', 'primary')}
</div>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('create_elite_qr', 'primary')}
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<CreditCardOutlined />}
onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')}
>
</Button>
</Space.Compact>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={giftCount}
onChange={(v) => setGiftCount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<GiftOutlined />}
onClick={() => startTask('donate_elite_gift')}
disabled={actionDisabled('donate_elite_gift')}
>
</Button>
</Space.Compact>
</Space>
</div>
</div>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
</Card>
{/* 账号 */}
<Card
size="small" title="账号"
extra={<Tag color="blue"> {selectedIds.length}/{accounts.length}</Tag>}
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
>
<Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between', flexShrink: 0 }} wrap>
<Space>
<Input
size="small" placeholder="搜索账号/昵称/游戏名" prefix={<SearchOutlined />}
value={accountSearch} onChange={(e) => setAccountSearch(e.target.value)}
style={{ width: 200 }} allowClear
/>
</Space>
<Space>
<Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}></Button>
{selectedIds.length > 0 && (
<Button size="small" danger onClick={removeSelected}></Button>
)}
</Space>
</Space>
<div ref={accountTableAreaRef} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Table
rowKey="id"
{/* 操作工具栏 + 账号表格(支持上下/左右布局切换) */}
{layoutMode === 'split' ? (
<div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}>
<Card
size="small" title="账号"
extra={<Tag color="blue"> {selectedIds.length}/{accounts.length}</Tag>}
style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
>
{accountsBody}
</Card>
<Card
size="small"
className="douyu-task-record-table"
loading={loading}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map(Number)),
}}
columns={accountColumns}
dataSource={filteredAccounts}
pagination={{
pageSize: accountPageSize,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
size: 'small',
showLessItems: true,
showTotal: (total) => `${total}`,
onChange: (_page, size) => {
if (size !== accountPageSize) {
setAccountPageSize(size);
localStorage.setItem('douyu_task_account_page_size', String(size));
}
},
}}
tableLayout="fixed"
scroll={{ x: 1180, y: Math.max(120, accountTableAreaHeight - 80) }}
onRow={(record) => ({
onContextMenu: (e) => handleRowContextMenu(record, e),
style: { cursor: 'context-menu' },
title: '右键打开账号动作',
})}
/>
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'}
extra={operationExtra}
style={{ width: 340, flexShrink: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 8, flex: 1, minHeight: 0, overflowY: 'auto' } }}
>
{operationBody}
</Card>
</div>
</Card>
) : (
<>
<Card
size="small"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'}
extra={operationExtra}
style={{ flexShrink: 0, marginBottom: 12 }}
styles={{ body: { padding: 8 } }}
>
{operationBody}
</Card>
<Card
size="small" title="账号"
extra={<Tag color="blue"> {selectedIds.length}/{accounts.length}</Tag>}
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
>
{accountsBody}
</Card>
</>
)}
{/* 右键菜单 */}
{contextMenu && (