b626db5d84
Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module.
713 lines
26 KiB
TypeScript
713 lines
26 KiB
TypeScript
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;
|
||
};
|
||
|
||
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: '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: 1500 }}
|
||
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="注册时间">
|
||
{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>
|
||
);
|
||
}
|