da19c39965
HQ 后台:用户/核销记录展示完整手机号;任务列表与编辑可关联版本;账单核销单号与券号/用户可快链;门店账单日标明为出账自然日;技术支持操作按钮右对齐。 Co-authored-by: Cursor <cursoragent@cursor.com>
264 lines
8.0 KiB
TypeScript
264 lines
8.0 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||
import { request } from '../lib/api';
|
||
import { fmtTime } from '../lib/constants';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||
import { AdminListHeader } from '../components/AdminListHeader';
|
||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||
|
||
|
||
type Row = {
|
||
id: string;
|
||
redeemNo: string;
|
||
amount: number;
|
||
settleAmount: number;
|
||
channel?: RedeemChannel;
|
||
createdAt: string;
|
||
isTest?: boolean;
|
||
user?: {
|
||
id?: string;
|
||
userNo: string;
|
||
phone: string | null;
|
||
nickname?: string | null;
|
||
hqRemark?: string | null;
|
||
};
|
||
store?: { name: string; cityName: string };
|
||
coupon?: { id?: string; couponNo: string };
|
||
};
|
||
|
||
function formatNicknameWithRemark(user?: {
|
||
nickname?: string | null;
|
||
hqRemark?: string | null;
|
||
} | null) {
|
||
const name = user?.nickname?.trim() || '—';
|
||
const remark = user?.hqRemark?.trim();
|
||
return remark ? `${name}(${remark})` : name;
|
||
}
|
||
|
||
export default function RedeemRecordsPage() {
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
||
const [form] = Form.useForm();
|
||
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||
const init: Record<string, string | boolean> = {};
|
||
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
|
||
return init;
|
||
});
|
||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||
'/admin/redeem-records',
|
||
() => {
|
||
const qs = new URLSearchParams();
|
||
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
|
||
if (filters.storeId) qs.set('storeId', String(filters.storeId));
|
||
if (filters.channel) qs.set('channel', String(filters.channel));
|
||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||
return qs;
|
||
},
|
||
[filters],
|
||
);
|
||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const deepLinkOpenedRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
|
||
}, [form, initialRedeemNo]);
|
||
|
||
async function openDetail(id: string) {
|
||
setDetailLoading(true);
|
||
setDrawerOpen(true);
|
||
try {
|
||
setDetail(await request(`/admin/redeem-records/${id}`));
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return;
|
||
const first = data?.items?.[0];
|
||
if (first && String(first.redeemNo) === initialRedeemNo) {
|
||
deepLinkOpenedRef.current = true;
|
||
void openDetail(first.id);
|
||
}
|
||
}, [data, initialRedeemNo, loading]);
|
||
|
||
const baseColumns: ColumnsType<Row> = [
|
||
{
|
||
title: '核销号',
|
||
dataIndex: 'redeemNo',
|
||
width: 200,
|
||
render: (v, row) => (
|
||
<span>
|
||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||
{row.isTest ? (
|
||
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||
测试
|
||
</Tag>
|
||
) : null}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
title: '方式',
|
||
dataIndex: 'channel',
|
||
width: 110,
|
||
render: (v: RedeemChannel | undefined) => {
|
||
const channel = v === 'PHONE' ? 'PHONE' : 'SCAN';
|
||
return (
|
||
<Tag color={channel === 'PHONE' ? 'purple' : 'blue'}>
|
||
{REDEEM_CHANNEL_LABELS[channel]}
|
||
</Tag>
|
||
);
|
||
},
|
||
},
|
||
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) =>
|
||
row.user?.id ? (
|
||
<AdminPrimaryLink
|
||
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
|
||
>
|
||
{v || '—'}
|
||
</AdminPrimaryLink>
|
||
) : (
|
||
v || '—'
|
||
),
|
||
},
|
||
{
|
||
title: '用户昵称',
|
||
dataIndex: ['user', 'nickname'],
|
||
width: 160,
|
||
render: (_: string | null | undefined, row) =>
|
||
row.user?.id ? (
|
||
<AdminPrimaryLink
|
||
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
|
||
>
|
||
{formatNicknameWithRemark(row.user)}
|
||
</AdminPrimaryLink>
|
||
) : (
|
||
formatNicknameWithRemark(row.user)
|
||
),
|
||
},
|
||
{
|
||
title: '用户手机',
|
||
dataIndex: ['user', 'phone'],
|
||
width: 120,
|
||
render: (v: string | null | undefined) => v || '—',
|
||
},
|
||
{ title: '门店', dataIndex: ['store', 'name'] },
|
||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
||
{
|
||
title: '券号',
|
||
dataIndex: ['coupon', 'couponNo'],
|
||
width: 160,
|
||
render: (v: string | undefined, row) =>
|
||
v ? (
|
||
<AdminPrimaryLink
|
||
onClick={() =>
|
||
navigate(`/benefit/coupons?couponNo=${encodeURIComponent(v)}`, {
|
||
state: row.coupon?.id ? { openCouponId: String(row.coupon.id) } : undefined,
|
||
})
|
||
}
|
||
>
|
||
{v}
|
||
</AdminPrimaryLink>
|
||
) : (
|
||
'—'
|
||
),
|
||
},
|
||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||
{
|
||
title: '操作',
|
||
width: 80,
|
||
render: (_, row) => (
|
||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||
详情
|
||
</Button>
|
||
),
|
||
},
|
||
];
|
||
|
||
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-records', baseColumns, { page, pageSize });
|
||
|
||
return (
|
||
<div>
|
||
{settingsModal}
|
||
<AdminListHeader title="核销记录" settings={settingsButton} />
|
||
<Form
|
||
form={form}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
onFinish={(v) => {
|
||
setFilters(v);
|
||
setPage(1);
|
||
}}
|
||
>
|
||
<Form.Item name="redeemNo" label="核销号">
|
||
<Input allowClear />
|
||
</Form.Item>
|
||
<Form.Item name="storeId" label="门店ID">
|
||
<Input allowClear />
|
||
</Form.Item>
|
||
<Form.Item name="channel" label="方式">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 140 }}
|
||
options={[
|
||
{ value: 'SCAN', label: '扫码核销' },
|
||
{ value: 'PHONE', label: '手机号核销' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="excludeTest" valuePropName="checked">
|
||
<Checkbox>过滤测试账号</Checkbox>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit">
|
||
查询
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
<Table
|
||
rowKey="id"
|
||
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);
|
||
},
|
||
}}
|
||
/>
|
||
<Drawer
|
||
title="核销详情"
|
||
width={640}
|
||
open={drawerOpen}
|
||
onClose={() => {
|
||
setDrawerOpen(false);
|
||
setDetail(null);
|
||
}}
|
||
destroyOnClose
|
||
>
|
||
{detailLoading ? (
|
||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||
) : detail ? (
|
||
<RedeemRecordDetailDescriptions detail={detail} />
|
||
) : null}
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|