Files
dukang/apps/admin-web/src/pages/UsersPage.tsx
T
jacy 9c8d5f2cad feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 14:35:39 +08:00

1067 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } 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 { DownOutlined, UpOutlined } from '@ant-design/icons';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
import { clientAppLabel, eventNameLabel, userEventCategoryLabel } from '../lib/display-labels';
type UserOrderRow = {
id: string;
orderNo: string;
status: string;
payAmount: number;
payStatus?: string;
createdAt: string;
};
function HqRemarkCell({
id,
hqRemark,
onSaved,
}: {
id: string;
hqRemark: string | null;
onSaved: (id: string, hqRemark: string | null) => void;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(hqRemark ?? '');
const savingRef = useRef(false);
useEffect(() => {
if (!editing) setValue(hqRemark ?? '');
}, [hqRemark, editing]);
async function commit() {
if (savingRef.current) return;
const next = value.trim() || null;
const prev = hqRemark?.trim() || null;
setEditing(false);
if (next === prev) return;
savingRef.current = true;
try {
const res = await request<{ id: string; hqRemark: string | null }>(`/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify({ hqRemark: next ?? '' }),
});
onSaved(id, res.hqRemark);
message.success('备注已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
setValue(hqRemark ?? '');
} finally {
savingRef.current = false;
}
}
if (editing) {
return (
<Input
size="small"
autoFocus
maxLength={128}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => void commit()}
onPressEnter={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Escape') void commit();
}}
onClick={(e) => e.stopPropagation()}
/>
);
}
return (
<span
title="双击修改备注,离开后保存"
onDoubleClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
style={{ cursor: 'text', display: 'inline-block', minWidth: 48 }}
>
{hqRemark || '—'}
</span>
);
}
/** 好客权益金额展示: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<string, unknown> | null;
};
type AssocPartnerBrief = {
id: string;
name: string;
companyName?: string | null;
phone?: string | null;
};
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
const FILTERS_COLLAPSED_KEY = 'admin-users-filters-collapsed';
const ASSOC_UNBOUND = 'none';
function formatAssocPartner(p?: AssocPartnerBrief | PartnerOption | null) {
if (!p) return '—';
const title = p.companyName || p.name || p.id;
return [title, p.phone].filter(Boolean).join(' / ') || '—';
}
type UserDetail = AdminUserRow & {
wxUnionId?: string | null;
cityPref?: Record<string, unknown> | 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;
assocPartner?: AssocPartnerBrief | null;
assocBoundAt?: string | null;
};
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 [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<UserDetail | null>(null);
const [behaviorLogs, setBehaviorLogs] = useState<UserBehaviorLog[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState('');
const [deleting, setDeleting] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
const [batchDeleteStep, setBatchDeleteStep] = useState<1 | 2>(1);
const [batchPreview, setBatchPreview] = useState<BatchDeletePreview | null>(null);
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false);
const [batchRiskAck, setBatchRiskAck] = useState(false);
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
const canEditAssoc = (profile?.permissionKeys ?? []).includes('users_partner_assoc');
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [assocPartnerId, setAssocPartnerId] = useState<string | undefined>();
const [savingAssoc, setSavingAssoc] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
try {
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
} catch {
return false;
}
});
const partnerOptions = useMemo(() => {
const map = new Map(partners.map((p) => [p.id, p]));
if (detail?.assocPartner && !map.has(detail.assocPartner.id)) {
map.set(detail.assocPartner.id, detail.assocPartner);
}
return [...map.values()];
}, [partners, detail?.assocPartner]);
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []);
const load = useCallback(async () => {
setLoading(true);
try {
const values = form.getFieldsValue();
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
const userId = String(values.userId || '').trim();
if (userId) qs.set('userId', userId);
if (values.phone) qs.set('phone', values.phone);
if (values.userNo) qs.set('userNo', values.userNo);
if (values.phoneVerified !== undefined && values.phoneVerified !== '') {
qs.set('phoneVerified', values.phoneVerified);
}
if (values.status !== undefined && values.status !== '') {
qs.set('status', String(values.status));
}
const keyword = String(values.keyword || '').trim();
if (keyword) qs.set('keyword', keyword);
const assocPartnerAccountId = String(
values.assocPartnerAccountId || searchParams.get('assocPartnerAccountId') || '',
).trim();
if (assocPartnerAccountId) qs.set('assocPartnerAccountId', assocPartnerAccountId);
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}, [form, page, pageSize, searchParams]);
useEffect(() => {
const userId = searchParams.get('userId');
const assocPartnerAccountId = searchParams.get('assocPartnerAccountId');
form.setFieldsValue({
...(userId ? { userId } : {}),
...(assocPartnerAccountId ? { assocPartnerAccountId } : {}),
});
}, [searchParams, form]);
useEffect(() => {
void load();
}, [load, searchParams]);
useEffect(() => {
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
if (!openUserId) return;
form.setFieldsValue({ userId: openUserId });
setPage(1);
void openDetail(openUserId);
navigate(`${location.pathname}?userId=${encodeURIComponent(openUserId)}`, {
replace: true,
state: null,
});
}, [location.state, location.pathname, navigate, form]);
async function openDetail(id: string) {
const [res, logs] = await Promise.all([
request<UserDetail>(`/admin/users/${id}`),
request<{ items: UserBehaviorLog[] }>(`/admin/logs/users?userId=${id}&pageSize=50`).catch(
() => ({ items: [] as UserBehaviorLog[] }),
),
]);
setDetail(res);
setAssocPartnerId(res.assocPartner?.id);
setBehaviorLogs(logs.items ?? []);
setDrawerOpen(true);
}
async function saveAssoc() {
if (!detail) return;
setSavingAssoc(true);
try {
await request(`/admin/users/${detail.id}/assoc`, {
method: 'PUT',
body: JSON.stringify({ partnerAccountId: assocPartnerId || null }),
});
message.success(assocPartnerId ? '已更新关联合伙人' : '已解除关联');
await openDetail(detail.id);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingAssoc(false);
}
}
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<BatchDeletePreview>('/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<UserOrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => ${v}` },
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
];
const baseColumns: ColumnsType<AdminUserRow> = [
{
title: '用户编号',
dataIndex: 'userNo',
width: 140,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange">测试</Tag> : null}
</Space>
),
},
{
title: '昵称',
dataIndex: 'nickname',
width: 140,
render: (v: string | null, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '备注',
dataIndex: 'hqRemark',
width: 160,
render: (v: string | null, row) => (
<HqRemarkCell
id={row.id}
hqRemark={v}
onSaved={(id, hqRemark) => {
setData((prev) =>
prev
? { ...prev, items: prev.items.map((u) => (u.id === id ? { ...u, hqRemark } : u)) }
: prev,
);
setDetail((d) => (d && d.id === id ? { ...d, hqRemark } : d));
}}
/>
),
},
{
title: '手机',
dataIndex: 'phone',
width: 120,
render: (v) => v || '—',
},
{
title: '验手机',
dataIndex: 'phoneVerifiedAt',
width: 90,
render: (v) => (v ? <Tag color="green">已验证</Tag> : <Tag color="orange">访客</Tag>),
},
{
title: '微信',
dataIndex: 'wechatVerified',
width: 100,
render: (v) => (v ? <Tag color="blue">微信已验证</Tag> : <Tag>未绑定</Tag>),
},
{
title: '来源类型',
dataIndex: 'sourceType',
width: 100,
render: (v: string) => (
<Tag color={v === 'PROMO_CODE' ? 'blue' : v === 'ORGANIC' ? 'default' : 'purple'}>
{USER_SOURCE_TYPE_LABELS[v as UserSourceType] || v}
</Tag>
),
},
{
title: '来源 ID',
dataIndex: 'sourceRefId',
width: 100,
render: (v, row) => {
if (!v) return '—';
if (row.sourceType === 'PROMO_CODE') {
return (
<Link to={`/promo-codes/${v}`} onClick={(e) => e.stopPropagation()}>
{v}
</Link>
);
}
return v;
},
},
{
title: '来源标签',
dataIndex: 'sourceLabel',
width: 120,
render: (v) => v || '—',
},
{
title: '关联合伙人',
dataIndex: 'assocPartner',
width: 180,
render: (p: AdminUserRow['assocPartner']) => formatAssocPartner(p),
},
{
title: 'deviceKey',
dataIndex: 'deviceKey',
render: (v) => v || '—',
},
{
title: '合并',
dataIndex: 'mergedIntoUserId',
width: 80,
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
},
{ 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 ? (
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(v)}
</Typography.Text>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '已用权益',
dataIndex: 'benefitUsedAmount',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0),
render: (v: number | undefined) => (
<Typography.Text type="secondary">{fmtBenefit(v)}</Typography.Text>
),
},
{
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) => (
<Space size="small">
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
详情
</Button>
<Button type="link" size="small" onClick={() => navigate(`/logs/users?userId=${row.id}`)}>
查看日志
</Button>
</Space>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('users', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<AdminListHeader
title="用户监控"
settings={settingsButton}
actions={
canDeleteUsers ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => void openBatchDeleteModal()}
>
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null
}
/>
<Form
form={form}
layout="vertical"
onFinish={() => {
setPage(1);
const next = new URLSearchParams();
const userId = String(form.getFieldValue('userId') || '').trim();
const assoc = String(form.getFieldValue('assocPartnerAccountId') || '').trim();
if (userId) next.set('userId', userId);
if (assoc) next.set('assocPartnerAccountId', assoc);
if (next.toString() !== searchParams.toString()) {
setSearchParams(next, { replace: true });
}
void load();
}}
>
<div className="admin-orders-filter-row">
<Form.Item name="keyword" label="搜索" className="admin-orders-filter-item admin-users-filter-item--keyword">
<Input allowClear placeholder="编号/昵称/备注/手机" />
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
className="admin-orders-filter-item admin-orders-filter-item--status"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={[
{ value: 'any', label: '已关联(全部)' },
{ value: ASSOC_UNBOUND, label: '未关联' },
...partners.map((p) => ({
value: p.id,
label: formatAssocPartner(p),
})),
]}
/>
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap>
<Button type="primary" htmlType="submit">查询</Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (searchParams.toString()) {
setSearchParams({}, { replace: true });
} else {
void load();
}
}}
>
重置
</Button>
<Button
type="link"
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => {
setFiltersCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}}
>
{filtersCollapsed ? '展开' : '收起'}
</Button>
</Space>
</Form.Item>
</div>
{!filtersCollapsed ? (
<div className="admin-orders-filter-row">
<Form.Item name="phone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input placeholder="模糊搜索" allowClear />
</Form.Item>
<Form.Item name="userNo" label="用户编号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input placeholder="DK..." allowClear />
</Form.Item>
<Form.Item name="userId" label="用户ID" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Input placeholder="精确匹配" allowClear />
</Form.Item>
<Form.Item name="phoneVerified" label="验手机" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={[
{ value: '1', label: '已验证' },
{ value: '0', label: '访客' },
]} />
</Form.Item>
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={[
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
]} />
</Form.Item>
</div>
) : null}
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
rowSelection={canDeleteUsers ? {
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
} : undefined}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="用户详情"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={detail && canDeleteUsers ? (
<Button danger onClick={openDeleteModal}>删除用户</Button>
) : undefined}
>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item>
<Descriptions.Item label="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
</Descriptions.Item>
<Descriptions.Item label="微信验证">
{detail.wechatVerified ? <Tag color="blue">微信已验证</Tag> : <Tag>未绑定</Tag>}
</Descriptions.Item>
<Descriptions.Item label="来源类型">
<Tag color={detail.sourceType === 'PROMO_CODE' ? 'blue' : 'default'}>
{USER_SOURCE_TYPE_LABELS[detail.sourceType as UserSourceType] || detail.sourceType}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="来源 ID">
{detail.sourceRefId ? (
detail.sourceType === 'PROMO_CODE' ? (
<Link to={`/promo-codes/${detail.sourceRefId}`}>{detail.sourceRefId}</Link>
) : (
detail.sourceRefId
)
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="来源标签">{detail.sourceLabel || '—'}</Descriptions.Item>
<Descriptions.Item label="关联合伙人">
{canEditAssoc ? (
<Space wrap>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="未关联"
style={{ minWidth: 260 }}
value={assocPartnerId}
onChange={setAssocPartnerId}
options={partnerOptions.map((p) => ({
value: p.id,
label: `${p.companyName || p.name || p.id}${p.phone ? ` · ${p.phone}` : ''}`,
}))}
/>
<Button type="primary" size="small" loading={savingAssoc} onClick={() => void saveAssoc()}>
保存关联
</Button>
<Typography.Text type="secondary">清空后保存即解绑;已支付订单佣金快照不变</Typography.Text>
</Space>
) : detail.assocPartner ? (
`${detail.assocPartner.companyName || detail.assocPartner.name}${
detail.assocPartner.phone ? ` / ${detail.assocPartner.phone}` : ''
}`
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="关联时间">
{detail.assocBoundAt ? fmtTime(detail.assocBoundAt) : '—'}
</Descriptions.Item>
{detail.sourcePromo && (
<Descriptions.Item label="推广码">
<Link to={`/promo-codes/${detail.sourcePromo.id}`}>
{detail.sourcePromo.name}{detail.sourcePromo.code}
</Link>
</Descriptions.Item>
)}
<Descriptions.Item label="wxOpenId">{detail.wxOpenId || '—'}</Descriptions.Item>
<Descriptions.Item label="wxUnionId">{detail.wxUnionId || '—'}</Descriptions.Item>
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
<Descriptions.Item label="合并至">
{detail.mergedInto
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
: '—'}
</Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
<Descriptions.Item label="好客权益">
<Space size={16} wrap>
<span>
剩余{' '}
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(detail.benefitBalance)}
</Typography.Text>
</span>
<span>已用 {fmtBenefit(detail.benefitUsedAmount)}</span>
<span>累计 {fmtBenefit(detail.benefitTotalAmount)}</span>
</Space>
</Descriptions.Item>
<Descriptions.Item label="注册时间">
{new Date(detail.createdAt).toLocaleString('zh-CN')}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
行为时间线(最近 {behaviorLogs.length} 条)
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
scroll={{ y: 200 }}
dataSource={behaviorLogs}
locale={{ emptyText: '暂无行为日志' }}
columns={[
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v) => fmtTime(v) },
{
title: '分类',
width: 100,
render: (_, r) => userEventCategoryLabel(r.eventName),
},
{ title: '事件', dataIndex: 'eventName', render: (v: string) => eventNameLabel(v) },
{ title: '端', dataIndex: 'clientApp', width: 110, render: (v: string | null) => clientAppLabel(v) },
]}
/>
<Typography.Title level={5} style={{ marginTop: 16 }}>
全部订单({detail.orders?.length ?? 0}
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
scroll={{ x: 520, y: 240 }}
dataSource={detail.orders ?? []}
columns={orderColumns}
locale={{ emptyText: '暂无订单' }}
/>
<Button
type="primary"
style={{ marginTop: 16 }}
onClick={() => navigate(`/logs/users?userId=${detail.id}`)}
>
查看用户日志
</Button>
</>
)}
</Drawer>
<Modal
title="确认删除用户"
open={deleteOpen}
okText="确认删除"
okButtonProps={{
danger: true,
disabled: !detail || deleteConfirm !== detail.userNo,
loading: deleting,
}}
onOk={() => void confirmDelete()}
onCancel={() => setDeleteOpen(false)}
width={720}
destroyOnClose
>
{detail && (
<>
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
message="此操作不可恢复"
description={(
<>
将删除用户 <strong>{detail.userNo}</strong> 及其地址、订单、权益券、核销记录等业务数据。
<br />
用户行为日志(埋点)与第三方调用日志将<strong>保留</strong>,不随用户删除。
</>
)}
/>
<Typography.Text strong>关联订单({detail.orders?.length ?? 0} 笔)</Typography.Text>
<Table
size="small"
style={{ marginTop: 8, marginBottom: 16 }}
rowKey="id"
pagination={false}
scroll={{ x: 520, y: 200 }}
dataSource={detail.orders ?? []}
columns={orderColumns}
locale={{ emptyText: '无订单' }}
/>
<Typography.Paragraph type="secondary">
请输入用户编号 <Typography.Text code>{detail.userNo}</Typography.Text> 以确认删除:
</Typography.Paragraph>
<Input
value={deleteConfirm}
placeholder={detail.userNo}
onChange={(e) => setDeleteConfirm(e.target.value)}
/>
</>
)}
</Modal>
<Modal
title={batchDeleteStep === 1 ? `确认批量删除(${selectedRowKeys.length} 人)` : '二次确认:删除关联业务数据'}
open={batchDeleteOpen}
okText={batchPreview?.hasRisk && batchDeleteStep === 1 ? '下一步' : '确认删除'}
okButtonProps={{
danger: batchDeleteStep === 2 || !batchPreview?.hasRisk,
loading: batchPreviewLoading || batchDeleting,
disabled: batchDeleteStep === 2 && !batchRiskAck,
}}
cancelText={batchDeleteStep === 2 ? '上一步' : '取消'}
onOk={() => handleBatchDeleteOk()}
onCancel={() => {
if (batchDeleteStep === 2) {
setBatchDeleteStep(1);
setBatchRiskAck(false);
return;
}
setBatchDeleteOpen(false);
}}
width={800}
destroyOnClose
>
{batchPreviewLoading && (
<Typography.Text type="secondary">正在检查关联订单与核销记录…</Typography.Text>
)}
{!batchPreviewLoading && batchPreview && batchDeleteStep === 1 && (
<>
<Alert
type={batchPreview.hasRisk ? 'warning' : 'error'}
showIcon
style={{ marginBottom: 16 }}
message={batchPreview.hasRisk ? '部分用户存在未完成订单或核销记录' : '此操作不可恢复'}
description={batchPreview.hasRisk
? '标有「需关注」的用户名下有未完成订单和/或核销记录。继续后将进入二次确认,确认后将一并删除相关订单、核销记录及权益等业务数据。用户行为日志将保留。'
: `将删除 ${batchPreview.total} 名用户及其地址、订单、权益券等业务数据。用户行为日志将保留。`}
/>
<Table
size="small"
rowKey="id"
pagination={false}
scroll={{ x: 640, y: 320 }}
dataSource={batchPreview.items}
expandable={{
rowExpandable: (row) => row.hasRisk,
expandedRowRender: (row) => (
<div style={{ padding: '0 8px 8px' }}>
{row.unfinishedOrders.length > 0 && (
<>
<Typography.Text strong>未完成订单({row.unfinishedOrders.length}</Typography.Text>
<Table
size="small"
style={{ marginTop: 8, marginBottom: 12 }}
rowKey="id"
pagination={false}
dataSource={row.unfinishedOrders}
columns={orderColumns}
/>
</>
)}
{row.redeemRecords.length > 0 && (
<>
<Typography.Text strong>核销记录({row.redeemRecords.length}</Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
rowKey="id"
pagination={false}
dataSource={row.redeemRecords}
columns={[
{ title: '核销单号', dataIndex: 'redeemNo', width: 160 },
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => ${v}` },
{
title: '打款状态',
dataIndex: 'payoutStatus',
width: 100,
render: (v) => (v === 'PENDING' ? <Tag color="orange">待打款</Tag> : v === 'PAID' ? <Tag color="green">已打款</Tag> : '—'),
},
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
]}
/>
</>
)}
</div>
),
}}
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 ? (
<Space size={4} wrap>
<Tag color="warning">需关注</Tag>
{row.unfinishedOrders.length > 0 && (
<Tag color="orange">未完成订单 {row.unfinishedOrders.length}</Tag>
)}
{row.redeemRecords.length > 0 && (
<Tag color="red">核销 {row.redeemRecords.length}</Tag>
)}
</Space>
) : (
<Tag color="default">无关联风险</Tag>
)),
},
]}
/>
</>
)}
{!batchPreviewLoading && batchPreview && batchDeleteStep === 2 && (
<>
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
message="即将删除未完成订单与核销记录"
description={(
<>
以下 <strong>{batchPreview.items.filter((i) => i.hasRisk).length}</strong> 名用户存在未完成订单或核销记录。
确认后将<strong>永久删除</strong>这些订单、核销记录、权益券及门店打款关联数据,且不可恢复。
</>
)}
/>
<Table
size="small"
style={{ marginBottom: 16 }}
rowKey="id"
pagination={false}
scroll={{ y: 200 }}
dataSource={batchPreview.items.filter((i) => i.hasRisk)}
columns={[
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '未完成订单', width: 110, render: (_, row) => row.unfinishedOrders.length },
{ title: '核销记录', width: 90, render: (_, row) => row.redeemRecords.length },
]}
/>
<Checkbox checked={batchRiskAck} onChange={(e) => setBatchRiskAck(e.target.checked)}>
我确认删除上述用户的未完成订单、核销记录及相关业务数据
</Checkbox>
</>
)}
</Modal>
</div>
);
}