feat(health): 线下活动管理端 CRUD + 积分统计 API + 前端页面 (Chunk 4)
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

后端:
- 线下活动管理: create/update/delete/list/checkin 5 个管理端接口
- 活动签到自动发放积分 (事务内原子操作)
- 积分统计 API: 总发放/总消耗/总过期/活跃账户/Top10排行

前端:
- OfflineEventList: 活动管理页面 (创建/编辑/删除/状态筛选)
- points.ts 扩展: 线下活动 + 统计 API 方法
- 侧边栏新增线下活动入口
This commit is contained in:
iven
2026-04-25 17:34:54 +08:00
parent eb937d3d02
commit 7b18a7398d
8 changed files with 987 additions and 0 deletions

View File

@@ -38,6 +38,7 @@ 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'));
const OfflineEventList = lazy(() => import('./pages/health/OfflineEventList'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -182,6 +183,7 @@ export default function App() {
<Route path="/health/points-rules" element={<PointsRuleList />} />
<Route path="/health/points-products" element={<PointsProductList />} />
<Route path="/health/points-orders" element={<PointsOrderList />} />
<Route path="/health/offline-events" element={<OfflineEventList />} />
</Routes>
</Suspense>
</ErrorBoundary>

View File

@@ -75,6 +75,49 @@ export interface VerifyOrderReq {
qr_code: string;
}
export interface OfflineEvent {
id: string;
title: string;
description: string | null;
event_date: string;
start_time: string | null;
end_time: string | null;
location: string | null;
points_reward: number;
max_participants: number;
current_participants: number;
status: string; // draft / published / ongoing / completed / cancelled
image_url: string | null;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateOfflineEventReq {
title: string;
description?: string;
event_date: string;
start_time?: string;
end_time?: string;
location?: string;
points_reward?: number;
max_participants?: number;
status?: string;
image_url?: string;
}
export interface PointsStatistics {
total_issued: number;
total_spent: number;
total_expired: number;
active_accounts: number;
top_earners: Array<{
account_id: string;
patient_id: string;
total_earned: number;
}>;
}
// --- API ---
export const pointsApi = {
@@ -128,4 +171,44 @@ export const pointsApi = {
}>('/health/points/verify', req);
return data.data;
},
// Offline Events
listOfflineEvents: async (params?: Record<string, unknown>) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<OfflineEvent>;
}>('/health/admin/offline-events', { params });
return data.data;
},
createOfflineEvent: async (req: CreateOfflineEventReq) => {
const { data } = await client.post<{
success: boolean;
data: OfflineEvent;
}>('/health/admin/offline-events', req);
return data.data;
},
updateOfflineEvent: async (id: string, req: Partial<CreateOfflineEventReq> & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: OfflineEvent;
}>(`/health/admin/offline-events/${id}`, req);
return data.data;
},
deleteOfflineEvent: async (id: string, version: number) => {
await client.delete(`/health/admin/offline-events/${id}`, {
data: { version },
});
},
// Points Statistics
getStatistics: async () => {
const { data } = await client.get<{
success: boolean;
data: PointsStatistics;
}>('/health/admin/points/statistics');
return data.data;
},
};

View File

