feat(huya): 统一积分兑换并完善兑换记录
This commit is contained in:
@@ -21,7 +21,6 @@ import { formatTime, formatUnixSeconds } from '../utils/time';
|
||||
const { Text } = Typography;
|
||||
const SCOPE = 'elite' as const;
|
||||
const ACT_ID = 25135;
|
||||
const DEFAULT_SID = '2203';
|
||||
const LAYOUT_KEY = 'huya_elite_layout_mode';
|
||||
const PAGE_SIZE_KEY = 'huya_elite_page_size';
|
||||
// 开通/充值:记住上次选的商品与数量(浏览器本地,换台机器不影响)
|
||||
@@ -130,8 +129,9 @@ function needsScanPay(task: HuyaTaskItem): boolean {
|
||||
* 客户还没扫,必须继续展示。
|
||||
*/
|
||||
function qrDoneLabel(task: HuyaTaskItem): string | null {
|
||||
if (task.task_type !== 'create_recharge_order') return null;
|
||||
if (!['create_recharge_order', 'exchange_goods'].includes(task.task_type)) return null;
|
||||
if (needsScanPay(task)) return null;
|
||||
if (task.task_type === 'exchange_goods') return '已兑换';
|
||||
if (task.result?.elite_opening === true) return '开通中';
|
||||
return task.result?.elite_opened === true ? '已开通' : '已支付';
|
||||
}
|
||||
@@ -173,9 +173,18 @@ function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | un
|
||||
return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
function taskResultRecords(task: HuyaTaskItem | null | undefined): Record<string, unknown>[] {
|
||||
const records = task?.result?.records;
|
||||
return Array.isArray(records)
|
||||
? records.filter((item): item is Record<string, unknown> => Boolean(item && typeof item === 'object'))
|
||||
: [];
|
||||
}
|
||||
|
||||
function taskQrUrl(task: HuyaTaskItem | undefined): string {
|
||||
if (!task) return '';
|
||||
if (task.task_type === 'create_recharge_order') return payQrValue(task);
|
||||
if (['create_recharge_order', 'exchange_goods'].includes(task.task_type)) {
|
||||
return payQrValue(task);
|
||||
}
|
||||
return resultText(task, 'bind_redirect_url') || resultText(task, 'url');
|
||||
}
|
||||
|
||||
@@ -213,6 +222,7 @@ export default function HuyaElitePage() {
|
||||
const [importTags, setImportTags] = useState<string[]>([]);
|
||||
const [importSelected, setImportSelected] = useState<number[]>([]);
|
||||
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
|
||||
const [recordsTask, setRecordsTask] = useState<HuyaTaskItem | null>(null);
|
||||
const [qrTaskLoading, setQrTaskLoading] = useState(false);
|
||||
// 小程序码图片独立保存:任务轮询只会更新轻量 tasks,不允许覆盖这张大图。
|
||||
const [qrImage, setQrImage] = useState('');
|
||||
@@ -358,7 +368,11 @@ export default function HuyaElitePage() {
|
||||
setStarting(true);
|
||||
try {
|
||||
const payload = taskType === 'exchange_goods'
|
||||
? { sid: Number(config?.sid || DEFAULT_SID), product_id: Number(selectedGoodsId), act_id: ACT_ID }
|
||||
? {
|
||||
product_id: selectedGoodsId,
|
||||
product_name: goods.find((item) => item.product_id === selectedGoodsId)?.name || '',
|
||||
pay_channel: config?.pay_channel || '',
|
||||
}
|
||||
: taskType === 'query_act_tasks' ? { act_id: ACT_ID }
|
||||
: taskType === 'create_recharge_order' ? (() => {
|
||||
// 精英宝典那块用 override 传自己的 spu(限购 1 份);其他商品用下拉选的 spu + 数量
|
||||
@@ -384,8 +398,15 @@ export default function HuyaElitePage() {
|
||||
const next = raw as unknown as HuyaTaskItem;
|
||||
setTasks((prev) => [next, ...prev.filter((item) => item.id !== next.id)].slice(0, 300));
|
||||
const result = (next.result || {}) as Record<string, unknown>;
|
||||
// 支付弹窗只在「生成开通支付码」和点账号行时打开:多选时每个码都会回包,
|
||||
// 这里再自动弹会把用户刚关掉的弹窗反复顶出来(码会在弹窗里自动补齐)。
|
||||
// 积分兑换只有积分不足时才会生成支付码;直兑任务不弹空支付窗。
|
||||
if (
|
||||
next.task_type === 'exchange_goods'
|
||||
&& (result.pay_url || result.pay_qr_code || result.mini_qrcode_image)
|
||||
) {
|
||||
setPayBatchIds((prev) => [...new Set([...prev, created.batch_id])]);
|
||||
setPayOpen(true);
|
||||
}
|
||||
// 开通支付码只在批次创建时打开弹窗;后续回包不能反复把用户关掉的弹窗顶出来。
|
||||
if (next.task_type === 'get_bind_qr') {
|
||||
if (result.mini_qrcode_image) {
|
||||
const directImage = String(result.mini_qrcode_image);
|
||||
@@ -509,7 +530,7 @@ export default function HuyaElitePage() {
|
||||
const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length;
|
||||
// 开通支付码:同一批次里每个账号一个码,未支付的排前面;已支付/已开通的只留一行状态
|
||||
const payTasks = useMemo(() => {
|
||||
const list = tasks.filter((task) => task.task_type === 'create_recharge_order'
|
||||
const list = tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type)
|
||||
&& payBatchIds.includes(task.batch_id)
|
||||
&& Boolean(resultText(task, 'pay_url') || resultText(task, 'mini_qrcode_image')));
|
||||
return [...list].sort((a, b) => Number(needsScanPay(b)) - Number(needsScanPay(a)) || a.id - b.id);
|
||||
@@ -517,11 +538,11 @@ export default function HuyaElitePage() {
|
||||
const payPendingTasks = payTasks.filter(needsScanPay);
|
||||
const payDoneTasks = payTasks.filter((task) => !needsScanPay(task));
|
||||
// 多选账号时一次生成多个码:个别账号失败(限购、CK 失效…)要单独点名,否则少一个码看不出原因
|
||||
const payFailedTasks = useMemo(() => tasks.filter((task) => task.task_type === 'create_recharge_order'
|
||||
const payFailedTasks = useMemo(() => tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type)
|
||||
&& payBatchIds.includes(task.batch_id)
|
||||
&& ['failed', 'error', 'stopped'].includes(task.status)), [tasks, payBatchIds]);
|
||||
// 还在排队/生成中的码(并发不够时,后面的码要等前面的码付完或超时才轮到)
|
||||
const payQueuedCount = useMemo(() => tasks.filter((task) => task.task_type === 'create_recharge_order'
|
||||
const payQueuedCount = useMemo(() => tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type)
|
||||
&& payBatchIds.includes(task.batch_id)
|
||||
&& ['planned', 'pending', 'running'].includes(task.status)
|
||||
&& !resultText(task, 'pay_url')).length, [tasks, payBatchIds]);
|
||||
@@ -537,6 +558,52 @@ export default function HuyaElitePage() {
|
||||
catch { message.error('复制失败,请手动选中复制'); }
|
||||
};
|
||||
|
||||
const viewedRecords = taskResultRecords(recordsTask);
|
||||
const exchangeRecordColumns: TableProps<Record<string, unknown>>['columns'] = [
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'prize_name',
|
||||
width: 210,
|
||||
render: (value: unknown) => String(value || '-'),
|
||||
},
|
||||
{
|
||||
title: '积分',
|
||||
dataIndex: 'score',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (value: unknown) => Number(value || 0).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '兑换时间',
|
||||
dataIndex: 'exchange_time',
|
||||
width: 180,
|
||||
render: (value: unknown, record) => {
|
||||
const text = record.exchange_time_text;
|
||||
if (typeof text === 'string' && text) return text;
|
||||
const timestamp = Number(value || 0);
|
||||
return timestamp > 0 ? formatUnixSeconds(timestamp) : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '发放状态',
|
||||
dataIndex: 'status_label',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (value: unknown, record) => {
|
||||
const label = String(value || record.prize_status || '-');
|
||||
return <Tag color={label === '已发放' ? 'success' : 'default'}>{label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_id',
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (value: unknown) =>
|
||||
value ? <Text copyable={{ text: String(value) }}>{String(value)}</Text> : '-',
|
||||
},
|
||||
];
|
||||
|
||||
// 列宽按比例分配(percent),表头与单元格统一居中,避免左右跳着看
|
||||
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
|
||||
{ title: '#', width: '4%', align: 'center', render: (_value, _row, index) => <Text type="secondary">{index + 1}</Text> },
|
||||
@@ -545,8 +612,8 @@ export default function HuyaElitePage() {
|
||||
{ title: '游戏名', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const expired = ckExpired(latestTask(tasks, row.id), row); return <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Text ellipsis title={row.game_name || row.game_channel}>{row.game_name || <Text type={expired ? 'danger' : 'secondary'}>{expired ? '角色未查到' : '未查询'}</Text>}</Text>{row.game_channel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.game_channel}</Text>}</Space>; } },
|
||||
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const opening = task?.result?.elite_opening === true; return <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={opening ? 'processing' : row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{opening ? '开通中' : row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space>; } },
|
||||
{ title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
|
||||
{ title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); return task ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
|
||||
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
|
||||
{ title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); const records = taskResultRecords(task); return task ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text>{task.task_type === 'query_exchange_records' && records.length > 0 && <Button size="small" type="link" onClick={(event) => { event.stopPropagation(); setRecordsTask(task); }}>查看记录</Button>}</Space> : <Text type="secondary">暂无操作</Text>; } },
|
||||
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (['create_recharge_order', 'exchange_goods'].includes(task.task_type)) setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
|
||||
{ title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (ckExpired(task, row)) return <Tooltip title={`${resultText(task, 'ck_expired_reason') || '角色信息查不到'} —— 请重新登录或重新导入 Cookie`}><Tag color="error">CK 失效</Tag></Tooltip>; const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
|
||||
];
|
||||
|
||||
@@ -562,7 +629,7 @@ export default function HuyaElitePage() {
|
||||
</Space>
|
||||
);
|
||||
|
||||
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}{canTransferCollab && selectedIds.length === 1 && <Button size="small" icon={<SwapOutlined />} loading={transferring} onClick={() => void transferToCollab()}>转入外部单协作</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
|
||||
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}{canTransferCollab && selectedIds.length === 1 && <Button size="small" icon={<SwapOutlined />} loading={transferring} onClick={() => void transferToCollab()}>转入外部单协作</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'query_exchange_records' && taskResultRecords(task).length > 0) setRecordsTask(task); else if (['create_recharge_order', 'exchange_goods'].includes(task.task_type)) openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
|
||||
|
||||
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
|
||||
const contextMenuStyle = contextMenu ? {
|
||||
@@ -580,9 +647,37 @@ export default function HuyaElitePage() {
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 6 }}>兑换任务 {exchangeCount} 条;勾选账号后可批量执行,账号行支持右键操作。</Text>
|
||||
{contextMenu && <div style={contextMenuStyle} onClick={(event) => event.stopPropagation()}><Card size="small" title={contextMenu.accountIds.length > 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.14)' }}><Space direction="vertical" style={{ width: '100%' }}>{HUYA_QUICK_ACTIONS.map((item) => <Button key={item.key} block size="small" type="text" icon={item.icon} onClick={() => runContextAction(item.key)} disabled={starting} style={{ justifyContent: 'flex-start' }}>{TASK_LABELS[item.key] || item.key}</Button>)}</Space></Card></div>}
|
||||
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => { setQrTask(null); setQrImage(''); }} centered>{qrTask && <div style={{ textAlign: 'center' }}>{qrImage ? <img src={miniQrcodeSrc(qrImage)} alt="绑定二维码" style={{ width: 240, height: 240, background: '#fff', padding: 12, boxSizing: 'border-box', borderRadius: 8 }} /> : qrTask.result?.has_mini_qrcode === true ? <div style={{ width: 240, height: 240, display: 'grid', placeItems: 'center', background: '#fff', borderRadius: 8 }}>{qrTaskLoading ? <Spin size="large" /> : <Text type="secondary">正在获取小程序码…</Text>}</div> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
|
||||
<Modal
|
||||
open={recordsTask !== null}
|
||||
title={`兑换记录 · ${recordsTask?.account_nickname || recordsTask?.account_uid || ''}`}
|
||||
footer={null}
|
||||
onCancel={() => setRecordsTask(null)}
|
||||
width={980}
|
||||
centered
|
||||
>
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Tag color="blue">共 {viewedRecords.length} 条</Tag>
|
||||
<Tag>活动 SID {String(recordsTask?.result?.sid || '-')}</Tag>
|
||||
{recordsTask?.message ? <Text type="secondary">{recordsTask.message}</Text> : null}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey={(record, index) => String(record.record_id || record.order_id || index)}
|
||||
size="small"
|
||||
columns={exchangeRecordColumns}
|
||||
dataSource={viewedRecords}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
scroll={{ x: 820 }}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={payOpen}
|
||||
title={payTasks.length ? `开通支付码(${payPendingTasks.length} 个待扫码 · 已完成 ${payDoneTasks.length}/${payTasks.length})` : '开通支付码'}
|
||||
title={payTasks.length ? `支付码(${payPendingTasks.length} 个待扫码 · 已完成 ${payDoneTasks.length}/${payTasks.length})` : '支付码'}
|
||||
width={920}
|
||||
centered
|
||||
onCancel={() => setPayOpen(false)}
|
||||
|
||||
@@ -257,7 +257,8 @@ function bindReadyForConfirm(task: HuyaTaskItem | null | undefined): boolean {
|
||||
}
|
||||
|
||||
function hasPaymentQrcode(task: HuyaTaskItem): boolean {
|
||||
return task.task_type === 'create_recharge_order' && Boolean(resultText(task.result, 'pay_url'));
|
||||
return ['create_recharge_order', 'exchange_goods'].includes(task.task_type)
|
||||
&& Boolean(resultText(task.result, 'pay_url'));
|
||||
}
|
||||
|
||||
function paymentStatus(task: HuyaTaskItem | null | undefined): string {
|
||||
@@ -983,7 +984,7 @@ export default function HuyaTasksPage() {
|
||||
? resultProfileNick(qrResult) || qrTask.account_nickname || qrTask.account_uid || `#${qrTask.account_id}`
|
||||
: '';
|
||||
const payResult = payTask?.result || null;
|
||||
const payUrl = resultText(payResult, 'pay_url');
|
||||
const payUrl = resultText(payResult, 'pay_qr_code') || resultText(payResult, 'pay_cashier_url') || resultText(payResult, 'pay_url');
|
||||
const payProductName = resultText(payResult, 'product_name');
|
||||
const payAmountText = resultText(payResult, 'amount_text');
|
||||
const payChannelLabel = resultText(payResult, 'pay_channel_label');
|
||||
@@ -1099,7 +1100,20 @@ export default function HuyaTasksPage() {
|
||||
}
|
||||
if (record.task_type === 'exchange_goods') {
|
||||
const productName = resultText(value, 'product_name');
|
||||
const orderId = resultText(value, 'order_id');
|
||||
const exchangeOrder = value?.exchange_order;
|
||||
const orderId = exchangeOrder && typeof exchangeOrder === 'object'
|
||||
? String((exchangeOrder as Record<string, unknown>).order_id || '')
|
||||
: resultText(value, 'order_id');
|
||||
if (resultText(value, 'pay_url') && record.status !== 'success') {
|
||||
return (
|
||||
<Space size={6}>
|
||||
<Tag color="processing">等待支付积分包</Tag>
|
||||
<Button size="small" icon={<QrcodeOutlined />} onClick={() => setPayTask(record)}>
|
||||
支付码
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (record.status !== 'success') {
|
||||
return productName ? <Text>{productName}</Text> : <Text type="secondary">-</Text>;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,8 @@ const WEIBO_MALL_CACHE_KEY = 'weibo_workbench_mall_cache_v1';
|
||||
const WEIBO_BATCH_RECHARGE_MAX = 20;
|
||||
const WEIBO_BATCH_RECHARGE_CONCURRENCY = 3;
|
||||
const WEIBO_BATCH_RECHARGE_POLL_INTERVAL_MS = 8_000;
|
||||
const WEIBO_BATCH_EXCHANGE_MAX = 50;
|
||||
const WEIBO_BATCH_EXCHANGE_CONCURRENCY = 3;
|
||||
|
||||
function normalizeImageURL(value: string): string {
|
||||
const imageURL = value.trim();
|
||||
@@ -122,6 +124,13 @@ interface BatchRechargeEntry {
|
||||
creditError: string;
|
||||
}
|
||||
|
||||
interface BatchExchangeEntry {
|
||||
account: WeiboAccountItem;
|
||||
phase: 'loading' | 'done' | 'error';
|
||||
result: WeiboExchangeRecordsResult | null;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface ImportRefreshTarget {
|
||||
id: number;
|
||||
label: string;
|
||||
@@ -259,6 +268,14 @@ export default function WeiboAccountsPage({
|
||||
const [exchangeLoading, setExchangeLoading] = useState(false);
|
||||
const [exchangeResult, setExchangeResult] =
|
||||
useState<WeiboExchangeRecordsResult | null>(null);
|
||||
const [batchExchangeOpen, setBatchExchangeOpen] = useState(false);
|
||||
const [batchExchangeLoading, setBatchExchangeLoading] = useState(false);
|
||||
const [batchExchangeSelectedId, setBatchExchangeSelectedId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [batchExchangeEntries, setBatchExchangeEntries] = useState<
|
||||
BatchExchangeEntry[]
|
||||
>([]);
|
||||
const [pointLoading, setPointLoading] = useState(false);
|
||||
const [pointResult, setPointResult] = useState<WeiboPointHistoryResult | null>(null);
|
||||
const [exchangingGoodsId, setExchangingGoodsId] = useState<number | null>(null);
|
||||
@@ -957,6 +974,126 @@ export default function WeiboAccountsPage({
|
||||
void loadExchangeRecords(account);
|
||||
};
|
||||
|
||||
const handleBatchExchangeRecords = async () => {
|
||||
const selected = items.filter((item) => selectedIds.includes(item.id));
|
||||
if (selected.length > WEIBO_BATCH_EXCHANGE_MAX) {
|
||||
message.warning(
|
||||
`批量查询兑换记录一次最多 ${WEIBO_BATCH_EXCHANGE_MAX} 个账号,请缩小选择范围`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const enabled = selected.filter((item) => item.is_enabled);
|
||||
const disabledCount = selected.length - enabled.length;
|
||||
if (enabled.length === 0) {
|
||||
message.warning('所选账号均已停用,无法查询实时兑换记录');
|
||||
return;
|
||||
}
|
||||
if (disabledCount > 0) {
|
||||
message.info(`已自动跳过 ${disabledCount} 个停用账号`);
|
||||
}
|
||||
|
||||
const entries: BatchExchangeEntry[] = enabled.map((account) => ({
|
||||
account,
|
||||
phase: 'loading',
|
||||
result: null,
|
||||
error: '',
|
||||
}));
|
||||
setBatchExchangeEntries(entries);
|
||||
setBatchExchangeSelectedId(null);
|
||||
setBatchExchangeLoading(true);
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let totalRecords = 0;
|
||||
|
||||
const patchEntry = (
|
||||
accountId: number,
|
||||
update: Partial<BatchExchangeEntry>,
|
||||
) => {
|
||||
setBatchExchangeEntries((current) =>
|
||||
current.map((entry) =>
|
||||
entry.account.id === accountId ? { ...entry, ...update } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
await runWithConcurrency(
|
||||
entries,
|
||||
WEIBO_BATCH_EXCHANGE_CONCURRENCY,
|
||||
async (entry) => {
|
||||
try {
|
||||
const result = await weiboApi.exchangeRecords(
|
||||
entry.account.id,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
success += 1;
|
||||
totalRecords += result.total;
|
||||
patchEntry(entry.account.id, {
|
||||
phase: 'done',
|
||||
result,
|
||||
error: '',
|
||||
});
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
patchEntry(entry.account.id, {
|
||||
phase: 'error',
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
setBatchExchangeLoading(false);
|
||||
const text = `批量查询完成:成功 ${success} 个,失败 ${failed} 个,共 ${totalRecords} 条记录`;
|
||||
if (failed > 0) message.warning(text);
|
||||
else message.success(text);
|
||||
};
|
||||
|
||||
const querySingleAccountExchangeRecords = async (
|
||||
account: WeiboAccountItem,
|
||||
) => {
|
||||
if (!account.is_enabled) {
|
||||
message.warning('账号已停用,请先启用');
|
||||
return;
|
||||
}
|
||||
setBatchExchangeEntries((current) => {
|
||||
const nextEntry: BatchExchangeEntry = {
|
||||
account,
|
||||
phase: 'loading',
|
||||
result: null,
|
||||
error: '',
|
||||
};
|
||||
return current.some((entry) => entry.account.id === account.id)
|
||||
? current.map((entry) =>
|
||||
entry.account.id === account.id ? nextEntry : entry,
|
||||
)
|
||||
: [...current, nextEntry];
|
||||
});
|
||||
try {
|
||||
const result = await weiboApi.exchangeRecords(account.id, 1, 50);
|
||||
setBatchExchangeEntries((current) =>
|
||||
current.map((entry) =>
|
||||
entry.account.id === account.id
|
||||
? { ...entry, phase: 'done', result, error: '' }
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setBatchExchangeEntries((current) =>
|
||||
current.map((entry) =>
|
||||
entry.account.id === account.id
|
||||
? {
|
||||
...entry,
|
||||
phase: 'error',
|
||||
result: null,
|
||||
error: getErrorMessage(error),
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPointHistory = async (account: WeiboAccountItem) => {
|
||||
setPointLoading(true);
|
||||
try {
|
||||
@@ -1520,6 +1657,88 @@ export default function WeiboAccountsPage({
|
||||
render: (value: number | null) =>
|
||||
value == null ? <Text type="secondary">-</Text> : value.toLocaleString(),
|
||||
},
|
||||
...(isWorkbench
|
||||
? [
|
||||
{
|
||||
title: '兑换记录',
|
||||
key: 'exchange_records',
|
||||
width: 150,
|
||||
align: 'center' as const,
|
||||
onHeaderCell: centerHeader,
|
||||
render: (_: unknown, account: WeiboAccountItem) => {
|
||||
const entry = batchExchangeByAccount.get(account.id);
|
||||
if (!entry) {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={!account.is_enabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void querySingleAccountExchangeRecords(account);
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (entry.phase === 'loading') {
|
||||
return <Tag color="processing">查询中</Tag>;
|
||||
}
|
||||
if (entry.phase === 'error') {
|
||||
return (
|
||||
<Tooltip title={entry.error}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void querySingleAccountExchangeRecords(account);
|
||||
}}
|
||||
>
|
||||
查询失败
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const latest = entry.result?.items[0];
|
||||
return (
|
||||
<Space
|
||||
orientation="vertical"
|
||||
size={0}
|
||||
style={{ alignItems: 'center' }}
|
||||
>
|
||||
<Tag color={entry.result?.total ? 'blue' : 'default'}>
|
||||
{entry.result?.total ?? 0} 条
|
||||
</Tag>
|
||||
{latest && (
|
||||
<Text
|
||||
type="secondary"
|
||||
ellipsis
|
||||
style={{ fontSize: 12, maxWidth: 140 }}
|
||||
title={latest.goods_name}
|
||||
>
|
||||
{latest.goods_name}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setBatchExchangeSelectedId(account.id);
|
||||
setBatchExchangeOpen(true);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: '最近检查',
|
||||
dataIndex: 'ck_checked_at',
|
||||
@@ -1862,6 +2081,19 @@ export default function WeiboAccountsPage({
|
||||
(total, item) => total + Math.max(0, item.cost_weidou ?? 0),
|
||||
0,
|
||||
) ?? 0;
|
||||
const batchExchangeByAccount = new Map(
|
||||
batchExchangeEntries.map((entry) => [entry.account.id, entry]),
|
||||
);
|
||||
const batchExchangeSelectedEntry =
|
||||
batchExchangeEntries.find(
|
||||
(entry) => entry.account.id === batchExchangeSelectedId,
|
||||
) ?? null;
|
||||
const batchExchangeDetailRows = (
|
||||
batchExchangeSelectedEntry?.result?.items || []
|
||||
).map((record, index) => ({
|
||||
...record,
|
||||
key: `${batchExchangeSelectedEntry?.account.id || 0}-${record.exchange_time}-${index}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||
@@ -2002,6 +2234,16 @@ export default function WeiboAccountsPage({
|
||||
批量充值
|
||||
</Button>
|
||||
)}
|
||||
{isWorkbench && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
loading={batchExchangeLoading}
|
||||
onClick={() => void handleBatchExchangeRecords()}
|
||||
>
|
||||
批量查兑换记录
|
||||
</Button>
|
||||
)}
|
||||
{isWorkbench && canTransferCollab && selectedIds.length === 1 && (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -2122,7 +2364,7 @@ export default function WeiboAccountsPage({
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => setSelectedIds(keys.map(Number)),
|
||||
}}
|
||||
scroll={{ x: isWorkbench ? 980 : 1450 }}
|
||||
scroll={{ x: isWorkbench ? 1130 : 1450 }}
|
||||
onRow={(account) => ({
|
||||
onClick: () => {
|
||||
if (isWorkbench) selectWorkbenchAccount(account);
|
||||
@@ -2443,6 +2685,60 @@ export default function WeiboAccountsPage({
|
||||
</AutoComplete>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`兑换记录 · ${
|
||||
batchExchangeSelectedEntry?.account.nickname ||
|
||||
batchExchangeSelectedEntry?.account.uid ||
|
||||
''
|
||||
}`}
|
||||
open={batchExchangeOpen}
|
||||
width={1080}
|
||||
onCancel={() => {
|
||||
setBatchExchangeOpen(false);
|
||||
setBatchExchangeSelectedId(null);
|
||||
}}
|
||||
footer={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setBatchExchangeOpen(false);
|
||||
setBatchExchangeSelectedId(null);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{batchExchangeSelectedEntry && (
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Text strong>
|
||||
{batchExchangeSelectedEntry.account.nickname ||
|
||||
batchExchangeSelectedEntry.account.uid}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
UID {batchExchangeSelectedEntry.account.uid}
|
||||
</Text>
|
||||
<Tag color="blue">
|
||||
共 {batchExchangeSelectedEntry.result?.total ?? 0} 条
|
||||
</Tag>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="key"
|
||||
size="small"
|
||||
columns={exchangeColumns}
|
||||
dataSource={batchExchangeDetailRows}
|
||||
locale={{ emptyText: '该账号暂无兑换记录' }}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
scroll={{ x: 760 }}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`批量充值 · 已选 ${selectedIds.length} 个账号`}
|
||||
open={batchRechargeConfigOpen}
|
||||
|
||||
@@ -1268,19 +1268,6 @@ export default function WeiboCollabPage() {
|
||||
{ value: 'error', label: ACCOUNT_CK_LABELS.error },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={archiveScope}
|
||||
onChange={(value) => {
|
||||
setArchiveScope(value);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'active', label: '活跃台账' },
|
||||
{ value: 'archived', label: '已归档' },
|
||||
{ value: 'all', label: '全部' },
|
||||
]}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={includeVoid}
|
||||
onChange={(event) => {
|
||||
@@ -1290,6 +1277,19 @@ export default function WeiboCollabPage() {
|
||||
>
|
||||
显示作废
|
||||
</Checkbox>
|
||||
<Space size={4}>
|
||||
<Text type="secondary">查看归档</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={archiveScope === 'archived'}
|
||||
onChange={(checked) => {
|
||||
setArchiveScope(checked ? 'archived' : 'active');
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label="查看归档"
|
||||
/>
|
||||
<Tag>{summary?.archived_total ?? 0}</Tag>
|
||||
</Space>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1_000_000}
|
||||
@@ -1366,6 +1366,17 @@ export default function WeiboCollabPage() {
|
||||
showIcon
|
||||
message="正在查看已归档订单"
|
||||
description="归档订单只读,取消归档后可继续操作。"
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setArchiveScope('active');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
返回活跃台账
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user