Files
dukang/apps/admin-web/src/pages/PendingRedeemPage.tsx
T
2026-07-12 12:00:55 +08:00

267 lines
8.5 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 {
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
REDEEM_PENDING_STATUS_LABELS,
type RedeemPendingItem,
type RedeemPendingStatus,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
PENDING: 'orange',
COMPLETED: 'green',
REJECTED: 'default',
};
export default function PendingRedeemPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
'/admin/redeem-pending',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [acting, setActing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
async function openDetail(id: string) {
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
setDetail(res);
setDrawerOpen(true);
}
async function complete() {
if (!detail) return;
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
message.success('已补核销');
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '补核销失败');
} finally {
setActing(false);
}
}
async function reject() {
if (!detail || !rejectReason.trim()) {
message.error('请填写驳回原因');
return;
}
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: rejectReason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '驳回失败');
} finally {
setActing(false);
}
}
const columns: ColumnsType<RedeemPendingItem> = [
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
{
title: '核销码 ID',
dataIndex: 'redeemToken',
width: 160,
ellipsis: true,
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>,
},
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
{
title: '用户',
width: 120,
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
},
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => ${v}` },
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: RedeemPendingStatus) => (
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
详情
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}>待处理核销(弱网兜底)</Typography.Title>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="pendingNo" label="待处理单号">
<Input allowClear />
</Form.Item>
<Form.Item name="redeemToken" label="核销码 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="storeId" label="门店 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</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: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="待处理核销详情"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail?.status === 'PENDING' && (
<Space>
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
驳回
</Button>
<Button type="primary" loading={acting} onClick={() => void complete()}>
人工补核销
</Button>
</Space>
)
}
>
{detail && (
<>
{detail.photoUrl && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
</div>
)}
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
<Descriptions.Item label="核销码 ID">
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status]}>
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
<Descriptions.Item label="门店">
{detail.store?.name || '—'}{detail.store?.id}
</Descriptions.Item>
<Descriptions.Item label="用户">
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
</Descriptions.Item>
<Descriptions.Item label="关联核销单">
{detail.redeemRecord?.redeemNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="处理时间">
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
<Modal
title="驳回待处理单"
open={rejectOpen}
okText="确认驳回"
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
onOk={() => void reject()}
onCancel={() => setRejectOpen(false)}
>
<Input.TextArea
rows={3}
placeholder="请填写驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}