feat(account): 一键查询展示「角色绑定明细」—— 不只一个"已绑定"

用户看到绑定状态后问:「是不是在详情中增加详细的绑定信息啥的」。

`bind_info` 的返回里本来就有明细(`_parse_bind_info` 会解析出来),
之前只取了 `role_name` 一项。现在把运营真正要看的都带上:

- **角色名**(`role_name`)
- **区服 / 平台**(`area_name` / `plat_name`)
- 换绑冷却(`change_role_wait_time`)与可换绑时间(`can_change_time`)

实测(账号 61):

  精英: 柿子洋音 / 微信 / 安卓(android)
  电竞: 资跳 / 微信 / 苹果(iOS),可换绑 1788624000

面板新增「🎮 角色绑定明细」卡片,三个作用域并排;
冷却时间格式化成"无 / x 小时 / x 天 y 小时",
时间戳过 `formatUnixSeconds` 那套(`0`/空显示 `-`,不显示 1970)。

## 验证(本地)

TZ=Asia/Shanghai **615 passed / 6 skipped**;ruff 全绿;前端 build 通过。
This commit is contained in:
yml2213
2026-10-04 20:26:31 +08:00
parent 6b5271c983
commit b199428619
2 changed files with 71 additions and 1 deletions
@@ -452,9 +452,16 @@ class OverviewMixin:
try:
info = self._fetch_bind_info(client, scope, alias)
derived = derive_from_bind_info(info)
# 把**明细**也带上:运营要看的是"绑的哪个角色、哪个区服、
# 什么时候能换绑",不是一个"已绑定"就完事。
overview["bind"][scope] = {
"derived": derived,
"role_name": str(info.get("role_name") or info.get("role") or ""),
"area_name": str(info.get("area_name") or ""),
"plat_name": str(info.get("plat_name") or ""),
"nickname": str(info.get("nickname") or ""),
"change_role_wait_time": _ts(info.get("change_role_wait_time")),
"can_change_time": _ts(info.get("can_change_time")),
}
if derived is not None:
# 唯一写入者;推不出来(None)时**不动原值** —— 宁可留 unknown,
@@ -1,4 +1,4 @@
import { Alert, Card, Empty, Space, Table, Tag, Tooltip, Typography } from 'antd';
import { Alert, Card, Col, Empty, Row, Space, Table, Tag, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
const { Text } = Typography;
@@ -45,6 +45,19 @@ export interface AccountOverviewResult {
/** 按作用域分组:elite / esports / peace */
exchange_records?: Record<string, Array<Record<string, unknown>>>;
exchange_record_count?: number;
/** 三个作用域的绑定明细(一键查询会真的去查,以前从不查 → 97% 显示未知) */
bind?: Record<
string,
{
derived?: string | null;
role_name?: string;
area_name?: string;
plat_name?: string;
nickname?: string;
change_role_wait_time?: number | null;
can_change_time?: number | null;
}
>;
/** 单项失败:**必须展示**,否则「没查到」会被读成「没有」 */
errors?: Record<string, string>;
}
@@ -182,6 +195,22 @@ const EXCHANGE_SCOPES: Array<{ key: string; label: string }> = [
{ key: 'peace', label: '和平小店' },
];
/** 三个作用域的展示顺序(与后端 bind 的 key 对应)。 */
const BIND_SCOPES: Array<{ key: string; label: string }> = [
{ key: 'elite', label: '精英宝典' },
{ key: 'esports', label: '电竞手册' },
{ key: 'peace', label: '和平小店' },
];
/** 换绑冷却(秒)→ 可读;0 / 空表示没有冷却。 */
function formatWait(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '无';
const hours = Math.floor(seconds / 3600);
if (hours >= 24) return `${Math.floor(hours / 24)} 天 ${hours % 24} 小时`;
if (hours >= 1) return `${hours} 小时`;
return `${Math.floor(seconds / 60)} 分钟`;
}
export default function AccountOverviewPanel({
result,
}: {
@@ -256,6 +285,40 @@ export default function AccountOverviewPanel({
)}
</Card>
<Card size="small" title="🎮 角色绑定明细">
<Row gutter={[16, 12]}>
{BIND_SCOPES.map(({ key, label }) => {
const info = (result.bind ?? {})[key];
if (!info) {
return (
<Col span={8} key={key}>
<Text type="secondary">{label}:未查询</Text>
</Col>
);
}
return (
<Col span={8} key={key}>
<Space direction="vertical" size={2}>
<Text strong>{label}</Text>
<Text>
{info.role_name ? info.role_name : '(未绑定角色)'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{[info.plat_name, info.area_name].filter(Boolean).join(' · ') || '-'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
换绑冷却 {formatWait(info.change_role_wait_time)}
{info.can_change_time
? ` · 可换绑 ${formatTime(info.can_change_time)}`
: ''}
</Text>
</Space>
</Col>
);
})}
</Row>
</Card>
{EXCHANGE_SCOPES.map(({ key, label }) => {
const rows = recordGroups[key] ?? [];
return (