feat(huya): 统一积分兑换并完善兑换记录

This commit is contained in:
yml
2026-11-13 19:03:55 +08:00
parent b89ee4fd18
commit 711be0e2b6
17 changed files with 2064 additions and 193 deletions
+108 -13
View File
@@ -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)}
+17 -3
View File
@@ -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>;
}
+297 -1
View File
@@ -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}
+24 -13
View File
@@ -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>
}
/>
)}