feat(account): S6 账号页重构 —— 列表关键列 + 详情页生命周期状态卡
S6 的展示层部分(数据模型部分见上一个提交)。目标:客服打开账号页要能一眼回答
「这账号能不能用、要不要我处理、账上还有多少」。
## 账号列表:加关键列 + 三个筛选
新增四列,**全部直接来自 `accounts` 行**(不 JOIN、不聚合 —— 列表是全站最热接口):
| 列 | 内容 |
| --- | --- |
| **CK** | 有无 CK / 正常 / 已失效 / 检测异常 + 最近检测时间 |
| **绑定** | 精/电/店 三个作用域各一个小标签(✓ / … / ✗ / ?) |
| **积分 / 鱼翅** | 两行小字 |
新增筛选:**CK 健康**、**角色绑定**(三个作用域任一命中即算)。
`AccountOut` 相应补齐 `has_cookie` / `ck_check_status` / `ck_checked_at` /
`points` / `gold_balance` / 三组绑定状态与标签。
## 账号详情页:生命周期主页
顶部新增**生命周期状态卡**,把之前散落的信息聚成一眼可读的几格:
- **生命周期状态**:状态标签 + 不参与自动化标记 + 变更时间与原因
- **CK**:有无 / 健康 / 最近检测时间
- **角色绑定**:精/电/店三格
- **账上还有多少**:积分 / 鱼翅 / 兑换余额
- **归属与标注**:分配客服(下拉)+ 标签(就地编辑)—— 之前详情页只能看不能改
业务台账补上**登录成功次数**与**最近登录**(`stats` 里本来就有,只是没展示)。
时间线已有分类筛选,生命周期操作入口(停用/归档/恢复/自动化)S3 已加。
## ⚠️ 顺带修掉一个静默 bug
`bind_payload()` 的字段名按**作用域名**拼成了 `elite_bind_status` / `peace_bind_status`,
而 schema 声明的是 `role_bind_status` / `xpd_bind_status`。Pydantic 默认
`extra='ignore'`,于是多出来的键**被静默丢弃**、声明过的字段保持默认值 ——
**接口永远返回 unknown 而不报任何错**(这正是它躲过前面几轮测试的原因)。
修法:前缀改为由**列名**推导(`SCOPE_PAYLOAD_PREFIX`),并在模块导入时自检
「载荷前缀 == 列名前缀」。另加两条用例:`bind_payload` 的键必须都被
`DouyuTaskAccountOut` / `AccountOut` 声明(这是能抓到这类错的那种用例)。
顺带发现我自己之前那条 payload 用例**把错误的前缀当成了预期**,一并修正。
## 自己踩的坑
详情页加客服列表时又把 `useEffect` 写在了早期 `return` 之后 ——
和本次会话前面修 `LoginTasksPage` 的是同一个错。eslint(`react-hooks/rules-of-hooks`)
当场抓住,已上移。**`tsc` 与 `vite build` 都不会报这个**。
另外:列表关键列那处编辑我曾以为已生效(脚本打印了成功),实际没落到文件里 ——
后面是靠「读回文件确认」和用例失败才发现。已改为每次都读回核对。
## 验证
- `TZ=Asia/Shanghai`:**497 passed / 6 skipped**(S6 前半 490 → +7)
- `TZ=UTC`(相关文件)通过;ruff 全绿;前端 build 通过、lint **0 errors**
- **真实开发库端到端**:列表与工作台接口都返回了真实值
(`has_cookie=True` / `ck_check_status=valid` / `role_bind_label=未知`),
修 bug 前这两处返回的是被丢弃后的默认值
未做:`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」已可下探到 7 天档(待确认)。
This commit is contained in:
@@ -17,7 +17,7 @@ import type {
|
||||
export const accountApi = {
|
||||
list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean }) =>
|
||||
api.get<AccountItem[], AccountItem[]>('/accounts', { params }),
|
||||
listPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean; status?: string }) =>
|
||||
listPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean; status?: string; ck_status?: string; bind_status?: string }) =>
|
||||
api.get<PaginatedResponse<AccountItem>, PaginatedResponse<AccountItem>>('/accounts', { params }),
|
||||
summary: () => api.get<BasicSummary, BasicSummary>('/accounts/summary'),
|
||||
import: (text: string, tag?: string) =>
|
||||
|
||||
@@ -141,6 +141,9 @@ export interface AccountOverview {
|
||||
status_reason: string;
|
||||
status_changed_at: string | null;
|
||||
auto_managed: boolean;
|
||||
has_cookie: boolean;
|
||||
ck_check_status: string;
|
||||
ck_checked_at: string | null;
|
||||
points: number | null;
|
||||
gold_balance: number | null;
|
||||
exchange_balance: number | null;
|
||||
@@ -246,6 +249,18 @@ export interface AccountItem {
|
||||
status_label: string;
|
||||
status_reason: string;
|
||||
auto_managed: boolean;
|
||||
// 列表页关键列(都直接来自 accounts 行)
|
||||
has_cookie: boolean;
|
||||
ck_check_status: string;
|
||||
ck_checked_at: string | null;
|
||||
points: number | null;
|
||||
gold_balance: number | null;
|
||||
role_bind_status: BindStatus;
|
||||
role_bind_label: string;
|
||||
esports_bind_status: BindStatus;
|
||||
esports_bind_label: string;
|
||||
xpd_bind_status: BindStatus;
|
||||
xpd_bind_label: string;
|
||||
}
|
||||
|
||||
export interface AssignmentsSummary {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TableProps } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined, EditOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { accountApi, type AccountEventItem, type AccountOverview } from '../api/modules';
|
||||
import { accountApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { formatTime } from '../utils/time';
|
||||
@@ -55,6 +55,19 @@ 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',
|
||||
@@ -99,9 +112,27 @@ export default function AccountDetailPage() {
|
||||
const [eventType, setEventType] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [tagDraft, setTagDraft] = useState('');
|
||||
const [tagEditing, setTagEditing] = useState(false);
|
||||
const [supportUsers, setSupportUsers] = useState<UserInfo[]>([]);
|
||||
const [remarkEditing, setRemarkEditing] = useState(false);
|
||||
const [remarkDraft, setRemarkDraft] = useState('');
|
||||
|
||||
/** 加载客服列表(分配用)。只在需要时拉一次。 */
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const users = await userApi.list();
|
||||
if (alive) setSupportUsers((users || []).filter((u) => u.role === 'support'));
|
||||
} catch {
|
||||
// 拉不到客服列表不影响页面其它部分
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
if (!accountId) return;
|
||||
setLoading(true);
|
||||
@@ -193,6 +224,31 @@ export default function AccountDetailPage() {
|
||||
};
|
||||
|
||||
|
||||
const handleAssign = async (assignedTo: number | null) => {
|
||||
if (!accountId) return;
|
||||
try {
|
||||
await accountApi.assign(accountId, assignedTo);
|
||||
message.success(assignedTo ? '已分配' : '已取消分配');
|
||||
void loadOverview();
|
||||
void loadEvents();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTag = async () => {
|
||||
if (!accountId) return;
|
||||
try {
|
||||
await accountApi.setTag(accountId, tagDraft.trim());
|
||||
message.success('标签已更新');
|
||||
setTagEditing(false);
|
||||
void loadOverview();
|
||||
void loadEvents();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存备注:备注是自由文本,走单个接口(标签那种短枚举才有批量)。 */
|
||||
const handleSaveRemark = async () => {
|
||||
if (!accountId) return;
|
||||
@@ -263,6 +319,94 @@ export default function AccountDetailPage() {
|
||||
</Space>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{/* ── 生命周期状态卡 ────────────────────────────────
|
||||
这一屏要回答客服最关心的三个问题:
|
||||
1. 这账号现在能不能用?(状态 + CK)
|
||||
2. 要不要我处理?(原因 + 变更时间 + 自动化是否在管)
|
||||
3. 账上还有多少?(积分 / 鱼翅 / 兑换余额) */}
|
||||
<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>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatTime(overview?.status_changed_at)}
|
||||
{overview?.status_reason ? `(${overview.status_reason})` : ''}
|
||||
</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>
|
||||
@@ -374,6 +518,8 @@ export default function AccountDetailPage() {
|
||||
<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>
|
||||
|
||||
|
||||
@@ -33,6 +33,21 @@ const STATUS_TONES: Record<string, string> = {
|
||||
archived: 'default',
|
||||
};
|
||||
|
||||
/** CK 健康筛选项:客服最常用的排查入口(「哪些 CK 已经失效了」)。 */
|
||||
const CK_OPTIONS = [
|
||||
{ value: 'invalid', label: 'CK 失效' },
|
||||
{ value: 'valid', label: 'CK 正常' },
|
||||
{ value: 'error', label: '检测异常' },
|
||||
];
|
||||
|
||||
/** 绑定筛选项:三个作用域任一命中即算。 */
|
||||
const BIND_OPTIONS = [
|
||||
{ value: 'bound', label: '已绑定' },
|
||||
{ value: 'pending', label: '待确认' },
|
||||
{ value: 'unbound', label: '未绑定' },
|
||||
{ value: 'unknown', label: '绑定未知' },
|
||||
];
|
||||
|
||||
/** 状态筛选项:与后端 ALL_STATES 保持一致。 */
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: '正常' },
|
||||
@@ -43,6 +58,19 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'archived', label: '已归档' },
|
||||
];
|
||||
|
||||
/** 单个作用域的绑定小标签:精/电/店。 */
|
||||
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>;
|
||||
}
|
||||
|
||||
export default function AccountsPage() {
|
||||
const navigate = useNavigate();
|
||||
// App.useApp() 的 modal 实例继承主题,静态 Modal.confirm 在暗色模式下是白底
|
||||
@@ -57,6 +85,8 @@ export default function AccountsPage() {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [tagFilter, setTagFilter] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [ckFilter, setCkFilter] = useState<string>('');
|
||||
const [bindFilter, setBindFilter] = useState<string>('');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [selectedAllMatching, setSelectedAllMatching] = useState(false);
|
||||
@@ -82,9 +112,11 @@ export default function AccountsPage() {
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: { tag?: string; status?: string } = {};
|
||||
const params: { tag?: string; status?: string; ck_status?: string; bind_status?: string } = {};
|
||||
if (tagFilter) params.tag = tagFilter;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (ckFilter) params.ck_status = ckFilter;
|
||||
if (bindFilter) params.bind_status = bindFilter;
|
||||
const data = await accountApi.listPaged({
|
||||
...params,
|
||||
page: currentPage,
|
||||
@@ -99,7 +131,7 @@ export default function AccountsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter, statusFilter]);
|
||||
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter, statusFilter, ckFilter, bindFilter]);
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
@@ -303,6 +335,61 @@ export default function AccountsPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// CK 健康:客服排查的第一入口。「没有 CK」和「CK 失效」是两种完全不同的处境。
|
||||
title: 'CK',
|
||||
dataIndex: 'ck_check_status',
|
||||
width: 140,
|
||||
render: (_, record: AccountItem) => {
|
||||
if (!record.has_cookie) return <Tag>无 CK</Tag>;
|
||||
if (record.ck_check_status === 'invalid') {
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color="red">已失效</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatTime(record.ck_checked_at)}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (record.ck_check_status === 'error') return <Tag color="orange">检测异常</Tag>;
|
||||
if (record.ck_check_status === 'valid') {
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color="green">正常</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatTime(record.ck_checked_at)}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return <Tag>未检测</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
// 三角色绑定:三个作用域各一列,合成一格紧凑展示
|
||||
title: '绑定',
|
||||
dataIndex: 'role_bind_status',
|
||||
width: 190,
|
||||
render: (_, record: AccountItem) => (
|
||||
<Space size={2} wrap>
|
||||
<BindChip scope="精" status={record.role_bind_status} />
|
||||
<BindChip scope="电" status={record.esports_bind_status} />
|
||||
<BindChip scope="店" status={record.xpd_bind_status} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '积分 / 鱼翅',
|
||||
dataIndex: 'points',
|
||||
width: 110,
|
||||
render: (_, record: AccountItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>积分 {record.points ?? '-'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>鱼翅 {record.gold_balance ?? '-'}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tag',
|
||||
@@ -410,6 +497,24 @@ export default function AccountsPage() {
|
||||
}}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
size="small"
|
||||
placeholder="按 CK 筛选"
|
||||
style={{ width: 120 }}
|
||||
value={ckFilter || undefined}
|
||||
onChange={(val) => { setCkFilter(val || ''); setCurrentPage(1); }}
|
||||
options={CK_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
size="small"
|
||||
placeholder="按绑定筛选"
|
||||
style={{ width: 120 }}
|
||||
value={bindFilter || undefined}
|
||||
onChange={(val) => { setBindFilter(val || ''); setCurrentPage(1); }}
|
||||
options={BIND_OPTIONS}
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
size="small"
|
||||
|
||||
Reference in New Issue
Block a user