Files
dukang/apps/admin-web/src/pages/RedeemRecordsPage.tsx
T
jacy da19c39965 feat(admin): v3.5.18 用户详情明文手机、任务关联版本与核销快链
HQ 后台:用户/核销记录展示完整手机号;任务列表与编辑可关联版本;账单核销单号与券号/用户可快链;门店账单日标明为出账自然日;技术支持操作按钮右对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:58:08 +08:00

264 lines
8.0 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 { 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>
);
}