webadmin端
批量删除用户 模板上传图片数量限制改成30 小飞侠接口配置
This commit is contained in:
@@ -12,6 +12,7 @@ import PartnerAccountsPage from './pages/PartnerAccountsPage';
|
||||
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
||||
import RedeemDebugPage from './pages/RedeemDebugPage';
|
||||
import DeliveriesPage from './pages/DeliveriesPage';
|
||||
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
||||
import HqAccountsPage from './pages/HqAccountsPage';
|
||||
@@ -57,6 +58,7 @@ export default function App() {
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
||||
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
|
||||
@@ -61,6 +61,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/benefit/coupons', label: '权益券' },
|
||||
{ key: '/benefit/ledgers', label: '流水' },
|
||||
{ key: '/redeem-records', label: '核销记录' },
|
||||
{ key: '/redeem/debug', label: '核销调试' },
|
||||
{ key: '/store-payouts', label: '门店打款' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */
|
||||
export const ADMIN_OPTIONS_PAGE_SIZE = 100;
|
||||
|
||||
export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
|
||||
XFX: '小飞侠',
|
||||
LOGISTICS: '物流快递',
|
||||
MANUAL: '手动',
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** 商品详情页文案模板(Admin 一键套用) */
|
||||
export const TEMPLATE_MAX_DETAIL_IMAGES = 20;
|
||||
export const TEMPLATE_MAX_DETAIL_IMAGES = 30;
|
||||
|
||||
export type ProductDetailTemplateContent = {
|
||||
storyTitle?: string;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -14,7 +18,20 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS } from '../lib/constants';
|
||||
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
|
||||
type ShipDefaults = {
|
||||
provider: string;
|
||||
providerLabel: string;
|
||||
fromName: string;
|
||||
fromMobile: string;
|
||||
fromAddress: string;
|
||||
fromAddressDetail: string;
|
||||
fromLng: number;
|
||||
fromLat: number;
|
||||
weight: number;
|
||||
payMode: string;
|
||||
};
|
||||
|
||||
type OrderDetail = AdminOrderRow & {
|
||||
receiverAddress?: string;
|
||||
@@ -45,12 +62,23 @@ type OrderDetail = AdminOrderRow & {
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [shipForm] = Form.useForm();
|
||||
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
||||
const [shipping, setShipping] = useState(false);
|
||||
|
||||
const selectedOrders = useMemo(
|
||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||
[data?.items, selectedRowKeys],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -71,10 +99,71 @@ export default function OrdersPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
const defaults = shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null);
|
||||
if (defaults) {
|
||||
setShipDefaults(defaults);
|
||||
shipForm.setFieldsValue({
|
||||
provider: defaults.provider,
|
||||
weight: defaults.weight,
|
||||
payMode: defaults.payMode,
|
||||
fromName: defaults.fromName,
|
||||
fromMobile: defaults.fromMobile,
|
||||
fromAddress: defaults.fromAddress,
|
||||
fromAddressDetail: defaults.fromAddressDetail,
|
||||
fromLng: defaults.fromLng,
|
||||
fromLat: defaults.fromLat,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function submitShip() {
|
||||
if (!detail) return;
|
||||
const values = await shipForm.validateFields();
|
||||
setShipping(true);
|
||||
try {
|
||||
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/ship`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('发货成功');
|
||||
setDetail(res);
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发货失败');
|
||||
} finally {
|
||||
setShipping(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchDelete() {
|
||||
if (!selectedRowKeys.length) return;
|
||||
setBatchDeleting(true);
|
||||
try {
|
||||
const res = await request<{ deleted: number; message: string }>('/admin/orders/batch-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids: selectedRowKeys }),
|
||||
});
|
||||
message.success(res.message || `已删除 ${res.deleted} 笔订单`);
|
||||
setBatchDeleteOpen(false);
|
||||
setSelectedRowKeys([]);
|
||||
if (detail && selectedRowKeys.includes(detail.id)) {
|
||||
setDrawerOpen(false);
|
||||
setDetail(null);
|
||||
}
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '批量删除失败');
|
||||
} finally {
|
||||
setBatchDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminOrderRow> = [
|
||||
@@ -124,7 +213,16 @@ export default function OrdersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>订单监控</Typography.Title>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => setBatchDeleteOpen(true)}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="orderNo" label="订单号">
|
||||
<Input placeholder="DK..." allowClear />
|
||||
@@ -149,6 +247,10 @@ export default function OrdersPage() {
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -215,11 +317,75 @@ export default function OrdersPage() {
|
||||
|
||||
{detail.delivery && (
|
||||
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="provider">{detail.delivery.provider}</Descriptions.Item>
|
||||
<Descriptions.Item label="快递公司">
|
||||
{DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || detail.delivery.provider}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Title level={5}>发货</Typography.Title>
|
||||
<Form form={shipForm} layout="vertical" size="small">
|
||||
<Form.Item name="provider" label="快递公司" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[{ value: 'XFX', label: '小飞侠' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '1', label: '寄付' },
|
||||
{ value: '2', label: '到付' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Collapse
|
||||
ghost
|
||||
items={[{
|
||||
key: 'from',
|
||||
label: '寄件信息(默认仓库,可修改)',
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Form.Item name="fromLng" label="经度">
|
||||
<InputNumber step={0.001} />
|
||||
</Form.Item>
|
||||
<Form.Item name="fromLat" label="纬度">
|
||||
<InputNumber step={0.001} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
|
||||
调用小飞侠发货
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.statusLogs && detail.statusLogs.length > 0 && (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||
@@ -239,6 +405,44 @@ export default function OrdersPage() {
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={`确认批量删除(${selectedOrders.length} 笔)`}
|
||||
open={batchDeleteOpen}
|
||||
okText="确认删除"
|
||||
okButtonProps={{ danger: true, loading: batchDeleting }}
|
||||
onOk={() => void confirmBatchDelete()}
|
||||
onCancel={() => setBatchDeleteOpen(false)}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="此操作不可恢复"
|
||||
description="将删除所选订单及其配送单、权益券、核销记录等关联业务数据。订单状态流转等业务日志将保留。"
|
||||
/>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
scroll={{ x: 600, y: 280 }}
|
||||
dataSource={selectedOrders}
|
||||
columns={[
|
||||
{ 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: 'receiverName', width: 90 },
|
||||
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
|
||||
} from 'antd';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type ApiResult = Record<string, unknown>;
|
||||
|
||||
function ResultBox({ data }: { data: ApiResult | null }) {
|
||||
if (!data) return <Typography.Text type="secondary">调用后在此显示结果</Typography.Text>;
|
||||
return (
|
||||
<pre style={{
|
||||
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||
maxHeight: 320, overflow: 'auto', fontSize: 12,
|
||||
}}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RedeemDebugPage() {
|
||||
const [createForm] = Form.useForm();
|
||||
const [previewForm] = Form.useForm();
|
||||
const [confirmForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createResult, setCreateResult] = useState<ApiResult | null>(null);
|
||||
const [previewResult, setPreviewResult] = useState<ApiResult | null>(null);
|
||||
const [confirmResult, setConfirmResult] = useState<ApiResult | null>(null);
|
||||
|
||||
async function invoke(
|
||||
path: string,
|
||||
body: unknown,
|
||||
setResult: (v: ApiResult | null) => void,
|
||||
successMsg: string,
|
||||
) {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await request<ApiResult>(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setResult(res);
|
||||
message.success(successMsg);
|
||||
if (path.includes('create-token') && res.token) {
|
||||
previewForm.setFieldsValue({ token: res.token });
|
||||
confirmForm.setFieldsValue({ token: res.token });
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '调用失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>核销调试</Typography.Title>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="仅用于 preV1 联调"
|
||||
description="模拟 C 端生成核销码、门店预览与确认核销。确认核销会真实扣减权益并写入核销记录。"
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'create',
|
||||
label: '1. 生成核销码',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="userId" label="用户 ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="用户表 id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="couponId" label="指定券 ID(可选)">
|
||||
<Input placeholder="不填则自动分摊" />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="绑定门店 ID(可选)">
|
||||
<Input placeholder="绑定后仅该门店可核销" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const values = createForm.getFieldsValue();
|
||||
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
|
||||
}}
|
||||
>
|
||||
生成核销码
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={createResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
label: '2. 预览核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={previewForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const values = previewForm.getFieldsValue();
|
||||
void invoke('/admin/redeem/debug/preview', values, setPreviewResult, '预览成功');
|
||||
}}
|
||||
>
|
||||
预览
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={previewResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'confirm',
|
||||
label: '3. 确认核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={confirmForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const values = confirmForm.getFieldsValue();
|
||||
void invoke('/admin/redeem/debug/confirm', values, setConfirmResult, '核销成功');
|
||||
}}
|
||||
>
|
||||
确认核销
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={confirmResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
@@ -37,6 +38,28 @@ type UserDetail = AdminUserRow & {
|
||||
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 [form] = Form.useForm();
|
||||
@@ -49,6 +72,13 @@ export default function UsersPage() {
|
||||
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 load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -99,6 +129,7 @@ export default function UsersPage() {
|
||||
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 : '删除失败');
|
||||
@@ -107,6 +138,60 @@ export default function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
{
|
||||
@@ -177,7 +262,16 @@ export default function UsersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>用户监控</Typography.Title>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => void openBatchDeleteModal()}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="模糊搜索" allowClear />
|
||||
@@ -214,6 +308,11 @@ export default function UsersPage() {
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
preserveSelectedRowKeys: true,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -338,6 +437,150 @@ export default function UsersPage() {
|
||||
</>
|
||||
)}
|
||||
</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) => 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user