diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index 6eb4b14..d85479b 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -1,5 +1,6 @@ import api from './client'; import type { + AccountExchangeRecords, AccountEventPage, AccountItem, AccountBulkSelection, @@ -50,6 +51,9 @@ export const accountApi = { api.put(`/accounts/${id}/automation`, { auto_managed }), // 详情页:状态卡 + 统计 overview: (id: number) => api.get(`/accounts/${id}/overview`), + // 三个作用域的**官方**兑换记录(点列表那一格时按需拉取,不进列表响应) + exchangeRecords: (id: number) => + api.get(`/accounts/${id}/exchange-records`), // 时间线(倒序分页),event_type 为空表示全部 events: ( id: number, diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index ace8ecb..7e2465c 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; + total: number; + queried_at: string | null; +} diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index 2c9d54f..ec8d884 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -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 = { 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 {scope}{mark}; -} - /** 绑定状态 → 展示色(与后端 account_binding 的四个枚举对应)。 */ const BIND_TONES: Record = { bound: 'green', @@ -369,136 +356,19 @@ export default function AccountDetailPage() { - {/* ── 生命周期状态卡 ──────────────────────────────── - 这一屏要回答客服最关心的三个问题: - 1. 这账号现在能不能用?(状态 + CK) - 2. 要不要我处理?(原因 + 变更时间 + 自动化是否在管) - 3. 账上还有多少?(积分 / 鱼翅 / 兑换余额) */} + {/* ── 卡片 1:账号信息 ────────────────────────────── + 身份 + 状态 + 归属,一张表看完。**不重复展示资产**(见卡片 2)。 */} - - 高价值套装与兑换记录(一键查询) - - } - extra={ - - } - > - {ovError && ( - - {ovError} - - )} - {overview?.overview_snapshot ? ( - - ) : ( - - 实时向斗鱼查一次这个账号:鱼翅 / 积分 / 开通状态会**就地刷新到下面的账号信息**, - 这里只单独展示账号表里没有的两项 —— 「兔兔白日梦」「狂战萌兔」是否已获得, - 以及官方兑换记录。 - - )} - - - - - 生命周期状态 - - - {overview?.status_label || '—'} - - {overview?.auto_managed === false && 不参与自动化} - + + + {overview?.nickname || overview?.username || '—'} + - {formatTime(overview?.status_changed_at)} - {overview?.status_reason ? `(${overview.status_reason})` : ''} + #{overview?.id ?? '-'} · {overview?.username || '—'} - - - - CK - {overview?.has_cookie === false - ? 无 CK - : - {overview?.ck_check_status === 'invalid' ? '已失效' - : overview?.ck_check_status === 'valid' ? '正常' : '未检测'} - } - - 最近检测 {formatTime(overview?.stats?.last_ck_check_at)} - - - - - 角色绑定 - - - - - - - - - - - - {/* 人工操作:与工具栏的生命周期动作分开 —— 那些是「账号可用性」, - 这些是「归属与标注」。 */} - - 归属与标注 - - setTagDraft(e.target.value)} - onPressEnter={() => void handleSaveTag()} - /> - - - - ) : ( - - )} - - - - - - - {overview?.username || '—'} {overview?.status_label || '—'} @@ -506,19 +376,50 @@ export default function AccountDetailPage() { {overview?.auto_managed === false && 不参与自动化} } - style={{ marginBottom: 12 }} > - {overview?.nickname || '-'} {overview?.uid || '-'} - {formatTime(overview?.created_at)} {overview?.game_name || '-'} - {overview?.game_channel ? ({overview.game_channel}) : null} + {overview?.game_channel ? ( + ({overview.game_channel}) + ) : null} + + + {formatTime(overview?.created_at)} + + + + + + {formatTime(overview?.status_changed_at)} + {overview?.status_reason ? ( + ({overview.status_reason}) + ) : null} + + + + + + {overview?.has_cookie === false ? ( + 无 CK + ) : ( + + {overview?.ck_check_status === 'invalid' + ? '已失效' + : overview?.ck_check_status === 'valid' + ? '正常' + : '未检测'} + + )} + + {formatTime(stats?.last_ck_check_at)} + + - {/* 三列各管一个作用域,取值 unknown/unbound/pending/bound */} - + {/* 三个作用域各一列,取值 unknown/unbound/pending/bound */} + 精英:{overview?.role_bind_label || '未知'} @@ -530,6 +431,53 @@ export default function AccountDetailPage() { + + + setTagDraft(e.target.value)} + onPressEnter={() => void handleSaveTag()} + /> + + + + ) : ( + + {overview?.tag || '-'} + + + } + > + {ovError && ( + + {ovError} + + )} + {overview?.overview_snapshot ? ( + + ) : ( + + 点右上角「一键查询」实时向斗鱼查一次:鱼翅 / 积分 / 开通状态会就地刷新到上面的 + 「资产与统计」,这里只展示账号表里没有的两项 —— + 高价值套装是否已获得,以及官方兑换记录。 + + )} + + {scope}{mark}; } +/** 三个作用域的展示顺序与中文名(后端按 scope 分组返回)。 */ +const EXCHANGE_SCOPES: Array<{ key: string; label: string }> = [ + { key: 'elite', label: '精英宝典' }, + { key: 'esports', label: '电竞手册' }, + { key: 'peace', label: '和平小店' }, +]; + +const EXCHANGE_COLUMNS: TableProps['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(''); const [bindFilter, setBindFilter] = useState(''); const [searchText, setSearchText] = useState(''); + // 兑换记录预览:点列表那一格**按需拉取**(不进列表响应),弹窗按作用域分组 + const [exRecords, setExRecords] = useState(null); + const [exLoading, setExLoading] = useState(false); + const [exAccount, setExAccount] = useState(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([]); 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 ( + + 未查询 + + ); + } + if (used.length === 0) { + return 两个都可兑换; + } + return ( + + {used.map((n) => ( + + {n.replace(/^套装-/, '')} 已兑换 + + ))} + + ); + }, + }, + { + // 兑换记录:次数 + 最近一次。 + // + // 只反映**斗鱼官方记录**(后端聚合已按 source=official_sync 过滤)—— + // 我们当场记的那些留给详情页时间线,列表要的是"官方权威且完整"的口径。 + // 点这一格弹窗看全部(按作用域分组),不用进详情页。 title: '兑换记录', dataIndex: 'exchange_count', width: 170, render: (_, record: AccountItem) => { - if (!record.exchange_count) return 未兑换; + if (!record.exchange_count) { + return 未兑换; + } return ( - - {record.exchange_count} 次 - - {formatTimeShort(record.last_exchange_at)} - {/* 列的标题已经是「兑换记录」,事件 title 里的「兑换 」前缀在这儿是重复的 */} - {record.last_exchange_name - ? ` ${record.last_exchange_name.replace(/^兑换\s*/, '')}` - : ''} - - + +
void openExchangeRecords(record)} + onKeyDown={(event) => { + if (event.key === 'Enter') void openExchangeRecords(record); + }} + > + + {record.exchange_count} 次 + + {formatTimeShort(record.last_exchange_at)} + {/* 列标题已是「兑换记录」,事件 title 里的「兑换 」前缀在这儿重复 */} + {record.last_exchange_name + ? ` ${record.last_exchange_name.replace(/^兑换\s*/, '')}` + : ''} + + +
+
); }, }, @@ -730,6 +853,54 @@ export default function AccountsPage() { options={tags.map((t) => ({ value: t, label: t }))} /> + + + 官方兑换记录 + {exAccount && ( + + {exAccount.username} + + )} + + } + open={Boolean(exAccount)} + onCancel={() => setExAccount(null)} + footer={} + width={760} + styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }} + > + {exLoading ? ( + + ) : !exRecords || exRecords.total === 0 ? ( + + 没有官方兑换记录。若还没查过这个账号,先点工具栏「批量一键查询」。 + + ) : ( + + {EXCHANGE_SCOPES.map(({ key, label }) => { + const rows = exRecords.groups[key] ?? []; + if (rows.length === 0) return null; + return ( +
+ + {label}({rows.length} 条) + + `${row.commodity_id}-${row.occurred_at ?? ''}`} + columns={EXCHANGE_COLUMNS} + dataSource={rows} + pagination={false} + /> + + ); + })} + + )} + ); } diff --git a/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx b/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx index b65fad1..0796b9e 100644 --- a/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx +++ b/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx @@ -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>; + /** 按作用域分组:elite / esports / peace */ + exchange_records?: Record>>; exchange_record_count?: number; /** 单项失败:**必须展示**,否则「没查到」会被读成「没有」 */ errors?: Record; @@ -90,93 +91,131 @@ function formatTime(value: string | number | null | undefined): string { * 我们没法只凭它断言账号真的拿到了。所以写「不可再兑」并把 count 一起显示, * 让运营自己判断 —— 这比给一个可能错的结论好。 */ -function HighValueCard({ goods }: { goods: HighValueGoods }) { - if (goods.missing) { - return ( - - - {goods.name} - 未查到该商品 - - {goods.reason || '限兑列表和官方兑换记录里都没有它'} - - - - ); - } - // ⚠️ 「是否已获得」只看官方兑换记录。`count`/`store_status` 是**全局库存**, - // 同一时刻不同账号看到的都一样,**不能**用来判断这个账号有没有。 - const owned = goods.owned === true; - return ( - {goods.name}} - extra={ - {owned ? '✅ 已获得' : '未获得'} - } - > - - {owned && ( - - {(goods.obtained_names || []).join('、') || '-'} - - )} - {goods.score ?? '-'} - - {goods.count ?? '-'} - {goods.count === 0 ? '(已售罄)' : ''} - - - {formatTime(goods.start_time)} ~ {formatTime(goods.end_time)} - - - {!goods.in_goods_list && ( - - 当前限兑列表里没有它(活动窗口可能已结束),但兑换记录仍在。 - - )} - - ); +/** + * 高价值套装的展示状态。 + * + * ⚠️ **颜色是「价值」口径,不是「有没有」口径** —— 别标反: + * 没兑换过 → 「可兑换」**绿色**(这个账号还值钱,能用来绑定兑换) + * 兑换过 → 「已兑换」**橙色**(价值降低) + * 同一件东西两个都兑换过,账号价值最低。 + * + * 「是否已获得」只看**官方兑换记录**:`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 = [ + { + title: '高价值套装', + dataIndex: 'name', + key: 'name', + width: 200, + render: (name: string, g) => + g.missing ? ( + + {name} + + ) : ( + {name} + ), + }, + { + title: '可兑换 / 已兑换', + key: 'owned', + width: 130, + render: (_, g) => { + const state = goodsState(g); + return {state.text}; + }, + }, + { + title: '兑换记录里的名字', + key: 'obtained', + render: (_, g) => + g.obtained_names?.length ? ( + {g.obtained_names.join('、')} + ) : ( + — + ), + }, + { + 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 ? ( + '—' + ) : ( + + {v} + {v === 0 && (售罄)} + {g.in_goods_list === false && (已下架)} + + ), + }, + { + 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 ; const errors = result.errors || {}; const goods = result.high_value_goods || []; - const records = result.exchange_records || []; + const recordGroups = result.exchange_records || {}; const recordColumns: ColumnsType> = [ { 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({ /> )} - - {goods.length === 0 ? ( - 没有配置关注的高价值套装,或本次没取到 - ) : ( - - {goods.map((g) => ( - - ))} - - )} - - - {records.length === 0 ? ( - 官方暂无兑换记录 + title="🎯 高价值套装(没兑换过才值钱)" styles={{ body: { padding: 0 } }}> + {goods.length === 0 ? ( +
+ 没有配置关注的高价值套装,或本次没取到 +
) : (
String(i)} - columns={recordColumns} - dataSource={records} - pagination={{ pageSize: 10, size: 'small' }} + rowKey={(g) => g.commodity_id || g.name} + columns={goodsColumns} + dataSource={goods} + pagination={false} /> )} - - 查询时间:{formatTime(queriedAt ?? result.queried_at)} - + {EXCHANGE_SCOPES.map(({ key, label }) => { + const rows = recordGroups[key] ?? []; + return ( + + {rows.length === 0 ? ( + 官方暂无{label}兑换记录 + ) : ( +
String(i)} + columns={recordColumns} + dataSource={rows} + pagination={{ pageSize: 10, size: 'small' }} + /> + )} + + ); + })} ); }