Files
dukang/apps/admin-web/src/pages/BenefitCouponsPage.tsx
T
jacy fed8ff3d3a fix(admin): 列设置靠右并支持订单状态多选
HQ 列表主操作居右、列设置贴最右侧;订单筛选状态可多选,导出同步过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:19:49 +08:00

512 lines
17 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 { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, DELIVERY_TYPE_LABELS, fmtTime } from '../lib/constants';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type CouponOrder = {
id?: string;
orderNo?: string;
productName?: string | null;
productSpec?: string | null;
quantity?: number | null;
saleUnit?: string | null;
deliveryType?: string | null;
payAmount?: number | string | null;
};
type Row = {
id: string;
couponNo: string;
totalAmount: number;
balance: number;
usedAmount: number;
status: string;
sourceProduct: string;
createdAt: string;
user?: { id?: string; userNo: string; phone: string | null };
order?: CouponOrder | null;
};
function saleUnitLabel(unit?: string | null) {
if (unit === 'BOX') return '箱';
if (unit === 'BOTTLE') return '瓶';
return '';
}
function formatPayAmount(v?: number | string | null) {
if (v == null || v === '') return '';
const n = Number(v);
return Number.isFinite(n) ? ${n.toFixed(2)}` : '';
}
/** 来源:有订单时展示商品名、规格、数量、配送方式、金额;手动发放沿用 sourceProduct */
function formatCouponSource(row: { sourceProduct?: string; order?: CouponOrder | null }) {
const order = row.order;
if (!order?.orderNo && !order?.productName) {
return row.sourceProduct || '—';
}
const unit = saleUnitLabel(order.saleUnit);
const qty =
order.quantity != null ? `${order.quantity}${unit}` : '';
const delivery = DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '';
const amount = formatPayAmount(order.payAmount);
const parts = [
order.productName || row.sourceProduct,
order.productSpec,
qty,
delivery,
amount,
].filter((p) => p != null && String(p).trim() !== '');
return parts.join(' / ') || row.sourceProduct || '—';
}
type CouponRedeemRecord = {
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
couponAmount?: number;
role?: 'PRIMARY' | 'SECONDARY';
createdAt: string;
store?: { id: string; name: string; cityName?: string | null } | null;
};
type CouponRedeemSummary = {
couponNo: string;
totalAmount: number;
usedAmount: number;
balance: number;
status: string;
redeemCount: number;
redeemRecordSum: number;
};
type CouponDetail = Row & {
redeemSummary?: CouponRedeemSummary | null;
redeemRecords?: CouponRedeemRecord[];
};
export default function BenefitCouponsPage() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [grantOpen, setGrantOpen] = useState(false);
const [granting, setGranting] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/benefit/coupons',
() => {
const qs = new URLSearchParams();
if (filters.couponNo) qs.set('couponNo', filters.couponNo);
if (filters.status) qs.set('status', filters.status);
if (filters.userId) qs.set('userId', filters.userId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<CouponDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
try {
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
setRedeemDetail(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载核销详情失败');
setRedeemDrawerOpen(false);
} finally {
setRedeemDetailLoading(false);
}
}
const baseColumns: ColumnsType<Row> = [
{
title: '券号',
dataIndex: 'couponNo',
width: 200,
ellipsis: false,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '用户',
dataIndex: ['user', 'userNo'],
width: 120,
ellipsis: false,
render: (v: string | undefined, row) =>
row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
{v}
</AdminPrimaryLink>
) : (
v || '—'
),
},
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
{
title: '订单',
dataIndex: ['order', 'orderNo'],
width: 180,
ellipsis: false,
render: (v: string | undefined) =>
v ? (
<AdminPrimaryLink onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}>
{v}
</AdminPrimaryLink>
) : (
'—'
),
},
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => ${v}` },
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => ${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
{
title: '来源',
dataIndex: 'sourceProduct',
width: 360,
ellipsis: false,
render: (_: string, row) => formatCouponSource(row),
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
详情
</Button>
),
},
];
async function handleGrant(values: AdminBenefitGrantRequest) {
setGranting(true);
try {
await request('/admin/benefit/coupons/grant', {
method: 'POST',
body: JSON.stringify({
phone: values.phone.trim(),
amount: values.amount,
remark: values.remark?.trim() || undefined,
}),
});
message.success('权益已发放');
setGrantOpen(false);
grantForm.resetFields();
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '发放失败');
} finally {
setGranting(false);
}
}
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-coupons', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<AdminListHeader
title="好客权益券"
settings={settingsButton}
actions={
<Button type="primary" onClick={() => setGrantOpen(true)}>
手动发放
</Button>
}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="couponNo" label="券号">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(COUPON_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title="手动发放好客权益"
open={grantOpen}
onCancel={() => setGrantOpen(false)}
onOk={() => grantForm.submit()}
confirmLoading={granting}
destroyOnClose
>
<Form form={grantForm} layout="vertical" onFinish={(v) => void handleGrant(v)}>
<Form.Item
name="phone"
label="用户手机号"
rules={[
{ required: true, message: '请输入用户手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
]}
>
<Input placeholder="已注册 C 端用户的手机号" maxLength={11} />
</Form.Item>
<Form.Item
name="amount"
label="权益金额(元)"
rules={[{ required: true, message: '请输入权益金额' }]}
>
<InputNumber
min={0.01}
max={999999.99}
precision={2}
style={{ width: '100%' }}
placeholder="发放金额"
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={200} placeholder="可选,默认「总部手动发放」" />
</Form.Item>
</Form>
</Modal>
<Drawer
title="权益券详情"
width={980}
styles={{ body: { paddingBottom: 24 } }}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail &&
detail.status !== 'VOID' && (
<Popconfirm
title="确认作废此券?"
onConfirm={async () => {
await request(`/admin/benefit/coupons/${detail.id}/void`, { method: 'POST' });
message.success('已作废');
setDrawerOpen(false);
void reload();
}}
>
<Button danger>作废</Button>
</Popconfirm>
)
}
>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
<Descriptions.Item label="用户">
{detail.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(detail.user!.id) } })}
>
{detail.user?.userNo}
</AdminPrimaryLink>
) : (
(detail.user?.userNo ?? '—')
)}
</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">
{detail.order?.orderNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/orders?orderNo=${encodeURIComponent(detail.order!.orderNo!)}`)
}
>
{detail.order.orderNo}
</AdminPrimaryLink>
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">
{COUPON_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
权益核销
</Typography.Title>
{detail.redeemSummary ? (
<>
<Descriptions column={2} bordered size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="权益券号">{detail.redeemSummary.couponNo}</Descriptions.Item>
<Descriptions.Item label="券状态">{detail.redeemSummary.status}</Descriptions.Item>
<Descriptions.Item label="权益总额">
¥{Number(detail.redeemSummary.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="已核销">
<Typography.Text type="danger" strong>
¥{Number(detail.redeemSummary.usedAmount).toFixed(2)}
</Typography.Text>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{detail.redeemSummary.redeemCount} 笔核销单)
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="剩余余额">
¥{Number(detail.redeemSummary.balance).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销单合计额">
¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)}
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
(本券分摊合计)
</Typography.Text>
</Descriptions.Item>
</Descriptions>
<Table
size="small"
rowKey="id"
pagination={false}
tableLayout="fixed"
locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []}
columns={[
{ title: '核销号', dataIndex: 'redeemNo' },
{
title: '角色',
dataIndex: 'role',
width: 72,
render: (v: string | undefined) =>
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
},
{
title: '门店',
dataIndex: ['store', 'name'],
render: (v: string | undefined, row: CouponRedeemRecord) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
},
{
title: '本券分摊',
dataIndex: 'couponAmount',
width: 96,
render: (v: number | undefined, row: CouponRedeemRecord) =>
${Number(v ?? row.amount).toFixed(2)}`,
},
{
title: '核销总额',
dataIndex: 'amount',
width: 96,
render: (v: number) => ${Number(v).toFixed(2)}`,
},
{
title: '结算额',
dataIndex: 'settleAmount',
width: 96,
render: (v: number) => ${Number(v).toFixed(2)}`,
},
{
title: '时间',
dataIndex: 'createdAt',
width: 148,
render: (v: string) => fmtTime(v),
},
{
title: '操作',
width: 64,
render: (_: unknown, row: CouponRedeemRecord) => (
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
详情
</Button>
),
},
]}
/>
</>
) : (
<Typography.Text type="secondary">暂无核销摘要</Typography.Text>
)}
</>
)}
</Drawer>
<Drawer
title="核销单详情"
width={640}
open={redeemDrawerOpen}
onClose={() => {
setRedeemDrawerOpen(false);
setRedeemDetail(null);
}}
destroyOnClose
>
{redeemDetailLoading ? (
<Typography.Text type="secondary">加载中…</Typography.Text>
) : redeemDetail ? (
<RedeemRecordDetailDescriptions detail={redeemDetail} />
) : null}
</Drawer>
</div>
);
}