feat(health): 日常监测后端 + 积分商城 PC 管理页面 (Chunk 3 V2 迭代)
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

后端 - 日常监测:
- 新增 daily_monitoring 表 (血压/体重/血糖/出入量/备注)
- Entity/DTO/Service/Handler 完整 CRUD
- 唯一约束 (patient_id, record_date) 防重复上报

前端 - 积分商城管理 (3 页面):
- PointsRuleList: 积分规则增删改 + 启用禁用
- PointsProductList: 商品管理 + 库存 + 类型筛选
- PointsOrderList: 订单列表 + 扫码核销
- API 模块 points.ts 对接 6 个管理端接口
- 侧边栏新增积分规则/商品管理/订单管理入口
This commit is contained in:
iven
2026-04-25 17:24:32 +08:00
parent 9901d5ce49
commit eb937d3d02
18 changed files with 1672 additions and 2 deletions

View File

@@ -0,0 +1,271 @@
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';
/** 订单状态映射 */
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 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);
} catch {
message.error('加载订单列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter]);
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: '患者ID',
dataIndex: 'patient_id',
key: 'patient_id',
width: 140,
render: (val: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{truncateId(val)}</span>
),
},
{
title: '商品ID',
dataIndex: 'product_id',
key: 'product_id',
width: 140,
render: (val: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{truncateId(val)}</span>
),
},
{
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: 140,
render: (val: string | null) => val ? <Tag color="blue">{truncateId(val)}</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>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={openVerifyModal}
>
</Button>
</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>
);
}