import { useCallback, useEffect, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Alert, Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; type UserOrderRow = { id: string; orderNo: string; status: string; payAmount: number; payStatus?: string; createdAt: string; }; /** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */ function fmtBenefit(v: number | null | undefined) { const n = Number(v ?? 0); if (!Number.isFinite(n) || n <= 0) return '—'; return `¥${n.toFixed(2)}`; } type UserBehaviorLog = { id: string; eventName: string; clientApp?: string | null; createdAt: string; extraJson?: Record | null; }; type UserDetail = AdminUserRow & { wxUnionId?: string | null; cityPref?: Record | null; mergedInto?: { id: string; userNo: string; phone: string | null; nickname: string | null } | null; sourcePromo?: { id: string; code: string; name: string } | null; orders?: UserOrderRow[]; mergedFromCount?: number; addressCount?: number; }; type BatchDeletePreviewItem = { id: string; userNo: string; nickname: string | null; phone: string | null; hasRisk: boolean; unfinishedOrders: UserOrderRow[]; redeemRecords: Array<{ id: string; redeemNo: string; amount: number; createdAt: string; payoutStatus: string | null; }>; }; type BatchDeletePreview = { items: BatchDeletePreviewItem[]; hasRisk: boolean; total: number; }; export default function UsersPage() { const navigate = useNavigate(); const location = useLocation(); const [form] = Form.useForm(); const [profile, setProfile] = useState(null); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [detail, setDetail] = useState(null); const [behaviorLogs, setBehaviorLogs] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(''); const [deleting, setDeleting] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchDeleteOpen, setBatchDeleteOpen] = useState(false); const [batchDeleteStep, setBatchDeleteStep] = useState<1 | 2>(1); const [batchPreview, setBatchPreview] = useState(null); const [batchPreviewLoading, setBatchPreviewLoading] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false); const [batchRiskAck, setBatchRiskAck] = useState(false); const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete'); useEffect(() => { request('/admin/auth/me').then(setProfile).catch(() => {}); }, []); const load = useCallback(async () => { setLoading(true); try { const values = form.getFieldsValue(); const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); if (values.phone) qs.set('phone', values.phone); if (values.userNo) qs.set('userNo', values.userNo); if (values.deviceKey) qs.set('deviceKey', values.deviceKey); if (values.phoneVerified !== undefined && values.phoneVerified !== '') { qs.set('phoneVerified', values.phoneVerified); } if (values.status !== undefined && values.status !== '') { qs.set('status', String(values.status)); } if (values.excludeTest) qs.set('excludeTest', 'true'); const res = await request>(`/admin/users?${qs}`); setData(res); } finally { setLoading(false); } }, [form, page, pageSize]); useEffect(() => { void load(); }, [load]); useEffect(() => { const openUserId = (location.state as { openUserId?: string } | null)?.openUserId; if (openUserId) { void openDetail(openUserId); navigate(location.pathname, { replace: true, state: null }); } }, [location.state, location.pathname, navigate]); async function openDetail(id: string) { const [res, logs] = await Promise.all([ request(`/admin/users/${id}`), request<{ items: UserBehaviorLog[] }>(`/admin/logs/users?userId=${id}&pageSize=50`).catch( () => ({ items: [] as UserBehaviorLog[] }), ), ]); setDetail(res); setBehaviorLogs(logs.items ?? []); setDrawerOpen(true); } function openDeleteModal() { setDeleteConfirm(''); setDeleteOpen(true); } async function confirmDelete() { if (!detail) return; if (deleteConfirm !== detail.userNo) { message.error('请输入正确的用户编号以确认删除'); return; } setDeleting(true); try { await request(`/admin/users/${detail.id}`, { method: 'DELETE' }); message.success('用户已删除(行为日志已保留)'); setDeleteOpen(false); setDrawerOpen(false); setDetail(null); setSelectedRowKeys((keys) => keys.filter((k) => k !== detail.id)); void load(); } catch (e) { message.error(e instanceof Error ? e.message : '删除失败'); } finally { setDeleting(false); } } async function openBatchDeleteModal() { if (!selectedRowKeys.length) return; setBatchDeleteStep(1); setBatchRiskAck(false); setBatchPreview(null); setBatchDeleteOpen(true); setBatchPreviewLoading(true); try { const res = await request('/admin/users/batch-delete/preview', { method: 'POST', body: JSON.stringify({ ids: selectedRowKeys }), }); setBatchPreview(res); } catch (e) { message.error(e instanceof Error ? e.message : '预检失败'); setBatchDeleteOpen(false); } finally { setBatchPreviewLoading(false); } } async function confirmBatchDelete(confirmRisk: boolean) { if (!selectedRowKeys.length) return; setBatchDeleting(true); try { const res = await request<{ deleted: number; message: string }>('/admin/users/batch-delete', { method: 'POST', body: JSON.stringify({ ids: selectedRowKeys, confirmRisk }), }); message.success(res.message || `已删除 ${res.deleted} 名用户`); setBatchDeleteOpen(false); setBatchPreview(null); if (detail && selectedRowKeys.includes(detail.id)) { setDrawerOpen(false); setDetail(null); } setSelectedRowKeys([]); void load(); } catch (e) { message.error(e instanceof Error ? e.message : '批量删除失败'); } finally { setBatchDeleting(false); } } function handleBatchDeleteOk() { if (!batchPreview) return; if (batchPreview.hasRisk && batchDeleteStep === 1) { setBatchDeleteStep(2); return; } void confirmBatchDelete(batchPreview.hasRisk); } const orderColumns: ColumnsType = [ { title: '订单号', dataIndex: 'orderNo', width: 170 }, { title: '状态', dataIndex: 'status', width: 100, render: (s) => {ORDER_STATUS_LABELS[s] || s}, }, { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, { title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, ]; const columns: ColumnsType = [ { title: '用户编号', dataIndex: 'userNo', width: 140, render: (v, row) => ( {v} {row.isTest ? 测试 : null} ), }, { title: '昵称', dataIndex: 'nickname', width: 100 }, { title: '手机', dataIndex: 'phone', width: 120, render: (v) => maskPhone(v), }, { title: '验手机', dataIndex: 'phoneVerifiedAt', width: 90, render: (v) => (v ? 已验证 : 访客), }, { title: '微信', dataIndex: 'wechatVerified', width: 100, render: (v) => (v ? 微信已验证 : 未绑定), }, { title: '来源类型', dataIndex: 'sourceType', width: 100, render: (v: string) => ( {USER_SOURCE_TYPE_LABELS[v as UserSourceType] || v} ), }, { title: '来源 ID', dataIndex: 'sourceRefId', width: 100, ellipsis: true, render: (v, row) => { if (!v) return '—'; if (row.sourceType === 'PROMO_CODE') { return ( e.stopPropagation()}> {v} ); } return v; }, }, { title: '来源标签', dataIndex: 'sourceLabel', width: 120, ellipsis: true, render: (v) => v || '—', }, { title: 'deviceKey', dataIndex: 'deviceKey', ellipsis: true, render: (v) => v || '—', }, { title: '合并', dataIndex: 'mergedIntoUserId', width: 80, render: (v) => (v ? 已合并 : '—'), }, { title: '订单数', dataIndex: 'orderCount', width: 80 }, { title: '剩余权益', dataIndex: 'benefitBalance', width: 110, align: 'right', sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0), render: (v: number | undefined) => Number(v ?? 0) > 0 ? ( {fmtBenefit(v)} ) : ( ), }, { title: '已用权益', dataIndex: 'benefitUsedAmount', width: 110, align: 'right', sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0), render: (v: number | undefined) => ( {fmtBenefit(v)} ), }, { title: '累计权益', dataIndex: 'benefitTotalAmount', width: 110, align: 'right', sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0), render: (v: number | undefined) => fmtBenefit(v), }, { title: '注册时间', dataIndex: 'createdAt', width: 170, render: (v) => new Date(v).toLocaleString('zh-CN'), }, { title: '操作', width: 140, render: (_, row) => ( ), }, ]; return (
用户监控 {canDeleteUsers ? ( ) : null}
{ setPage(1); void load(); }}> 过滤测试账号
setSelectedRowKeys(keys as string[]), } : undefined} pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); }, }} /> setDrawerOpen(false)} extra={detail && canDeleteUsers ? ( ) : undefined} > {detail && ( <> {detail.id} {detail.userNo} {detail.nickname || '—'} {maskPhone(detail.phone)} {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} {detail.wechatVerified ? 微信已验证 : 未绑定} {USER_SOURCE_TYPE_LABELS[detail.sourceType as UserSourceType] || detail.sourceType} {detail.sourceRefId ? ( detail.sourceType === 'PROMO_CODE' ? ( {detail.sourceRefId} ) : ( detail.sourceRefId ) ) : '—'} {detail.sourceLabel || '—'} {detail.sourcePromo && ( {detail.sourcePromo.name}({detail.sourcePromo.code}) )} {detail.wxOpenId || '—'} {detail.wxUnionId || '—'} {detail.deviceKey || '—'} {detail.mergedInto ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})` : '—'} {detail.mergedFromCount ?? 0} {detail.orderCount} / {detail.addressCount} 剩余{' '} {fmtBenefit(detail.benefitBalance)} 已用 {fmtBenefit(detail.benefitUsedAmount)} 累计 {fmtBenefit(detail.benefitTotalAmount)} {new Date(detail.createdAt).toLocaleString('zh-CN')} 行为时间线(最近 {behaviorLogs.length} 条)
fmtTime(v) }, { title: '分类', width: 100, render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—', }, { title: '事件', dataIndex: 'eventName' }, { title: '端', dataIndex: 'clientApp', width: 90 }, ]} /> 全部订单({detail.orders?.length ?? 0})
)} void confirmDelete()} onCancel={() => setDeleteOpen(false)} width={720} destroyOnClose > {detail && ( <> 将删除用户 {detail.userNo} 及其地址、订单、权益券、核销记录等业务数据。
用户行为日志(埋点)与第三方调用日志将保留,不随用户删除。 )} /> 关联订单({detail.orders?.length ?? 0} 笔)
请输入用户编号 {detail.userNo} 以确认删除: setDeleteConfirm(e.target.value)} /> )} handleBatchDeleteOk()} onCancel={() => { if (batchDeleteStep === 2) { setBatchDeleteStep(1); setBatchRiskAck(false); return; } setBatchDeleteOpen(false); }} width={800} destroyOnClose > {batchPreviewLoading && ( 正在检查关联订单与核销记录… )} {!batchPreviewLoading && batchPreview && batchDeleteStep === 1 && ( <>
row.hasRisk, expandedRowRender: (row) => (
{row.unfinishedOrders.length > 0 && ( <> 未完成订单({row.unfinishedOrders.length})
)} {row.redeemRecords.length > 0 && ( <> 核销记录({row.redeemRecords.length})
`¥${v}` }, { title: '打款状态', dataIndex: 'payoutStatus', width: 100, render: (v) => (v === 'PENDING' ? 待打款 : v === 'PAID' ? 已打款 : '—'), }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, ]} /> )} ), }} columns={[ { title: '用户编号', dataIndex: 'userNo', width: 120 }, { title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 120, render: (v) => maskPhone(v) }, { title: '风险', width: 200, render: (_, row) => (row.hasRisk ? ( 需关注 {row.unfinishedOrders.length > 0 && ( 未完成订单 {row.unfinishedOrders.length} )} {row.redeemRecords.length > 0 && ( 核销 {row.redeemRecords.length} )} ) : ( 无关联风险 )), }, ]} /> )} {!batchPreviewLoading && batchPreview && batchDeleteStep === 2 && ( <> 以下 {batchPreview.items.filter((i) => i.hasRisk).length} 名用户存在未完成订单或核销记录。 确认后将永久删除这些订单、核销记录、权益券及门店打款关联数据,且不可恢复。 )} />
i.hasRisk)} columns={[ { title: '用户编号', dataIndex: 'userNo', width: 120 }, { title: '未完成订单', width: 110, render: (_, row) => row.unfinishedOrders.length }, { title: '核销记录', width: 90, render: (_, row) => row.redeemRecords.length }, ]} /> setBatchRiskAck(e.target.checked)}> 我确认删除上述用户的未完成订单、核销记录及相关业务数据 )} ); }