feat(account): 详情页重排 + 列表高价值套装列 + 兑换记录点击预览
## 详情页重排(4 张卡,消除重复) 用户反馈「设计太乱、数据不好看、布局也乱」——根因是**同一份数据在一屏出现两次**: 积分/鱼翅/兑换余额在状态条和资产区各一次,角色绑定在状态条和信息表各一次。 新布局:**账号信息**(身份+状态+归属,一张 Descriptions)→ **资产与统计** (数字只出现一次,上面"现在有多少"、下面"历史累计")→ **高价值套装与兑换记录** (从最顶上移到这里,它是补充信息,不该抢主视觉)→ **账号时间线**。 零散的 `flex + gap` 统计行改成 `Row/Col` 栅格,列宽对齐。 ## 高价值套装:颜色按**价值**口径,别标反 用户澄清的业务语义与直觉相反: - **没兑换过** = 还能用来绑定兑换 = **高价值**(绿色「可兑换」) - 兑换过 1 个 → 价值降低;两个都兑换过 → 价值最低(橙色「已兑换」) 判定依据是**斗鱼官方兑换记录**(账号级)。⚠️ 不能用 `query_limited_goods` 的 `count`/`storeStatus` —— 那是**全局库存**(同一时刻不同账号看到的都一样), 用它判"这个账号有没有"必然给错结论。这个结论是推翻过一版才得到的: 第一版拿相隔两个月的两条快照对比,把**时间差异**当成了**账号差异**。 ## 列表 - 新增「高价值套装」列:🟢两个都可兑换 / 🟠某套装已兑换 / ⚪未查询 (「未查询」必须与「没兑换」分开,否则会把未知当成高价值) - 「兑换记录」列只显示官方口径,**点击弹窗**看全部(按作用域分组, 含商品/积分/真实兑换时间/当时用的昵称与平台),不用进详情页 ## 三个作用域的记录展示 面板与预览都按 精英宝典 / 电竞手册 / 和平小店 分三块展示, 每条带**真实兑换时间**与"使用昵称"(排查时有用)。 ## 顺带修 `exchange_records` 从数组改成按作用域分组的对象后,面板同步适配; `formatTime` 对 `0` 显示 `-`(斗鱼用 0 表示"没有这个时间", 直接格式化会显示成 1970/1/1)。
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import api from './client';
|
||||
import type {
|
||||
AccountExchangeRecords,
|
||||
AccountEventPage,
|
||||
AccountItem,
|
||||
AccountBulkSelection,
|
||||
@@ -50,6 +51,9 @@ export const accountApi = {
|
||||
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/automation`, { auto_managed }),
|
||||
// 详情页:状态卡 + 统计
|
||||
overview: (id: number) => api.get<AccountOverview, AccountOverview>(`/accounts/${id}/overview`),
|
||||
// 三个作用域的**官方**兑换记录(点列表那一格时按需拉取,不进列表响应)
|
||||
exchangeRecords: (id: number) =>
|
||||
api.get<AccountExchangeRecords, AccountExchangeRecords>(`/accounts/${id}/exchange-records`),
|
||||
// 时间线(倒序分页),event_type 为空表示全部
|
||||
events: (
|
||||
id: number,
|
||||
|
||||
@@ -176,6 +176,8 @@ export interface AccountOverview {
|
||||
stats: AccountOverviewStats;
|
||||
/** 最近一次「一键查询」的快照(结构与后端 query_account_overview 的 result 一致) */
|
||||
overview_snapshot: import('../pages/douyu-tasks/AccountOverviewPanel').AccountOverviewResult | null;
|
||||
/** 一键查询得到的已兑换高价值套装 */
|
||||
high_value_owned: string[];
|
||||
overview_queried_at: string | null;
|
||||
/** 开通时间来自事件表(上线后才开始记录,更早的不显示) */
|
||||
opened_at: { elite: string | null; esports: string | null };
|
||||
@@ -278,6 +280,10 @@ export interface AccountItem {
|
||||
exchange_count: number;
|
||||
last_exchange_at: string | null;
|
||||
last_exchange_name: string;
|
||||
/** 一键查询得到的「已兑换过的高价值套装」(账号表里没有,来自官方兑换记录) */
|
||||
high_value_owned: string[];
|
||||
/** 最近一次一键查询时间 —— 用来区分「查过但没兑换」和「还没查过」 */
|
||||
overview_queried_at: string | null;
|
||||
}
|
||||
|
||||
export interface AssignmentsSummary {
|
||||
@@ -1094,3 +1100,20 @@ export interface AutomationManualQueue {
|
||||
total: number;
|
||||
groups: AutomationManualGroup[];
|
||||
}
|
||||
|
||||
/** 官方兑换记录(按作用域分组) */
|
||||
export interface AccountExchangeRecord {
|
||||
title: string;
|
||||
commodity_id: string;
|
||||
commodity_name: string;
|
||||
score: number | string | null;
|
||||
show_name: string;
|
||||
exchange_num: number | null;
|
||||
occurred_at: string | null;
|
||||
}
|
||||
|
||||
export interface AccountExchangeRecords {
|
||||
groups: Record<string, AccountExchangeRecord[]>;
|
||||
total: number;
|
||||
queried_at: string | null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Button, Card, Descriptions, Empty, Input, Popconfirm, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
|
||||
Button, Card, Col, Descriptions, Divider, Empty, Input, Popconfirm, Row, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
@@ -56,19 +56,6 @@ const STATUS_TONES: Record<string, string> = {
|
||||
archived: 'default',
|
||||
};
|
||||
|
||||
/** 单个作用域的绑定小标签:精/电/店。 */
|
||||
function BindChip({ scope, status }: { scope: string; status?: string }) {
|
||||
const tone = status === 'bound' ? 'green'
|
||||
: status === 'pending' ? 'orange'
|
||||
: status === 'unbound' ? 'red'
|
||||
: 'default';
|
||||
const mark = status === 'bound' ? '✓'
|
||||
: status === 'pending' ? '…'
|
||||
: status === 'unbound' ? '✗'
|
||||
: '?';
|
||||
return <Tag color={tone} style={{ marginInlineEnd: 0 }}>{scope}{mark}</Tag>;
|
||||
}
|
||||
|
||||
/** 绑定状态 → 展示色(与后端 account_binding 的四个枚举对应)。 */
|
||||
const BIND_TONES: Record<string, string> = {
|
||||
bound: 'green',
|
||||
@@ -369,136 +356,19 @@ export default function AccountDetailPage() {
|
||||
</Space>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{/* ── 生命周期状态卡 ────────────────────────────────
|
||||
这一屏要回答客服最关心的三个问题:
|
||||
1. 这账号现在能不能用?(状态 + CK)
|
||||
2. 要不要我处理?(原因 + 变更时间 + 自动化是否在管)
|
||||
3. 账上还有多少?(积分 / 鱼翅 / 兑换余额) */}
|
||||
{/* ── 卡片 1:账号信息 ──────────────────────────────
|
||||
身份 + 状态 + 归属,一张表看完。**不重复展示资产**(见卡片 2)。 */}
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginBottom: 12 }}
|
||||
title={
|
||||
<Space>
|
||||
<ThunderboltOutlined />
|
||||
<span>高价值套装与兑换记录(一键查询)</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={ovLoading}
|
||||
onClick={runOverview}
|
||||
>
|
||||
查询该账号全部信息
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{ovError && (
|
||||
<Text type="danger" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{ovError}
|
||||
</Text>
|
||||
)}
|
||||
{overview?.overview_snapshot ? (
|
||||
<AccountOverviewPanel
|
||||
result={overview.overview_snapshot}
|
||||
queriedAt={overview.overview_queried_at}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
实时向斗鱼查一次这个账号:鱼翅 / 积分 / 开通状态会**就地刷新到下面的账号信息**,
|
||||
这里只单独展示账号表里没有的两项 —— 「兔兔白日梦」「狂战萌兔」是否已获得,
|
||||
以及官方兑换记录。
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Space size={24} wrap align="start">
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text type="secondary">生命周期状态</Text>
|
||||
<Space size={6}>
|
||||
<Tag color={STATUS_TONES[status] ?? 'default'} style={{ fontSize: 14, padding: '2px 8px' }}>
|
||||
{overview?.status_label || '—'}
|
||||
</Tag>
|
||||
{overview?.auto_managed === false && <Tag color="orange">不参与自动化</Tag>}
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{overview?.nickname || overview?.username || '—'}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatTime(overview?.status_changed_at)}
|
||||
{overview?.status_reason ? `(${overview.status_reason})` : ''}
|
||||
#{overview?.id ?? '-'} · {overview?.username || '—'}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text type="secondary">CK</Text>
|
||||
{overview?.has_cookie === false
|
||||
? <Tag>无 CK</Tag>
|
||||
: <Tag color={overview?.ck_check_status === 'invalid' ? 'red' : 'green'}>
|
||||
{overview?.ck_check_status === 'invalid' ? '已失效'
|
||||
: overview?.ck_check_status === 'valid' ? '正常' : '未检测'}
|
||||
</Tag>}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
最近检测 {formatTime(overview?.stats?.last_ck_check_at)}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text type="secondary">角色绑定</Text>
|
||||
<Space size={2} wrap>
|
||||
<BindChip scope="精" status={overview?.role_bind_status} />
|
||||
<BindChip scope="电" status={overview?.esports_bind_status} />
|
||||
<BindChip scope="店" status={overview?.xpd_bind_status} />
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Statistic title="积分" value={overview?.points ?? '-'} />
|
||||
<Statistic title="鱼翅" value={overview?.gold_balance ?? '-'} />
|
||||
<Statistic title="兑换余额" value={overview?.exchange_balance ?? '-'} />
|
||||
|
||||
{/* 人工操作:与工具栏的生命周期动作分开 —— 那些是「账号可用性」,
|
||||
这些是「归属与标注」。 */}
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text type="secondary">归属与标注</Text>
|
||||
<Space size={6} wrap>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 140 }}
|
||||
placeholder="未分配"
|
||||
value={overview?.assigned_to ?? undefined}
|
||||
allowClear
|
||||
onChange={(val) => void handleAssign(val ?? null)}
|
||||
options={supportUsers.map((u) => ({ value: u.id, label: u.username }))}
|
||||
/>
|
||||
{tagEditing ? (
|
||||
<Space.Compact>
|
||||
<Input
|
||||
size="small"
|
||||
style={{ width: 120 }}
|
||||
maxLength={32}
|
||||
value={tagDraft}
|
||||
onChange={(e) => setTagDraft(e.target.value)}
|
||||
onPressEnter={() => void handleSaveTag()}
|
||||
/>
|
||||
<Button size="small" type="primary" onClick={() => void handleSaveTag()}>保存</Button>
|
||||
<Button size="small" onClick={() => setTagEditing(false)}>取消</Button>
|
||||
</Space.Compact>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => { setTagDraft(overview?.tag || ''); setTagEditing(true); }}
|
||||
>
|
||||
{overview?.tag || '设置标签'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Title level={5} style={{ margin: 0 }}>{overview?.username || '—'}</Title>
|
||||
<Tag color={STATUS_TONES[status] ?? 'default'}>
|
||||
{overview?.status_label || '—'}
|
||||
</Tag>
|
||||
@@ -506,19 +376,50 @@ export default function AccountDetailPage() {
|
||||
{overview?.auto_managed === false && <Tag color="orange">不参与自动化</Tag>}
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Descriptions column={3} size="small" bordered>
|
||||
<Descriptions.Item label="斗鱼昵称">{overview?.nickname || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="斗鱼 UID">{overview?.uid || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="导入时间">{formatTime(overview?.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="游戏角色">
|
||||
{overview?.game_name || '-'}
|
||||
{overview?.game_channel ? <Text type="secondary"> ({overview.game_channel})</Text> : null}
|
||||
{overview?.game_channel ? (
|
||||
<Text type="secondary">({overview.game_channel})</Text>
|
||||
) : null}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="导入时间">
|
||||
{formatTime(overview?.created_at)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="生命周期">
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>
|
||||
{formatTime(overview?.status_changed_at)}
|
||||
{overview?.status_reason ? (
|
||||
<Text type="secondary">({overview.status_reason})</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="CK">
|
||||
<Space size={6} wrap>
|
||||
{overview?.has_cookie === false ? (
|
||||
<Tag>无 CK</Tag>
|
||||
) : (
|
||||
<Tag color={overview?.ck_check_status === 'invalid' ? 'red' : 'green'}>
|
||||
{overview?.ck_check_status === 'invalid'
|
||||
? '已失效'
|
||||
: overview?.ck_check_status === 'valid'
|
||||
? '正常'
|
||||
: '未检测'}
|
||||
</Tag>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatTime(stats?.last_ck_check_at)}
|
||||
</Text>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色绑定">
|
||||
{/* 三列各管一个作用域,取值 unknown/unbound/pending/bound */}
|
||||
<Space size={6} wrap>
|
||||
{/* 三个作用域各一列,取值 unknown/unbound/pending/bound */}
|
||||
<Space size={4} wrap>
|
||||
<Tag color={BIND_TONES[overview?.role_bind_status ?? 'unknown']}>
|
||||
精英:{overview?.role_bind_label || '未知'}
|
||||
</Tag>
|
||||
@@ -530,6 +431,53 @@ export default function AccountDetailPage() {
|
||||
</Tag>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="归属">
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 140 }}
|
||||
placeholder="未分配"
|
||||
value={overview?.assigned_to ?? undefined}
|
||||
allowClear
|
||||
onChange={(val) => void handleAssign(val ?? null)}
|
||||
options={supportUsers.map((u) => ({ value: u.id, label: u.username }))}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标签">
|
||||
{tagEditing ? (
|
||||
<Space.Compact>
|
||||
<Input
|
||||
size="small"
|
||||
style={{ width: 120 }}
|
||||
maxLength={32}
|
||||
value={tagDraft}
|
||||
onChange={(e) => setTagDraft(e.target.value)}
|
||||
onPressEnter={() => void handleSaveTag()}
|
||||
/>
|
||||
<Button size="small" type="primary" onClick={() => void handleSaveTag()}>
|
||||
保存
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setTagEditing(false)}>
|
||||
取消
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
) : (
|
||||
<Space size={4}>
|
||||
<span>{overview?.tag || '-'}</span>
|
||||
<Tooltip title="编辑标签">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setTagDraft(overview?.tag || '');
|
||||
setTagEditing(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">
|
||||
{remarkEditing ? (
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
@@ -565,65 +513,130 @@ export default function AccountDetailPage() {
|
||||
</Space>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态变更">
|
||||
{formatTime(overview?.status_changed_at)}
|
||||
{overview?.status_reason ? (
|
||||
<Text type="secondary">({overview.status_reason})</Text>
|
||||
) : null}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', gap: 32, marginTop: 16, flexWrap: 'wrap' }}>
|
||||
<Statistic title="当前积分" value={overview?.points ?? '-'} />
|
||||
<Statistic
|
||||
title="积分到期"
|
||||
// 斗鱼用 0 表示「没有到期时间」;不处理会显示成 1970/1/1
|
||||
value={overview?.points_expire_at ? formatTime(overview.points_expire_at) : '-'}
|
||||
valueStyle={{ fontSize: 20 }}
|
||||
/>
|
||||
<Statistic title="鱼翅余额" value={overview?.gold_balance ?? '-'} />
|
||||
<Statistic title="兑换余额" value={overview?.exchange_balance ?? '-'} />
|
||||
<Statistic
|
||||
title="精英宝典"
|
||||
value={overview?.elite_opened ? '已开通' : '未开通'}
|
||||
valueStyle={{ color: overview?.elite_opened ? '#3f8600' : undefined, fontSize: 20 }}
|
||||
/>
|
||||
<Statistic
|
||||
title="电竞手册"
|
||||
value={overview?.esports_opened ? '已开通' : '未开通'}
|
||||
valueStyle={{ fontSize: 20 }}
|
||||
/>
|
||||
</div>
|
||||
{/* ── 卡片 2:资产与统计 ────────────────────────────
|
||||
资产就**只在这里**展示一次(之前状态条里还重复了一遍)。
|
||||
两组分开:上面是「现在有多少」,下面是「历史累计」。 */}
|
||||
<Card size="small" style={{ marginBottom: 12 }} title="资产与统计">
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col span={4}>
|
||||
<Statistic title="当前积分" value={overview?.points ?? '-'} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic
|
||||
title="积分到期"
|
||||
value={
|
||||
// 斗鱼用 0 表示「没有到期时间」;不处理会显示成 1970/1/1
|
||||
overview?.points_expire_at ? formatTime(overview.points_expire_at) : '-'
|
||||
}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="鱼翅余额" value={overview?.gold_balance ?? '-'} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="兑换余额" value={overview?.exchange_balance ?? '-'} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic
|
||||
title="精英宝典"
|
||||
value={overview?.elite_opened ? '已开通' : '未开通'}
|
||||
valueStyle={{
|
||||
fontSize: 18,
|
||||
color: overview?.elite_opened ? '#3f8600' : undefined,
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic
|
||||
title="电竞手册"
|
||||
value={overview?.esports_opened ? '已开通' : '未开通'}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Descriptions column={3} size="small" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="精英宝典开通时间">
|
||||
{formatTime(overview?.opened_at?.elite)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="电竞手册开通时间">
|
||||
{formatTime(overview?.opened_at?.esports)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近 CK 检测">
|
||||
{formatTime(stats?.last_ck_check_at)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Divider style={{ margin: '16px 0 12px' }} />
|
||||
|
||||
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<Statistic title="累计充值" value={stats?.recharge_total ?? 0} prefix="¥" />
|
||||
<Statistic title="兑换次数" value={stats?.exchange_count ?? 0} />
|
||||
<Statistic title="累计消耗积分" value={stats?.points_spent ?? 0} />
|
||||
<Statistic title="CK 失效次数" value={stats?.ck_invalid_count ?? 0} />
|
||||
<Statistic title="登录成功次数" value={stats?.login_count ?? 0} />
|
||||
<Statistic title="最近登录" value={formatTime(stats?.last_login_at)} />
|
||||
<Statistic title="事件总数" value={stats?.event_count ?? 0} />
|
||||
</div>
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col span={4}>
|
||||
<Statistic title="累计充值" value={stats?.recharge_total ?? 0} prefix="¥" />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="兑换次数" value={stats?.exchange_count ?? 0} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="累计消耗积分" value={stats?.points_spent ?? 0} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="CK 失效次数" value={stats?.ck_invalid_count ?? 0} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="登录成功次数" value={stats?.login_count ?? 0} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="事件总数" value={stats?.event_count ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 12, fontSize: 12 }}>
|
||||
开通时间与累计统计来自「账号事件」,从该功能上线起开始记录;
|
||||
最近登录 {formatTime(stats?.last_login_at)} · 精英开通{' '}
|
||||
{formatTime(overview?.opened_at?.elite)} · 电竞开通{' '}
|
||||
{formatTime(overview?.opened_at?.esports)}
|
||||
<br />
|
||||
开通时间与累计统计来自「账号事件」,从该功能上线起记录;
|
||||
更早的历史请到「登录任务 / 精英宝典 / 电竞手册 / 和平小店」页查看。
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* ── 卡片 3:一键查询结果 ──────────────────────────
|
||||
放在账号信息**下面** —— 它是补充信息(账号表里没有的两项),
|
||||
不该压在最上面抢主视觉。 */}
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginBottom: 12 }}
|
||||
title={
|
||||
<Space>
|
||||
<ThunderboltOutlined />
|
||||
<span>高价值套装与兑换记录</span>
|
||||
{overview?.overview_queried_at && (
|
||||
<Text type="secondary" style={{ fontSize: 12, fontWeight: 'normal' }}>
|
||||
查询于 {formatTime(overview.overview_queried_at)}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={ovLoading}
|
||||
onClick={runOverview}
|
||||
>
|
||||
一键查询
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{ovError && (
|
||||
<Text type="danger" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{ovError}
|
||||
</Text>
|
||||
)}
|
||||
{overview?.overview_snapshot ? (
|
||||
<AccountOverviewPanel result={overview.overview_snapshot} />
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
点右上角「一键查询」实时向斗鱼查一次:鱼翅 / 积分 / 开通状态会就地刷新到上面的
|
||||
「资产与统计」,这里只展示账号表里没有的两项 ——
|
||||
高价值套装是否已获得,以及官方兑换记录。
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Spin>
|
||||
|
||||
|
||||
<Card
|
||||
title="账号时间线"
|
||||
extra={
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert, Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
|
||||
Row, Col, Card, Statistic, App, Tooltip,
|
||||
Alert,
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Input,
|
||||
Select,
|
||||
Popconfirm,
|
||||
Typography,
|
||||
Tag,
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Statistic,
|
||||
App,
|
||||
Tooltip,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import type { TableProps } from 'antd';
|
||||
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { accountApi, douyuApi, type AccountItem, type BasicSummary } from '../api/modules';
|
||||
import { accountApi, douyuApi, type AccountExchangeRecord, type AccountExchangeRecords, type AccountItem, type BasicSummary } from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { formatTimeShort } from '../utils/time';
|
||||
import { formatTime, formatTimeShort } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const TAG_COLORS = [
|
||||
@@ -72,6 +87,44 @@ function BindChip({ scope, status }: { scope: string; status?: string }) {
|
||||
return <Tag color={tone} style={{ marginInlineEnd: 0 }}>{scope}{mark}</Tag>;
|
||||
}
|
||||
|
||||
/** 三个作用域的展示顺序与中文名(后端按 scope 分组返回)。 */
|
||||
const EXCHANGE_SCOPES: Array<{ key: string; label: string }> = [
|
||||
{ key: 'elite', label: '精英宝典' },
|
||||
{ key: 'esports', label: '电竞手册' },
|
||||
{ key: 'peace', label: '和平小店' },
|
||||
];
|
||||
|
||||
const EXCHANGE_COLUMNS: TableProps<AccountExchangeRecord>['columns'] = [
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'commodity_name',
|
||||
key: 'commodity_name',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '积分',
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
width: 90,
|
||||
render: (value: number | string | null) => value ?? '-',
|
||||
},
|
||||
{
|
||||
// 官方记录带**真实兑换时间**
|
||||
title: '兑换时间',
|
||||
dataIndex: 'occurred_at',
|
||||
key: 'occurred_at',
|
||||
width: 170,
|
||||
render: (value: string | null) => formatTime(value),
|
||||
},
|
||||
{
|
||||
// 当时用的斗鱼昵称 + 平台/支付渠道,排查时有用
|
||||
title: '使用昵称',
|
||||
dataIndex: 'show_name',
|
||||
key: 'show_name',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AccountsPage() {
|
||||
const navigate = useNavigate();
|
||||
// App.useApp() 的 modal 实例继承主题,静态 Modal.confirm 在暗色模式下是白底
|
||||
@@ -88,6 +141,23 @@ export default function AccountsPage() {
|
||||
const [ckFilter, setCkFilter] = useState<string>('');
|
||||
const [bindFilter, setBindFilter] = useState<string>('');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
// 兑换记录预览:点列表那一格**按需拉取**(不进列表响应),弹窗按作用域分组
|
||||
const [exRecords, setExRecords] = useState<AccountExchangeRecords | null>(null);
|
||||
const [exLoading, setExLoading] = useState(false);
|
||||
const [exAccount, setExAccount] = useState<AccountItem | null>(null);
|
||||
const openExchangeRecords = async (record: AccountItem) => {
|
||||
setExAccount(record);
|
||||
setExRecords(null);
|
||||
setExLoading(true);
|
||||
try {
|
||||
setExRecords(await accountApi.exchangeRecords(record.id));
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setExLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [selectedAllMatching, setSelectedAllMatching] = useState(false);
|
||||
const [batchTagInput, setBatchTagInput] = useState('');
|
||||
@@ -405,25 +475,78 @@ export default function AccountsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
// 兑换记录:次数 + 最近一次(商品名 + 日期)。
|
||||
// 数据源是账号自己的事件流(`account_events.exchange`),不是 douyu_tasks 的 JSON ——
|
||||
// 后者是执行日志,S5 起业务事实已经在事件里了。完整历史看详情页时间线。
|
||||
// 高价值套装:这只关乎**账号价值** —— 没兑换过才值钱。
|
||||
//
|
||||
// ⚠️ 语义与直觉相反,别标反了:
|
||||
// **没兑换过** = 还能用来绑定兑换 = **高价值**(绿色)
|
||||
// 兑换过 1 个 → 价值降低;两个都兑换过 → 价值最低
|
||||
// 数据来自一键查询拉的**斗鱼官方兑换记录**。
|
||||
title: '高价值套装',
|
||||
dataIndex: 'high_value_owned',
|
||||
width: 170,
|
||||
render: (owned: string[] | undefined, record: AccountItem) => {
|
||||
const used = owned ?? [];
|
||||
// 没查过 ≠ 没兑换:必须分开说,否则会把"没数据"当成"高价值"
|
||||
// 「有没有查过」看查询时间:只看 owned 是否为空的话,
|
||||
// 「查过但没兑换」(高价值)和「没查过」(未知)就分不开了。
|
||||
const queried = Boolean(record.overview_queried_at);
|
||||
if (!queried) {
|
||||
return (
|
||||
<Tooltip title="还没做过一键查询,点工具栏「批量一键查询」">
|
||||
<Text type="secondary">未查询</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (used.length === 0) {
|
||||
return <Tag color="green">两个都可兑换</Tag>;
|
||||
}
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{used.map((n) => (
|
||||
<Tag key={n} color="orange">
|
||||
{n.replace(/^套装-/, '')} 已兑换
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// 兑换记录:次数 + 最近一次。
|
||||
//
|
||||
// 只反映**斗鱼官方记录**(后端聚合已按 source=official_sync 过滤)——
|
||||
// 我们当场记的那些留给详情页时间线,列表要的是"官方权威且完整"的口径。
|
||||
// 点这一格弹窗看全部(按作用域分组),不用进详情页。
|
||||
title: '兑换记录',
|
||||
dataIndex: 'exchange_count',
|
||||
width: 170,
|
||||
render: (_, record: AccountItem) => {
|
||||
if (!record.exchange_count) return <Text type="secondary">未兑换</Text>;
|
||||
if (!record.exchange_count) {
|
||||
return <Text type="secondary">未兑换</Text>;
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
|
||||
<Text>{record.exchange_count} 次</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis title={record.last_exchange_name}>
|
||||
{formatTimeShort(record.last_exchange_at)}
|
||||
{/* 列的标题已经是「兑换记录」,事件 title 里的「兑换 」前缀在这儿是重复的 */}
|
||||
{record.last_exchange_name
|
||||
? ` ${record.last_exchange_name.replace(/^兑换\s*/, '')}`
|
||||
: ''}
|
||||
</Text>
|
||||
</Space>
|
||||
<Tooltip title="点击查看全部官方兑换记录">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => void openExchangeRecords(record)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void openExchangeRecords(record);
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
|
||||
<Text>{record.exchange_count} 次</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
{formatTimeShort(record.last_exchange_at)}
|
||||
{/* 列标题已是「兑换记录」,事件 title 里的「兑换 」前缀在这儿重复 */}
|
||||
{record.last_exchange_name
|
||||
? ` ${record.last_exchange_name.replace(/^兑换\s*/, '')}`
|
||||
: ''}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -730,6 +853,54 @@ export default function AccountsPage() {
|
||||
options={tags.map((t) => ({ value: t, label: t }))}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<span>官方兑换记录</span>
|
||||
{exAccount && (
|
||||
<Text type="secondary" style={{ fontSize: 12, fontWeight: 'normal' }}>
|
||||
{exAccount.username}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
open={Boolean(exAccount)}
|
||||
onCancel={() => setExAccount(null)}
|
||||
footer={<Button onClick={() => setExAccount(null)}>关闭</Button>}
|
||||
width={760}
|
||||
styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }}
|
||||
>
|
||||
{exLoading ? (
|
||||
<Spin />
|
||||
) : !exRecords || exRecords.total === 0 ? (
|
||||
<Text type="secondary">
|
||||
没有官方兑换记录。若还没查过这个账号,先点工具栏「批量一键查询」。
|
||||
</Text>
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
{EXCHANGE_SCOPES.map(({ key, label }) => {
|
||||
const rows = exRecords.groups[key] ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<Text strong>
|
||||
{label}({rows.length} 条)
|
||||
</Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey={(row) => `${row.commodity_id}-${row.occurred_at ?? ''}`}
|
||||
columns={EXCHANGE_COLUMNS}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Card, Descriptions, Empty, Space, Table, Tag, Typography } from 'antd';
|
||||
import { Alert, Card, Empty, Space, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -42,7 +42,8 @@ export interface AccountOverviewResult {
|
||||
};
|
||||
high_value_goods?: HighValueGoods[];
|
||||
limited_goods_total?: number;
|
||||
exchange_records?: Array<Record<string, unknown>>;
|
||||
/** 按作用域分组:elite / esports / peace */
|
||||
exchange_records?: Record<string, Array<Record<string, unknown>>>;
|
||||
exchange_record_count?: number;
|
||||
/** 单项失败:**必须展示**,否则「没查到」会被读成「没有」 */
|
||||
errors?: Record<string, string>;
|
||||
@@ -90,93 +91,131 @@ function formatTime(value: string | number | null | undefined): string {
|
||||
* 我们没法只凭它断言账号真的拿到了。所以写「不可再兑」并把 count 一起显示,
|
||||
* 让运营自己判断 —— 这比给一个可能错的结论好。
|
||||
*/
|
||||
function HighValueCard({ goods }: { goods: HighValueGoods }) {
|
||||
if (goods.missing) {
|
||||
return (
|
||||
<Card size="small" style={{ width: 280, borderStyle: 'dashed' }}>
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text strong>{goods.name}</Text>
|
||||
<Tag color="default">未查到该商品</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{goods.reason || '限兑列表和官方兑换记录里都没有它'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
// ⚠️ 「是否已获得」只看官方兑换记录。`count`/`store_status` 是**全局库存**,
|
||||
// 同一时刻不同账号看到的都一样,**不能**用来判断这个账号有没有。
|
||||
const owned = goods.owned === true;
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{ width: 280, borderColor: owned ? '#52c41a' : undefined }}
|
||||
title={<Text strong>{goods.name}</Text>}
|
||||
extra={
|
||||
<Tag color={owned ? 'green' : 'default'}>{owned ? '✅ 已获得' : '未获得'}</Tag>
|
||||
}
|
||||
>
|
||||
<Descriptions size="small" column={1} colon={false}>
|
||||
{owned && (
|
||||
<Descriptions.Item label="记录">
|
||||
{(goods.obtained_names || []).join('、') || '-'}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="积分价">{goods.score ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前库存">
|
||||
{goods.count ?? '-'}
|
||||
{goods.count === 0 ? '(已售罄)' : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="活动时间">
|
||||
{formatTime(goods.start_time)} ~ {formatTime(goods.end_time)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{!goods.in_goods_list && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
当前限兑列表里没有它(活动窗口可能已结束),但兑换记录仍在。
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
/**
|
||||
* 高价值套装的展示状态。
|
||||
*
|
||||
* ⚠️ **颜色是「价值」口径,不是「有没有」口径** —— 别标反:
|
||||
* 没兑换过 → 「可兑换」**绿色**(这个账号还值钱,能用来绑定兑换)
|
||||
* 兑换过 → 「已兑换」**橙色**(价值降低)
|
||||
* 同一件东西两个都兑换过,账号价值最低。
|
||||
*
|
||||
* 「是否已获得」只看**官方兑换记录**:`count`/`store_status` 是全局库存,
|
||||
* 同一时刻不同账号看到的都一样,不能用来判断这个账号有没有。
|
||||
*/
|
||||
function goodsState(g: HighValueGoods): { text: string; color: string } {
|
||||
if (g.missing) return { text: '未查到该商品', color: 'default' };
|
||||
return g.owned
|
||||
? { text: '已兑换(价值降低)', color: 'orange' }
|
||||
: { text: '可兑换', color: 'green' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键查询结果面板。
|
||||
*
|
||||
* ⚠️ **只展示账号表里没有的东西**(高价值套装是否已获得、官方兑换记录)。
|
||||
*
|
||||
* 鱼翅 / 积分 / 钱包余额 / 开通状态这些,账号表本来就有对应字段
|
||||
* (`gold_balance` / `points` / `exchange_balance` / `elite_opened` / `esports_opened`),
|
||||
* 查询的作用是**刷新它们**,然后由账号详情里已有的界面直接反映出来 ——
|
||||
* 在这里再复制一份展示是多余的,还会让人分不清哪份才是准的。
|
||||
*/
|
||||
const goodsColumns: ColumnsType<HighValueGoods> = [
|
||||
{
|
||||
title: '高价值套装',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
render: (name: string, g) =>
|
||||
g.missing ? (
|
||||
<Tooltip title={g.reason || '限兑列表和官方兑换记录里都没有它'}>
|
||||
<Text type="secondary">{name}</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text strong>{name}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '可兑换 / 已兑换',
|
||||
key: 'owned',
|
||||
width: 130,
|
||||
render: (_, g) => {
|
||||
const state = goodsState(g);
|
||||
return <Tag color={state.color}>{state.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '兑换记录里的名字',
|
||||
key: 'obtained',
|
||||
render: (_, g) =>
|
||||
g.obtained_names?.length ? (
|
||||
<Text style={{ fontSize: 12 }}>{g.obtained_names.join('、')}</Text>
|
||||
) : (
|
||||
<Text type="secondary">—</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '积分价',
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
width: 90,
|
||||
render: (v: number | null | undefined) => v ?? '—',
|
||||
},
|
||||
{
|
||||
title: '当前库存',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 120,
|
||||
render: (v: number | null | undefined, g) =>
|
||||
v == null ? (
|
||||
'—'
|
||||
) : (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{v === 0 && <Text type="secondary">(售罄)</Text>}
|
||||
{g.in_goods_list === false && <Text type="secondary">(已下架)</Text>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '活动时间',
|
||||
key: 'window',
|
||||
width: 200,
|
||||
render: (_, g) =>
|
||||
g.missing ? '—' : `${formatTime(g.start_time)} ~ ${formatTime(g.end_time)}`,
|
||||
},
|
||||
];
|
||||
|
||||
/** 三个作用域的展示顺序(后端按 scope 分组返回)。 */
|
||||
const EXCHANGE_SCOPES: Array<{ key: string; label: string }> = [
|
||||
{ key: 'elite', label: '精英宝典' },
|
||||
{ key: 'esports', label: '电竞手册' },
|
||||
{ key: 'peace', label: '和平小店' },
|
||||
];
|
||||
|
||||
export default function AccountOverviewPanel({
|
||||
result,
|
||||
queriedAt,
|
||||
}: {
|
||||
result: AccountOverviewResult;
|
||||
/** 查询时间(优先用账号上记录的,回落到结果里的) */
|
||||
queriedAt?: string | null;
|
||||
}) {
|
||||
if (!result) return <Empty description="暂无结果" />;
|
||||
const errors = result.errors || {};
|
||||
const goods = result.high_value_goods || [];
|
||||
const records = result.exchange_records || [];
|
||||
const recordGroups = result.exchange_records || {};
|
||||
|
||||
const recordColumns: ColumnsType<Record<string, unknown>> = [
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'commodityName',
|
||||
key: 'commodityName',
|
||||
render: (v: unknown) => String(v ?? '-'),
|
||||
key: 'name',
|
||||
render: (_, row) => String(row.commodityName ?? row.commodity_name ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
title: '积分',
|
||||
key: 'score',
|
||||
width: 90,
|
||||
render: (_, row) => String(row.score ?? '-'),
|
||||
},
|
||||
{
|
||||
// 官方记录带**真实兑换时间**
|
||||
title: '兑换时间',
|
||||
key: 'time',
|
||||
width: 170,
|
||||
render: (_, row) =>
|
||||
formatTime(
|
||||
(row.createTime ?? row.create_time ?? row.time) as string | number | null,
|
||||
),
|
||||
formatTime((row.time ?? row.occurred_at) as string | number | null),
|
||||
},
|
||||
{
|
||||
title: '使用昵称',
|
||||
key: 'show',
|
||||
render: (_, row) => String(row.showName ?? row.show_name ?? '-'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -199,38 +238,47 @@ export default function AccountOverviewPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card size="small" title="🎯 高价值套装">
|
||||
{goods.length === 0 ? (
|
||||
<Text type="secondary">没有配置关注的高价值套装,或本次没取到</Text>
|
||||
) : (
|
||||
<Space wrap size={12}>
|
||||
{goods.map((g) => (
|
||||
<HighValueCard key={g.commodity_id || g.name} goods={g} />
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title={`兑换记录${result.exchange_record_count ? `(${result.exchange_record_count} 条)` : ''}`}
|
||||
>
|
||||
{records.length === 0 ? (
|
||||
<Text type="secondary">官方暂无兑换记录</Text>
|
||||
title="🎯 高价值套装(没兑换过才值钱)" styles={{ body: { padding: 0 } }}>
|
||||
{goods.length === 0 ? (
|
||||
<div style={{ padding: 12 }}>
|
||||
<Text type="secondary">没有配置关注的高价值套装,或本次没取到</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, i) => String(i)}
|
||||
columns={recordColumns}
|
||||
dataSource={records}
|
||||
pagination={{ pageSize: 10, size: 'small' }}
|
||||
rowKey={(g) => g.commodity_id || g.name}
|
||||
columns={goodsColumns}
|
||||
dataSource={goods}
|
||||
pagination={false}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
查询时间:{formatTime(queriedAt ?? result.queried_at)}
|
||||
</Text>
|
||||
{EXCHANGE_SCOPES.map(({ key, label }) => {
|
||||
const rows = recordGroups[key] ?? [];
|
||||
return (
|
||||
<Card
|
||||
key={key}
|
||||
size="small"
|
||||
title={`兑换记录 · ${label}${rows.length ? `(${rows.length} 条)` : ''}`}
|
||||
styles={{ body: { padding: rows.length ? 0 : 12 } }}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Text type="secondary">官方暂无{label}兑换记录</Text>
|
||||
) : (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, i) => String(i)}
|
||||
columns={recordColumns}
|
||||
dataSource={rows}
|
||||
pagination={{ pageSize: 10, size: 'small' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user