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:
yml2213
2026-10-04 20:03:52 +08:00
parent 62d9053e8a
commit f416397cc8
5 changed files with 563 additions and 304 deletions
+4
View File
@@ -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,
+23
View File
@@ -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;
}
+205 -192
View File
@@ -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={
+189 -18
View File
@@ -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>
);
}