webadmin端

批量删除用户
模板上传图片数量限制改成30
小飞侠接口配置
This commit is contained in:
2026-07-06 18:04:02 +08:00
parent 4e18763968
commit f0b99ea9da
17 changed files with 1172 additions and 49 deletions
+2
View File
@@ -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: '门店打款' },
],
},
+6
View File
@@ -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;
+208 -4
View File
@@ -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>
);
}
+244 -1
View File
@@ -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>
);
}
+7
View File
@@ -67,3 +67,10 @@ XIAOFEIXIA_MCH_ID=
XIAOFEIXIA_API_KEY=
XIAOFEIXIA_SIGN_TYPE=MD5
# XIAOFEIXIA_APP_ID=
# HQ 订单发货默认寄件信息
SHIP_FROM_NAME=杜康仓库
SHIP_FROM_MOBILE=13800000000
SHIP_FROM_ADDRESS=河南省郑州市金水区
SHIP_FROM_ADDRESS_DETAIL=杜康酒业仓
SHIP_FROM_LNG=113.665
SHIP_FROM_LAT=34.757
@@ -1,7 +1,8 @@
import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminOrdersService } from './admin-orders.service';
import { UpdateOrderStatusDto } from './dto/admin-mutate.dto';
import { AdminShipOrderDto, BatchDeleteOrdersDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Controller('admin/orders')
@@ -14,11 +15,28 @@ export class AdminOrdersController {
return this.ordersService.list(query);
}
@Post('batch-delete')
@UseGuards(SuperAdminGuard)
batchDelete(@Body() dto: BatchDeleteOrdersDto) {
return this.ordersService.batchDeleteOrders(dto.ids.map((id) => BigInt(id)));
}
@Get('ship-defaults')
shipDefaults() {
return this.ordersService.getShipDefaults();
}
@Get(':id')
detail(@Param('id') id: string) {
return this.ordersService.detail(BigInt(id));
}
/** HQ 发货:调用小飞侠创建运单并更新配送信息 */
@Post(':id/ship')
ship(@Param('id') id: string, @Body() dto: AdminShipOrderDto) {
return this.ordersService.shipOrder(BigInt(id), dto);
}
/** preV1 调试:直接改订单状态,不走业务校验 */
@Put(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
@@ -1,17 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { orderStatusLogWhere } from '../../common/event/event.helpers';
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import { TradeService } from '../trade/trade.service';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
import type { AdminShipOrderDto } from './dto/admin-mutate.dto';
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
@Injectable()
export class AdminOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly tradeService: TradeService,
private readonly xiaofeixiaService: AdminXiaofeixiaService,
private readonly config: ConfigService,
) {}
async list(query: AdminOrdersQueryDto) {
@@ -87,4 +93,156 @@ export class AdminOrdersService {
await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG');
return this.detail(id);
}
getShipDefaults() {
return {
provider: 'XFX',
providerLabel: '小飞侠',
fromName: this.config.get<string>('SHIP_FROM_NAME') || '杜康仓库',
fromMobile: this.config.get<string>('SHIP_FROM_MOBILE') || '13800000000',
fromAddress: this.config.get<string>('SHIP_FROM_ADDRESS') || '河南省郑州市金水区',
fromAddressDetail: this.config.get<string>('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓',
fromLng: Number(this.config.get<string>('SHIP_FROM_LNG') || 113.665),
fromLat: Number(this.config.get<string>('SHIP_FROM_LAT') || 34.757),
weight: 2,
payMode: '1',
};
}
async shipOrder(id: bigint, dto: AdminShipOrderDto) {
if (dto.provider !== 'XFX') {
throw new BadRequestException('暂仅支持小飞侠配送');
}
const order = await this.prisma.order.findUnique({
where: { id },
include: { delivery: true },
});
if (!order) throw new NotFoundException('订单不存在');
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可发货');
}
if (order.delivery?.trackingNo) {
throw new BadRequestException('该订单已有运单号,请勿重复发货');
}
const defaults = this.getShipDefaults();
const shipmentDto: XiaofeixiaCreateShipmentDto = {
outNumber: order.orderNo,
fromName: dto.fromName || defaults.fromName,
fromMobile: dto.fromMobile || defaults.fromMobile,
fromAddress: dto.fromAddress || defaults.fromAddress,
fromAddressDetail: dto.fromAddressDetail || defaults.fromAddressDetail,
fromLng: dto.fromLng ?? defaults.fromLng,
fromLat: dto.fromLat ?? defaults.fromLat,
toName: order.receiverName,
toMobile: order.receiverPhone,
toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
toAddressDetail: order.receiverAddress,
goodsName: order.productName,
goodsNum: order.quantity,
weight: dto.weight ?? defaults.weight,
payMode: dto.payMode || defaults.payMode,
remark: dto.remark || `HQ发货 ${order.orderNo}`,
};
const result = await this.xiaofeixiaService.createShipment(shipmentDto);
if (!result.ok || !result.data) {
throw new BadRequestException(result.error || '小飞侠创建运单失败');
}
const { providerShipmentId, trackingNumber } = result.data;
const now = new Date();
await this.prisma.$transaction(async (tx) => {
if (order.delivery) {
await tx.orderDelivery.update({
where: { orderId: id },
data: {
provider: 'XFX',
trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId),
shippingAt: now,
},
});
} else {
await tx.orderDelivery.create({
data: {
orderId: id,
provider: 'XFX',
trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId),
shippingAt: now,
},
});
}
});
await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP');
return this.detail(id);
}
async batchDeleteOrders(ids: bigint[]) {
const uniqueIds = [...new Set(ids)];
if (!uniqueIds.length) {
return { ok: true, deleted: 0, message: '未选择订单' };
}
const orders = await this.prisma.order.findMany({
where: { id: { in: uniqueIds } },
select: { id: true, orderNo: true },
});
if (!orders.length) throw new NotFoundException('订单不存在');
const orderIds = orders.map((o) => o.id);
await this.prisma.$transaction(async (tx) => {
await this.deleteOrdersInTx(tx, orderIds);
});
return {
ok: true,
deleted: orderIds.length,
orderNos: orders.map((o) => o.orderNo),
message: '订单及关联业务数据已删除,状态流转等业务日志已保留',
};
}
private async deleteOrdersInTx(tx: Prisma.TransactionClient, orderIds: bigint[]) {
if (!orderIds.length) return;
const couponIds = (
await tx.benefitCoupon.findMany({
where: { orderId: { in: orderIds } },
select: { id: true },
})
).map((c) => c.id);
if (couponIds.length) {
const redeemIds = (
await tx.redeemRecord.findMany({
where: { couponId: { in: couponIds } },
select: { id: true },
})
).map((r) => r.id);
if (redeemIds.length) {
await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await tx.benefitCoupon.deleteMany({ where: { id: { in: couponIds } } });
}
await tx.order.updateMany({
where: { originOrderId: { in: orderIds } },
data: { originOrderId: null },
});
await tx.commonTicket.deleteMany({
where: { refType: 'ORDER', refId: { in: orderIds } },
});
await tx.order.deleteMany({ where: { id: { in: orderIds } } });
}
}
@@ -25,7 +25,7 @@ type TemplateRow = {
updatedAt: Date;
};
const MAX_TEMPLATE_DETAIL_IMAGES = 20;
const MAX_TEMPLATE_DETAIL_IMAGES = 30;
@Injectable()
export class AdminProductDetailTemplatesService {
@@ -0,0 +1,31 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
import type {
AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto';
@Controller('admin/redeem/debug')
@UseGuards(HqAuthGuard)
export class AdminRedeemDebugController {
constructor(private readonly service: AdminRedeemDebugService) {}
/** preV1 调试:为用户生成核销码 */
@Post('create-token')
createToken(@Body() dto: AdminRedeemDebugCreateTokenDto) {
return this.service.createToken(dto);
}
/** preV1 调试:门店侧预览核销 */
@Post('preview')
preview(@Body() dto: AdminRedeemDebugStoreTokenDto) {
return this.service.preview(dto);
}
/** preV1 调试:门店侧确认核销 */
@Post('confirm')
confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) {
return this.service.confirm(dto);
}
}
@@ -0,0 +1,45 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { RedeemService } from '../redeem/redeem.service';
import type {
AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto';
@Injectable()
export class AdminRedeemDebugService {
constructor(
private readonly prisma: PrismaService,
private readonly redeemService: RedeemService,
) {}
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
const account = await this.prisma.storeAccount.findFirst({
where: { storeId: BigInt(storeId), status: 'ACTIVE' },
orderBy: { id: 'asc' },
select: { id: true, store: { select: { name: true } } },
});
if (!account) {
throw new NotFoundException('该门店无可用账户,请先创建门店账户');
}
return account.id;
}
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
return this.redeemService.createToken(BigInt(dto.userId), {
amount: dto.amount,
couponId: dto.couponId,
storeId: dto.storeId,
});
}
async preview(dto: AdminRedeemDebugStoreTokenDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.previewRedeem(storeAccountId, dto.token);
}
async confirm(dto: AdminRedeemDebugStoreTokenDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token });
}
}
@@ -1,8 +1,9 @@
import { Controller, Delete, Get, Param, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminUsersService } from './admin-users.service';
import { AdminUsersQueryDto } from './dto/admin-query.dto';
import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto } from './dto/admin-mutate.dto';
@Controller('admin/users')
@UseGuards(HqAuthGuard)
@@ -14,6 +15,21 @@ export class AdminUsersController {
return this.usersService.list(query);
}
@Post('batch-delete/preview')
@UseGuards(SuperAdminGuard)
previewBatchDelete(@Body() dto: BatchDeleteUsersDto) {
return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id)));
}
@Post('batch-delete')
@UseGuards(SuperAdminGuard)
batchDelete(@Body() dto: BatchDeleteUsersConfirmDto) {
return this.usersService.batchDeleteUsers(
dto.ids.map((id) => BigInt(id)),
dto.confirmRisk,
);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.usersService.detail(BigInt(id));
@@ -1,9 +1,11 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminUsersQueryDto } from './dto/admin-query.dto';
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
function mapAdminUserRow(u: {
id: bigint;
userNo: string;
@@ -115,11 +117,125 @@ export class AdminUsersService {
});
}
async previewBatchDelete(ids: bigint[]) {
const uniqueIds = [...new Set(ids)];
if (!uniqueIds.length) {
return { items: [], hasRisk: false, total: 0 };
}
const users = await this.prisma.user.findMany({
where: { id: { in: uniqueIds } },
select: {
id: true,
userNo: true,
nickname: true,
phone: true,
orders: {
where: { status: { notIn: [...FINISHED_ORDER_STATUSES] } },
select: { id: true, orderNo: true, status: true, payAmount: true, createdAt: true },
orderBy: { createdAt: 'desc' },
},
redeemRecords: {
select: {
id: true,
redeemNo: true,
amount: true,
createdAt: true,
payout: { select: { status: true } },
},
orderBy: { createdAt: 'desc' },
},
},
orderBy: { createdAt: 'desc' },
});
const foundIds = new Set(users.map((u) => u.id.toString()));
const missing = uniqueIds.filter((id) => !foundIds.has(id.toString()));
if (missing.length) {
throw new NotFoundException('部分用户不存在');
}
const items = users.map((u) => {
const unfinishedOrders = u.orders;
const redeemRecords = u.redeemRecords.map((r) => ({
id: r.id,
redeemNo: r.redeemNo,
amount: r.amount,
createdAt: r.createdAt,
payoutStatus: r.payout?.status ?? null,
}));
const hasRisk = unfinishedOrders.length > 0 || redeemRecords.length > 0;
return {
id: u.id,
userNo: u.userNo,
nickname: u.nickname,
phone: u.phone,
unfinishedOrders,
redeemRecords,
hasRisk,
};
});
return serializeBigInt({
items,
hasRisk: items.some((i) => i.hasRisk),
total: items.length,
});
}
async batchDeleteUsers(ids: bigint[], confirmRisk: boolean) {
const preview = await this.previewBatchDelete(ids);
if (preview.hasRisk && !confirmRisk) {
const riskyUsers = preview.items.filter((i) => i.hasRisk);
throw new BadRequestException({
message: '所选用户存在未完成订单或核销记录,需二次确认后删除',
code: 'USER_DELETE_RISK',
riskyUsers: riskyUsers.map((u) => ({
id: u.id,
userNo: u.userNo,
unfinishedOrderCount: u.unfinishedOrders.length,
redeemRecordCount: u.redeemRecords.length,
})),
});
}
const userIds = preview.items.map((i) => BigInt(String(i.id)));
await this.prisma.$transaction(async (tx) => {
for (const id of userIds) {
await this.deleteUserInTx(tx, id);
}
});
return {
ok: true,
deleted: userIds.length,
message: '用户及关联业务数据已删除,行为日志已保留',
};
}
async deleteUser(id: bigint) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('用户不存在');
await this.prisma.$transaction(async (tx) => {
await this.deleteUserInTx(tx, id);
});
return {
ok: true,
message: '用户及关联业务数据已删除,行为日志已保留',
};
}
private async deleteUserInTx(tx: Prisma.TransactionClient, id: bigint) {
const user = await tx.user.findUnique({ where: { id } });
if (!user) return;
await tx.user.updateMany({
where: { mergedIntoUserId: id },
data: { mergedIntoUserId: null },
});
const orderIds = (
await tx.order.findMany({ where: { userId: id }, select: { id: true } })
).map((o) => o.id);
@@ -155,11 +271,5 @@ export class AdminUsersService {
}
await tx.user.delete({ where: { id } });
});
return {
ok: true,
message: '用户及关联业务数据已删除,行为日志已保留',
};
}
}
@@ -1,4 +1,5 @@
import { IsArray, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator';
export class UpdateStoreStatusDto {
@IsString()
@@ -307,6 +308,96 @@ export class UpdateOrderStatusDto {
status: string;
}
export class BatchDeleteOrdersDto {
@IsArray()
@IsString({ each: true })
ids: string[];
}
export class BatchDeleteUsersDto {
@IsArray()
@IsString({ each: true })
ids: string[];
}
export class BatchDeleteUsersConfirmDto extends BatchDeleteUsersDto {
@IsBoolean()
confirmRisk: boolean;
}
/** HQ 订单发货(目前仅小飞侠 XFX) */
export class AdminShipOrderDto {
@IsIn(['XFX'])
provider: string;
@IsOptional()
@IsString()
fromName?: string;
@IsOptional()
@IsString()
fromMobile?: string;
@IsOptional()
@IsString()
fromAddress?: string;
@IsOptional()
@IsString()
fromAddressDetail?: string;
@IsOptional()
@IsNumber()
fromLng?: number;
@IsOptional()
@IsNumber()
fromLat?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.01)
weight?: number;
@IsOptional()
@IsIn(['1', '2'])
payMode?: string;
@IsOptional()
@IsString()
remark?: string;
}
export class AdminRedeemDebugCreateTokenDto {
@IsString()
@IsNotEmpty()
userId: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount: number;
@IsOptional()
@IsString()
couponId?: string;
@IsOptional()
@IsString()
storeId?: string;
}
export class AdminRedeemDebugStoreTokenDto {
@IsString()
@IsNotEmpty()
storeId: string;
@IsString()
@IsNotEmpty()
token: string;
}
export class UpdateDeliveryDto {
@IsOptional()
@IsString()
@@ -33,9 +33,12 @@ import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service';
import { RedeemModule } from '../redeem/redeem.module';
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
@Module({
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
controllers: [
AdminDashboardController,
AdminUsersController,
@@ -56,6 +59,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
AdminTicketsController,
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
AdminRedeemDebugController,
],
providers: [
AdminDashboardService,
@@ -73,6 +77,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
AdminTicketsService,
AdminXiaofeixiaService,
AdminProductDetailTemplatesService,
AdminRedeemDebugService,
SuperAdminGuard,
],
})