Files
hms/apps/web/src/pages/health/PointsOrderList.tsx
iven 69313a177e
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
feat(web): 健康模块 13 页面按钮级权限控制 — AuthButton 包装
使用 AuthButton 声明式组件包装健康模块全部操作按钮:
- health.patient.manage: PatientList/PatientDetail/PatientTagManage
- health.appointment.manage: AppointmentList
- health.doctor.manage: DoctorList/DoctorSchedule
- health.follow-up.manage: FollowUpTaskList
- health.consultation.manage: ConsultationList/ConsultationDetail
- health.points.manage: OfflineEventList/PointsProductList/PointsOrderList/PointsRuleList
2026-04-25 23:33:32 +08:00

294 lines
7.9 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Badge,
message,
Card,
Row,
Col,
Tag,
} from 'antd';
import {
CheckCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import {
pointsApi,
type PointsOrder,
} from '../../api/health/points';
import { patientApi } from '../../api/health/patients';
import { AuthButton } from '../../components/AuthButton';
/** 订单状态映射 */
const STATUS_MAP: Record<string, { text: string; color: string }> = {
pending: { text: '待核销', color: 'orange' },
verified: { text: '已核销', color: 'green' },
cancelled: { text: '已取消', color: 'red' },
expired: { text: '已过期', color: 'default' },
};
/** 状态筛选选项 */
const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({
value,
label: text,
}));
/** 截断 ID 显示 */
function truncateId(id: string): string {
if (!id) return '-';
return id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id;
}
export default function PointsOrderList() {
const [data, setData] = useState<PointsOrder[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [verifyModalOpen, setVerifyModalOpen] = useState(false);
const [verifyForm] = Form.useForm();
const [verifying, setVerifying] = useState(false);
// 名称缓存
const [nameCache, setNameCache] = useState<Record<string, string>>({});
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
try {
const result = await pointsApi.listOrders({
page: p,
page_size: ps,
status: statusFilter || undefined,
});
setData(result.data);
setTotal(result.total);
// 批量解析患者名称
const patientIds = [...new Set(result.data.map((o) => o.patient_id))];
const missingIds = patientIds.filter((id) => !nameCache[id]);
if (missingIds.length > 0) {
const newNames: Record<string, string> = {};
await Promise.all(
missingIds.map(async (id) => {
try {
const detail = await patientApi.get(id);
newNames[id] = detail.name;
} catch {
newNames[id] = id.slice(0, 8);
}
}),
);
setNameCache((prev) => ({ ...prev, ...newNames }));
}
} catch {
message.error('加载订单列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter, nameCache]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ---- 核销 ----
const openVerifyModal = () => {
verifyForm.resetFields();
setVerifyModalOpen(true);
};
const handleVerify = async (values: { qr_code: string }) => {
setVerifying(true);
try {
const order = await pointsApi.verifyOrder({ qr_code: values.qr_code });
message.success(`核销成功,订单 ${truncateId(order.id)} 已确认`);
setVerifyModalOpen(false);
verifyForm.resetFields();
fetchData(page, pageSize);
} catch {
message.error('核销失败,请检查二维码是否正确');
} finally {
setVerifying(false);
}
};
// ---- 列定义 ----
const columns = [
{
title: '订单号',
dataIndex: 'id',
key: 'id',
width: 140,
render: (val: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{truncateId(val)}</span>
),
},
{
title: '患者',
dataIndex: 'patient_id',
key: 'patient_id',
width: 100,
render: (id: string) => nameCache[id] || id.slice(0, 8),
},
{
title: '商品',
dataIndex: 'product_name',
key: 'product_name',
width: 140,
render: (name: string | null, record: PointsOrder) =>
name || truncateId(record.product_id),
},
{
title: '积分',
dataIndex: 'points_cost',
key: 'points_cost',
width: 80,
render: (val: number) => <span style={{ fontWeight: 600, color: '#d97706' }}>{val}</span>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (val: string) => {
const cfg = STATUS_MAP[val] || { text: val, color: 'default' };
return <Badge status={cfg.color as 'success' | 'default' | 'processing' | 'error' | 'warning'} text={cfg.text} />;
},
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '核销时间',
dataIndex: 'verified_at',
key: 'verified_at',
width: 170,
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '核销人',
dataIndex: 'verified_by',
key: 'verified_by',
width: 100,
render: (val: string | null) => val ? <Tag color="blue">{nameCache[val] || val.slice(0, 8)}</Tag> : '-',
},
{
title: '过期时间',
dataIndex: 'expires_at',
key: 'expires_at',
width: 170,
render: (val: string | null) => {
if (!val) return '-';
const isExpired = dayjs(val).isBefore(dayjs());
return (
<span style={{ color: isExpired ? '#dc2626' : undefined }}>
{dayjs(val).format('YYYY-MM-DD HH:mm')}
</span>
);
},
},
{
title: '备注',
dataIndex: 'notes',
key: 'notes',
width: 150,
ellipsis: true,
render: (val: string | null) => val || '-',
},
];
return (
<Card>
{/* 筛选栏 */}
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
<Col flex="auto">
<Space>
<Select
placeholder="筛选状态"
value={statusFilter}
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
options={STATUS_OPTIONS}
allowClear
style={{ width: 140 }}
/>
</Space>
</Col>
<Col>
<AuthButton code="health.points.manage">
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={openVerifyModal}
>
</Button>
</AuthButton>
</Col>
</Row>
{/* 数据表格 */}
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
{/* 核销弹窗 */}
<Modal
title="核销订单"
open={verifyModalOpen}
onCancel={() => {
setVerifyModalOpen(false);
verifyForm.resetFields();
}}
onOk={() => verifyForm.submit()}
confirmLoading={verifying}
destroyOnClose
width={440}
>
<Form form={verifyForm} layout="vertical" onFinish={handleVerify}>
<Form.Item
name="qr_code"
label="二维码内容"
rules={[{ required: true, message: '请输入或扫码获取二维码内容' }]}
>
<Input.TextArea
rows={3}
placeholder="请输入订单二维码内容,或使用扫码枪扫码"
autoFocus
/>
</Form.Item>
</Form>
</Modal>
</Card>
);
}