@@ -66,6 +66,7 @@ const healthMenuItems: MenuItem[] = [
{ key: '/health/points-rules', icon: <TrophyOutlined />, label: '积分规则' },
{ key: '/health/points-products', icon: <ShopOutlined />, label: '商品管理' },
{ key: '/health/points-orders', icon: <FileTextOutlined />, label: '订单管理' },
{ key: '/health/offline-events', icon: <CalendarOutlined />, label: '线下活动' },
];
const sysMenuItems: MenuItem[] = [
@@ -95,6 +96,7 @@ const routeTitleMap: Record<string, string> = {
'/health/points-rules': '积分规则管理',
'/health/points-products': '商品管理',
'/health/points-orders': '订单管理',
'/health/offline-events': '线下活动管理',
};
// 侧边栏菜单项 - 提取为独立组件避免重复渲染

View File

@@ -0,0 +1,398 @@
import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
Select,
Badge,
Popconfirm,
message,
Card,
Row,
Col,
DatePicker,
TimePicker,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import {
pointsApi,
type OfflineEvent,
type CreateOfflineEventReq,
} from '../../api/health/points';
/** 活动状态映射 */
const STATUS_MAP: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' },
published: { text: '已发布', color: 'processing' },
ongoing: { text: '进行中', color: 'success' },
completed: { text: '已结束', color: 'default' },
cancelled: { text: '已取消', color: 'error' },
};
/** 活动状态选项 */
const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({
value,
label: text,
}));
export default function OfflineEventList() {
const [data, setData] = useState<OfflineEvent[]>([]);
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 [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<OfflineEvent | null>(null);
const [form] = Form.useForm();
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
try {
const result = await pointsApi.listOfflineEvents({
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 openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({
points_reward: 0,
max_participants: 0,
status: 'draft',
});
setModalOpen(true);
};
const openEdit = (record: OfflineEvent) => {
setEditing(record);
form.setFieldsValue({
title: record.title,
description: record.description,
event_date: record.event_date ? dayjs(record.event_date) : undefined,
start_time: record.start_time ? dayjs(record.start_time, 'HH:mm') : undefined,
end_time: record.end_time ? dayjs(record.end_time, 'HH:mm') : undefined,
location: record.location,
points_reward: record.points_reward,
max_participants: record.max_participants,
status: record.status,
image_url: record.image_url,
});
setModalOpen(true);
};
const handleSubmit = async (values: {
title: string;
description?: string;
event_date: Dayjs;
start_time?: Dayjs;
end_time?: Dayjs;
location?: string;
points_reward: number;
max_participants: number;
status: string;
image_url?: string;
}) => {
try {
const req: CreateOfflineEventReq = {
title: values.title,
description: values.description,
event_date: values.event_date.format('YYYY-MM-DD'),
start_time: values.start_time ? values.start_time.format('HH:mm') : undefined,
end_time: values.end_time ? values.end_time.format('HH:mm') : undefined,
location: values.location,
points_reward: values.points_reward,
max_participants: values.max_participants,
status: values.status,
image_url: values.image_url,
};
if (editing) {
await pointsApi.updateOfflineEvent(editing.id, { ...req, version: editing.version });
message.success('更新成功');
} else {
await pointsApi.createOfflineEvent(req);
message.success('创建成功');
}
setModalOpen(false);
form.resetFields();
fetchData(page, pageSize);
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
};
// ---- 删除 ----
const handleDelete = async (record: OfflineEvent) => {
try {
await pointsApi.deleteOfflineEvent(record.id, record.version);
message.success('删除成功');
fetchData(page, pageSize);
} catch {
message.error('删除失败');
}
};
// ---- 签到 ----
const handleCheckin = async (_record: OfflineEvent) => {
message.info('签到功能需配合患者选择,将在后续版本完善');
};
// ---- 列定义 ----
const columns = [
{
title: '活动名称',
dataIndex: 'title',
key: 'title',
width: 180,
ellipsis: true,
},
{
title: '活动日期',
dataIndex: 'event_date',
key: 'event_date',
width: 120,
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD') : '-'),
},
{
title: '时间段',
key: 'time_range',
width: 140,
render: (_: unknown, record: OfflineEvent) => {
if (!record.start_time && !record.end_time) return '-';
return `${record.start_time || '--'} ~ ${record.end_time || '--'}`;
},
},
{
title: '地点',
dataIndex: 'location',
key: 'location',
width: 140,
ellipsis: true,
render: (val: string | null) => val || '-',
},
{
title: '积分奖励',
dataIndex: 'points_reward',
key: 'points_reward',
width: 90,
render: (val: number) => <span style={{ fontWeight: 600, color: '#d97706' }}>{val}</span>,
},
{
title: '参与人数',
key: 'participants',
width: 110,
render: (_: unknown, record: OfflineEvent) => {
const max = record.max_participants;
const current = record.current_participants;
const isFull = max > 0 && current >= max;
return (
<span>
<span style={{ color: isFull ? '#dc2626' : undefined, fontWeight: isFull ? 600 : undefined }}>
{current}
</span>
{' / '}
{max === 0 ? '不限' : max}
</span>
);
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (val: string) => {
const info = STATUS_MAP[val] || { text: val, color: 'default' as const };
return <Badge status={info.color as 'default' | 'processing' | 'success' | 'error'} text={info.text} />;
},
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 160,
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
key: 'action',
width: 200,
render: (_: unknown, record: OfflineEvent) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
>
</Button>
<Button
type="link"
size="small"
icon={<CheckCircleOutlined />}
onClick={() => handleCheckin(record)}
disabled={record.status === 'draft' || record.status === 'cancelled'}
>
</Button>
<Popconfirm
title="确定删除该活动?"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
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={<PlusOutlined />} onClick={openCreate}>
</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={editing ? '编辑活动' : '新建活动'}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
width={620}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
name="title"
label="活动名称"
rules={[{ required: true, message: '请输入活动名称' }]}
>
<Input placeholder="如:社区义诊活动" />
</Form.Item>
<Form.Item name="description" label="活动描述">
<Input.TextArea rows={3} placeholder="活动说明" />
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="event_date"
label="活动日期"
rules={[{ required: true, message: '请选择活动日期' }]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择日期" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="start_time" label="开始时间">
<TimePicker style={{ width: '100%' }} format="HH:mm" placeholder="开始" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="end_time" label="结束时间">
<TimePicker style={{ width: '100%' }} format="HH:mm" placeholder="结束" />
</Form.Item>
</Col>
</Row>
<Form.Item name="location" label="活动地点">
<Input placeholder="如:社区活动中心" />
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="points_reward" label="积分奖励" initialValue={0}>
<InputNumber min={0} max={999999} style={{ width: '100%' }} placeholder="0" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="max_participants" label="最大人数" initialValue={0}>
<InputNumber min={0} max={99999} style={{ width: '100%' }} placeholder="0 表示不限" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="status" label="状态" initialValue="draft">
<Select options={STATUS_OPTIONS} />
</Form.Item>
</Col>
</Row>
<Form.Item name="image_url" label="封面图片链接">
<Input placeholder="活动封面图片 URL" />
</Form.Item>
</Form>
</Modal>
</Card>
);
}