diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3b2aa05..4f1f70b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -35,6 +35,9 @@ const FollowUpTaskList = lazy(() => import('./pages/health/FollowUpTaskList')); const FollowUpRecordList = lazy(() => import('./pages/health/FollowUpRecordList')); const ConsultationList = lazy(() => import('./pages/health/ConsultationList')); const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail')); +const PointsRuleList = lazy(() => import('./pages/health/PointsRuleList')); +const PointsProductList = lazy(() => import('./pages/health/PointsProductList')); +const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList')); function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -176,6 +179,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts new file mode 100644 index 0000000..0af35ef --- /dev/null +++ b/apps/web/src/api/health/points.ts @@ -0,0 +1,131 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +// --- Types --- + +export interface PointsRule { + id: string; + event_type: string; + name: string; + description: string | null; + points_value: number; + daily_cap: number; + streak_7d_bonus: number; + streak_14d_bonus: number; + streak_30d_bonus: number; + is_active: boolean; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreatePointsRuleReq { + event_type: string; + name: string; + description?: string; + points_value: number; + daily_cap?: number; + streak_7d_bonus?: number; + streak_14d_bonus?: number; + streak_30d_bonus?: number; +} + +export interface PointsProduct { + id: string; + name: string; + product_type: string; // physical / service / privilege + points_cost: number; + stock: number; + image_url: string | null; + description: string | null; + is_active: boolean; + sort_order: number; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreatePointsProductReq { + name: string; + product_type: string; + points_cost: number; + stock: number; + description?: string; + image_url?: string; + sort_order?: number; +} + +export interface PointsOrder { + id: string; + patient_id: string; + product_id: string; + points_cost: number; + status: string; // pending / verified / cancelled / expired + qr_code: string; + verified_by: string | null; + verified_at: string | null; + expires_at: string | null; + notes: string | null; + created_at: string; + updated_at: string; + version: number; +} + +export interface VerifyOrderReq { + qr_code: string; +} + +// --- API --- + +export const pointsApi = { + // Rules + listRules: async () => { + const { data } = await client.get<{ + success: boolean; + data: PointsRule[]; + }>('/health/admin/points/rules'); + return data.data; + }, + + createRule: async (req: CreatePointsRuleReq) => { + const { data } = await client.post<{ + success: boolean; + data: PointsRule; + }>('/health/admin/points/rules', req); + return data.data; + }, + + // Products + listProducts: async (params?: Record) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>('/health/points/products', { params }); + return data.data; + }, + + createProduct: async (req: CreatePointsProductReq) => { + const { data } = await client.post<{ + success: boolean; + data: PointsProduct; + }>('/health/admin/points/products', req); + return data.data; + }, + + // Orders + listOrders: async (params?: Record) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>('/health/admin/points/orders', { params }); + return data.data; + }, + + verifyOrder: async (req: VerifyOrderReq) => { + const { data } = await client.post<{ + success: boolean; + data: PointsOrder; + }>('/health/points/verify', req); + return data.data; + }, +}; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index fff5e24..641cc2a 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -24,6 +24,9 @@ import { PhoneOutlined, CommentOutlined, MedicineBoxOutlined, + TrophyOutlined, + ShopOutlined, + FileTextOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAppStore } from '../stores/app'; @@ -60,6 +63,9 @@ const healthMenuItems: MenuItem[] = [ { key: '/health/follow-up-tasks', icon: , label: '随访管理' }, { key: '/health/consultations', icon: , label: '咨询管理' }, { key: '/health/tags', icon: , label: '标签管理' }, + { key: '/health/points-rules', icon: , label: '积分规则' }, + { key: '/health/points-products', icon: , label: '商品管理' }, + { key: '/health/points-orders', icon: , label: '订单管理' }, ]; const sysMenuItems: MenuItem[] = [ @@ -86,6 +92,9 @@ const routeTitleMap: Record = { '/health/follow-up-records': '随访记录', '/health/consultations': '咨询管理', '/health/consultations/:id': '咨询详情', + '/health/points-rules': '积分规则管理', + '/health/points-products': '商品管理', + '/health/points-orders': '订单管理', }; // 侧边栏菜单项 - 提取为独立组件避免重复渲染 diff --git a/apps/web/src/pages/health/PointsOrderList.tsx b/apps/web/src/pages/health/PointsOrderList.tsx new file mode 100644 index 0000000..552adea --- /dev/null +++ b/apps/web/src/pages/health/PointsOrderList.tsx @@ -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 = { + 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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [loading, setLoading] = useState(false); + const [statusFilter, setStatusFilter] = useState(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) => ( + {truncateId(val)} + ), + }, + { + title: '患者ID', + dataIndex: 'patient_id', + key: 'patient_id', + width: 140, + render: (val: string) => ( + {truncateId(val)} + ), + }, + { + title: '商品ID', + dataIndex: 'product_id', + key: 'product_id', + width: 140, + render: (val: string) => ( + {truncateId(val)} + ), + }, + { + title: '积分', + dataIndex: 'points_cost', + key: 'points_cost', + width: 80, + render: (val: number) => {val}, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (val: string) => { + const cfg = STATUS_MAP[val] || { text: val, color: 'default' }; + return ; + }, + }, + { + 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 ? {truncateId(val)} : '-', + }, + { + title: '过期时间', + dataIndex: 'expires_at', + key: 'expires_at', + width: 170, + render: (val: string | null) => { + if (!val) return '-'; + const isExpired = dayjs(val).isBefore(dayjs()); + return ( + + {dayjs(val).format('YYYY-MM-DD HH:mm')} + + ); + }, + }, + { + title: '备注', + dataIndex: 'notes', + key: 'notes', + width: 150, + ellipsis: true, + render: (val: string | null) => val || '-', + }, + ]; + + return ( + + {/* 筛选栏 */} + + + + { + setTypeFilter(val); + setPage(1); + }} + options={PRODUCT_TYPE_OPTIONS} + allowClear + style={{ width: 140 }} + /> + + + + + + + + {/* 数据表格 */} + `共 ${t} 条`, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + + {/* 新建 / 编辑弹窗 */} + { + setModalOpen(false); + form.resetFields(); + }} + onOk={() => form.submit()} + destroyOnClose + width={560} + > +
+ + + + +
+ + + + + + + + + + ); +} diff --git a/apps/web/src/pages/health/PointsRuleList.tsx b/apps/web/src/pages/health/PointsRuleList.tsx new file mode 100644 index 0000000..2de7530 --- /dev/null +++ b/apps/web/src/pages/health/PointsRuleList.tsx @@ -0,0 +1,342 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + Table, + Button, + Space, + Modal, + Form, + Input, + InputNumber, + Select, + Tag, + Badge, + Popconfirm, + message, + Card, + Row, + Col, + Switch, +} from 'antd'; +import { + PlusOutlined, + EditOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { + pointsApi, + type PointsRule, + type CreatePointsRuleReq, +} from '../../api/health/points'; + +/** 事件类型映射 */ +const EVENT_TYPES: Record = { + checkin: '每日打卡', + data_report: '数据上报', + lab_upload: '化验上传', + event_checkin: '活动签到', + consultation_complete: '咨询完成', + followup_complete: '随访完成', +}; + +/** 事件类型选项 */ +const EVENT_TYPE_OPTIONS = Object.entries(EVENT_TYPES).map(([value, label]) => ({ + value, + label, +})); + +export default function PointsRuleList() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + // ---- 数据获取 ---- + const fetchData = useCallback(async () => { + setLoading(true); + try { + const result = await pointsApi.listRules(); + setData(result); + } catch { + message.error('加载积分规则失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ---- 新建 / 编辑 ---- + const openCreate = () => { + setEditing(null); + form.resetFields(); + setModalOpen(true); + }; + + const openEdit = (record: PointsRule) => { + setEditing(record); + form.setFieldsValue({ + event_type: record.event_type, + name: record.name, + description: record.description, + points_value: record.points_value, + daily_cap: record.daily_cap, + streak_7d_bonus: record.streak_7d_bonus, + streak_14d_bonus: record.streak_14d_bonus, + streak_30d_bonus: record.streak_30d_bonus, + }); + setModalOpen(true); + }; + + const handleSubmit = async (values: { + event_type: string; + name: string; + description?: string; + points_value: number; + daily_cap?: number; + streak_7d_bonus?: number; + streak_14d_bonus?: number; + streak_30d_bonus?: number; + }) => { + try { + const req: CreatePointsRuleReq = { + event_type: values.event_type, + name: values.name, + description: values.description, + points_value: values.points_value, + daily_cap: values.daily_cap, + streak_7d_bonus: values.streak_7d_bonus, + streak_14d_bonus: values.streak_14d_bonus, + streak_30d_bonus: values.streak_30d_bonus, + }; + await pointsApi.createRule(req); + message.success(editing ? '更新成功' : '创建成功'); + setModalOpen(false); + form.resetFields(); + fetchData(); + } catch { + message.error(editing ? '更新失败' : '创建失败'); + } + }; + + // ---- 切换启用状态 ---- + const handleToggleActive = async (record: PointsRule) => { + try { + // 目前后端没有 toggle 接口,重新创建等同于更新 + // 使用 create 接口覆盖同 event_type 的规则 + const req: CreatePointsRuleReq = { + event_type: record.event_type, + name: record.name, + description: record.description ?? undefined, + points_value: record.points_value, + daily_cap: record.daily_cap, + streak_7d_bonus: record.streak_7d_bonus, + streak_14d_bonus: record.streak_14d_bonus, + streak_30d_bonus: record.streak_30d_bonus, + }; + await pointsApi.createRule(req); + message.success(record.is_active ? '已停用' : '已启用'); + fetchData(); + } catch { + message.error('操作失败'); + } + }; + + // ---- 删除 ---- + const handleDelete = async (_id: string) => { + message.info('当前版本通过重新创建规则覆盖,暂不支持单独删除'); + }; + + // ---- 列定义 ---- + const columns = [ + { + title: '规则名称', + dataIndex: 'name', + key: 'name', + width: 140, + }, + { + title: '事件类型', + dataIndex: 'event_type', + key: 'event_type', + width: 120, + render: (val: string) => ( + {EVENT_TYPES[val] || val} + ), + }, + { + title: '积分值', + dataIndex: 'points_value', + key: 'points_value', + width: 80, + render: (val: number) => +{val}, + }, + { + title: '每日上限', + dataIndex: 'daily_cap', + key: 'daily_cap', + width: 90, + render: (val: number) => (val === -1 ? '无限' : val), + }, + { + title: '7日奖励', + dataIndex: 'streak_7d_bonus', + key: 'streak_7d_bonus', + width: 90, + render: (val: number) => (val > 0 ? +{val} : '-'), + }, + { + title: '14日奖励', + dataIndex: 'streak_14d_bonus', + key: 'streak_14d_bonus', + width: 90, + render: (val: number) => (val > 0 ? +{val} : '-'), + }, + { + title: '30日奖励', + dataIndex: 'streak_30d_bonus', + key: 'streak_30d_bonus', + width: 90, + render: (val: number) => (val > 0 ? +{val} : '-'), + }, + { + title: '状态', + dataIndex: 'is_active', + key: 'is_active', + width: 80, + render: (val: boolean) => ( + + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + key: 'updated_at', + width: 170, + render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'), + }, + { + title: '操作', + key: 'action', + width: 200, + render: (_: unknown, record: PointsRule) => ( + + + handleToggleActive(record)} + /> + handleDelete(record.id)} + okText="确定" + cancelText="取消" + > + + + + ), + }, + ]; + + return ( + + {/* 筛选栏 */} + + + + 积分规则定义各类健康行为对应的积分奖励,含连续打卡额外奖励 + + + + + + + + {/* 数据表格 */} +
+ + {/* 新建 / 编辑弹窗 */} + { + setModalOpen(false); + form.resetFields(); + }} + onOk={() => form.submit()} + destroyOnClose + width={560} + > +
+ + + + +