feat(onboarding): 删「按用户强制引导」UI + 新增设备管理页

- users 页删 force_onboarding 操作 + types 字段
- 新增设备管理页(/devices): 按设备列表 + 行内「重走引导」+「全部重设」危险操作 + 菜单项

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:51:51 +08:00
parent c66d90a6e1
commit 2eff4a71be
4 changed files with 108 additions and 33 deletions
+99
View File
@@ -0,0 +1,99 @@
'use client';
import type { ColumnsType } from 'antd/es/table';
import { Button, Modal, Popconfirm, Space, Table, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { useCursorList } from '@/lib/useCursorList';
import type { DeviceOnboardingItem } from '@/lib/types';
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
const EMPTY: Record<string, unknown> = {};
export default function DevicesPage() {
// 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。
const { items, loading, reload } = useCursorList<DeviceOnboardingItem>(
'/admin/api/onboarding/devices',
EMPTY,
);
const canReset = canDo(['operator']);
const resetDevice = async (d: DeviceOnboardingItem) => {
try {
await api.post(`/admin/api/onboarding/devices/${encodeURIComponent(d.device_id)}/reset`);
message.success('已重置:该设备所有账号下次打开 App 重走引导');
reload();
} catch (e) {
message.error(errMsg(e));
}
};
const resetAll = () => {
Modal.confirm({
title: '确认全部重设新手引导?',
content:
'清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。',
okText: '确认全部重设',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await api.post('/admin/api/onboarding/reset-all', { confirm: true });
message.success('已全部重设:所有用户下次打开 App 会重走引导');
reload();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const columns: ColumnsType<DeviceOnboardingItem> = [
{ title: '设备(ANDROID_ID)', dataIndex: 'device_id' },
{ title: '走过引导的账号数', dataIndex: 'account_count', width: 160 },
{ title: '最近完成引导', dataIndex: 'last_completed_at', render: dt, width: 200 },
{
title: '操作',
key: 'op',
width: 120,
render: (_: unknown, d: DeviceOnboardingItem) =>
canReset ? (
<Popconfirm
title="重走引导?"
description="该设备上所有账号下次打开 App 会重看一次。"
onConfirm={() => resetDevice(d)}
okText="确认"
cancelText="取消"
>
<a></a>
</Popconfirm>
) : (
<span style={{ color: '#ccc' }}>-</span>
),
},
];
return (
<div>
<h2></h2>
<p style={{ color: '#888', marginBottom: 16 }}>
( ANDROID_ID ),
使 App ,
</p>
{canReset && (
<Space style={{ marginBottom: 16 }}>
<Button type="primary" danger onClick={resetAll}>
</Button>
</Space>
)}
<Table
rowKey="device_id"
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
/>
</div>
);
}
+2
View File
@@ -9,6 +9,7 @@ import {
FundProjectionScreenOutlined,
LogoutOutlined,
MessageOutlined,
MobileOutlined,
MoneyCollectOutlined,
SettingOutlined,
TeamOutlined,
@@ -23,6 +24,7 @@ const { Sider, Header, Content } = Layout;
const MENU = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
-31
View File
@@ -123,28 +123,6 @@ export default function UsersPage() {
});
};
// 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 App 重看一遍,看完后端自动恢复(只触发这一次)。
// 用户尚未重看前可「撤销」(把 force_onboarding 置回 false),纠正误点。
const setForceOnboarding = (u: UserListItem, enable: boolean) => {
Modal.confirm({
title: enable
? `确认让用户 ${u.phone} 重看一次新手引导?`
: `撤销用户 ${u.phone} 的「重看新手引导」?`,
content: enable
? '点一下即把该用户标记为「未看过引导」:Ta 下次打开 App 会重走一遍新手引导,看完自动恢复——只触发这一次。仅对已支持该功能的 App 版本生效。'
: '该用户尚未重看,撤销后下次打开 App 不再触发。',
onOk: async () => {
try {
await api.post(`/admin/api/users/${u.id}/force-onboarding`, { enabled: enable });
message.success(enable ? '已开启:该用户下次打开 App 会重看一次' : '已撤销');
reload();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const columns: ColumnsType<UserListItem> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '手机号', dataIndex: 'phone' },
@@ -177,15 +155,6 @@ export default function UsersPage() {
{u.debug_trace_enabled ? '关调试链接' : '开调试链接'}
</a>
)}
{canStatus &&
u.status !== 'deleted' &&
(u.force_onboarding ? (
<span style={{ color: '#fa8c16' }}>
<a onClick={() => setForceOnboarding(u, false)}></a>
</span>
) : (
<a onClick={() => setForceOnboarding(u, true)}></a>
))}
</Space>
),
},
+7 -2
View File
@@ -29,13 +29,18 @@ export interface UserListItem {
status: string;
// 调试链接权限:开了的用户在 App 比价结果页/记录页可复制 price.shaguabijia.com 的 trace 调试链接
debug_trace_enabled: boolean;
// 运营「一键开启新手引导」:true = 该用户下次打开 App 会被强制重走新手引导,走完自动取消
force_onboarding: boolean;
wechat_openid: string | null;
created_at: string;
last_login_at: string;
}
// 设备维度新手引导:一台设备(ANDROID_ID)聚合,走过引导的账号数 + 最近完成时间。
export interface DeviceOnboardingItem {
device_id: string;
account_count: number;
last_completed_at: string;
}
export interface UserOverview {
user: UserListItem;
coin_balance: number;