Files
dukang/apps/admin-web/src/pages/UsersPage.tsx
T
jacy 88053353ab feat(ops,store,partner): 用户权益列 + 门店合同多图上传
- admin 用户列表新增剩余/已用/累计好客权益金额三列(benefitCoupon groupBy 聚合,排除 VOID)
- admin/partner 门店入驻合同支持多张照片与 PDF(CommonResource 多记录,复用 ENV 多图逻辑)
- 新增 contract-urls.util 归一化工具,向后兼容旧 contractUrl 字段并标记 deprecated

需求1/2/3
2026-08-12 22:48:02 +08:00

765 lines
28 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, 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<string, unknown> | null;
};
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;
};
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<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');
useEffect(() => {
request<HqProfile>('/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<Paginated<AdminUserRow>>(`/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<UserDetail>(`/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<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 columns: 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: 100 },
{
title: '手机',
dataIndex: 'phone',
width: 120,
render: (v) => maskPhone(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,
ellipsis: true,
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,
ellipsis: true,
render: (v) => v || '—',
},
{
title: 'deviceKey',
dataIndex: 'deviceKey',
ellipsis: true,
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>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
{canDeleteUsers ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => void openBatchDeleteModal()}
>
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号">
<Input placeholder="模糊搜索" allowClear />
</Form.Item>
<Form.Item name="userNo" label="用户编号">
<Input placeholder="DK..." allowClear />
</Form.Item>
<Form.Item name="deviceKey" label="deviceKey">
<Input placeholder="UUID" allowClear style={{ width: 200 }} />
</Form.Item>
<Form.Item name="phoneVerified" label="验手机">
<Select allowClear style={{ width: 100 }} options={[
{ value: '1', label: '已验证' },
{ value: '0', label: '访客' },
]} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 90 }} options={[
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
]} />
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox>过滤测试账号</Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">查询</Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1830 }}
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="手机号">{maskPhone(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>
{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 ? maskPhone(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) => resolveUserLogCategory(r.eventName) ?? '—',
},
{ title: '事件', dataIndex: 'eventName' },
{ title: '端', dataIndex: 'clientApp', width: 90 },
]}
/>
<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>
);
}