feat(health): 线下活动管理端 CRUD + 积分统计 API + 前端页面 (Chunk 4)
后端: - 线下活动管理: create/update/delete/list/checkin 5 个管理端接口 - 活动签到自动发放积分 (事务内原子操作) - 积分统计 API: 总发放/总消耗/总过期/活跃账户/Top10排行 前端: - OfflineEventList: 活动管理页面 (创建/编辑/删除/状态筛选) - points.ts 扩展: 线下活动 + 统计 API 方法 - 侧边栏新增线下活动入口
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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': '线下活动管理',
|
||||
};
|
||||
|
||||
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
|
||||
|
||||
398
apps/web/src/pages/health/OfflineEventList.tsx
Normal file
398
apps/web/src/pages/health/OfflineEventList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -259,3 +259,38 @@ pub struct OfflineEventResp {
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 管理端:带版本号的更新/删除包装
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateOfflineEventWithVersion {
|
||||
pub data: UpdateOfflineEventReq,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AdminCheckinReq {
|
||||
pub patient_id: Uuid,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 积分统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PointsStatisticsResp {
|
||||
pub total_issued: i64,
|
||||
pub total_spent: i64,
|
||||
pub total_expired: i64,
|
||||
pub active_accounts: i64,
|
||||
pub top_earners: Vec<TopEarner>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TopEarner {
|
||||
pub account_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub total_earned: i32,
|
||||
}
|
||||
|
||||
@@ -262,6 +262,111 @@ where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 线下活动 — 管理端 CRUD + 签到
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn admin_create_event<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateOfflineEventReq>,
|
||||
) -> Result<Json<ApiResponse<OfflineEventResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = points_service::create_offline_event(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn admin_update_event<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
Json(wrapper): Json<UpdateOfflineEventWithVersion>,
|
||||
) -> Result<Json<ApiResponse<OfflineEventResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut data = wrapper.data;
|
||||
data.sanitize();
|
||||
let result = points_service::update_offline_event(
|
||||
&state, ctx.tenant_id, event_id, Some(ctx.user_id), data, wrapper.version,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn admin_delete_event<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
points_service::delete_offline_event(
|
||||
&state, ctx.tenant_id, event_id, Some(ctx.user_id), wrapper.version,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct AdminListEventsParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn admin_list_events<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<AdminListEventsParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<OfflineEventResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = points_service::admin_list_offline_events(
|
||||
&state, ctx.tenant_id, params.status, page, page_size,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn admin_checkin_event<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
Json(req): Json<AdminCheckinReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
points_service::admin_checkin_event(
|
||||
&state, ctx.tenant_id, event_id, req.patient_id, Some(ctx.user_id),
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 积分统计 — 管理端
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_points_statistics<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<PointsStatisticsResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let result = points_service::get_points_statistics(&state, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 辅助:通过 user_id 解析 patient_id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -340,6 +340,26 @@ impl HealthModule {
|
||||
"/health/admin/points/orders",
|
||||
axum::routing::get(points_handler::admin_list_orders),
|
||||
)
|
||||
// 线下活动 — 管理端
|
||||
.route(
|
||||
"/health/admin/offline-events",
|
||||
axum::routing::get(points_handler::admin_list_events)
|
||||
.post(points_handler::admin_create_event),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/offline-events/{id}",
|
||||
axum::routing::put(points_handler::admin_update_event)
|
||||
.delete(points_handler::admin_delete_event),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/offline-events/{id}/checkin",
|
||||
axum::routing::post(points_handler::admin_checkin_event),
|
||||
)
|
||||
// 积分统计 — 管理端
|
||||
.route(
|
||||
"/health/admin/points/statistics",
|
||||
axum::routing::get(points_handler::get_points_statistics),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -906,3 +906,345 @@ fn event_to_resp(m: offline_event::Model) -> OfflineEventResp {
|
||||
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 线下活动 — 管理端 CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 管理端:创建线下活动
|
||||
pub async fn create_offline_event(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
req: CreateOfflineEventReq,
|
||||
) -> HealthResult<OfflineEventResp> {
|
||||
let now = Utc::now();
|
||||
let active = offline_event::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
title: Set(req.title),
|
||||
description: Set(req.description),
|
||||
event_date: Set(req.event_date),
|
||||
start_time: Set(req.start_time),
|
||||
end_time: Set(req.end_time),
|
||||
location: Set(req.location),
|
||||
points_reward: Set(req.points_reward.unwrap_or(0)),
|
||||
max_participants: Set(req.max_participants.unwrap_or(0)),
|
||||
current_participants: Set(0),
|
||||
status: Set("draft".to_string()),
|
||||
image_url: Set(req.image_url),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
let m = active.insert(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "offline_event.created", "offline_event")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(event_to_resp(m))
|
||||
}
|
||||
|
||||
/// 管理端:更新线下活动
|
||||
pub async fn update_offline_event(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
event_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
req: UpdateOfflineEventReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<OfflineEventResp> {
|
||||
let model = offline_event::Entity::find()
|
||||
.filter(offline_event::Column::Id.eq(event_id))
|
||||
.filter(offline_event::Column::TenantId.eq(tenant_id))
|
||||
.filter(offline_event::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::OfflineEventNotFound)?;
|
||||
|
||||
let next_ver = check_version(expected_version, model.version)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let mut active: offline_event::ActiveModel = model.into();
|
||||
if let Some(title) = req.title { active.title = Set(title); }
|
||||
if let Some(description) = req.description { active.description = Set(Some(description)); }
|
||||
if let Some(event_date) = req.event_date { active.event_date = Set(event_date); }
|
||||
if let Some(start_time) = req.start_time { active.start_time = Set(Some(start_time)); }
|
||||
if let Some(end_time) = req.end_time { active.end_time = Set(Some(end_time)); }
|
||||
if let Some(location) = req.location { active.location = Set(Some(location)); }
|
||||
if let Some(points_reward) = req.points_reward { active.points_reward = Set(points_reward); }
|
||||
if let Some(max_participants) = req.max_participants { active.max_participants = Set(max_participants); }
|
||||
if let Some(status) = req.status { active.status = Set(status); }
|
||||
if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); }
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "offline_event.updated", "offline_event")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(event_to_resp(m))
|
||||
}
|
||||
|
||||
/// 管理端:软删除线下活动
|
||||
pub async fn delete_offline_event(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
event_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<()> {
|
||||
let model = offline_event::Entity::find()
|
||||
.filter(offline_event::Column::Id.eq(event_id))
|
||||
.filter(offline_event::Column::TenantId.eq(tenant_id))
|
||||
.filter(offline_event::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::OfflineEventNotFound)?;
|
||||
|
||||
let _next_ver = check_version(expected_version, model.version)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let mut active: offline_event::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(active.version.unwrap() + 1);
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "offline_event.deleted", "offline_event")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 管理端:分页列出所有线下活动(可按状态筛选)
|
||||
pub async fn admin_list_offline_events(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
status_filter: Option<String>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<PaginatedResponse<OfflineEventResp>> {
|
||||
let limit = page_size.min(100);
|
||||
let offset = page.saturating_sub(1) * limit;
|
||||
|
||||
let mut query = offline_event::Entity::find()
|
||||
.filter(offline_event::Column::TenantId.eq(tenant_id))
|
||||
.filter(offline_event::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref status) = status_filter {
|
||||
query = query.filter(offline_event::Column::Status.eq(status.as_str()));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&state.db).await?;
|
||||
let models = query
|
||||
.order_by_desc(offline_event::Column::EventDate)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let total_pages = total.div_ceil(limit.max(1));
|
||||
let data = models.into_iter().map(event_to_resp).collect();
|
||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||
}
|
||||
|
||||
/// 管理端:扫码签到 + 自动发积分
|
||||
pub async fn admin_checkin_event(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
event_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
) -> HealthResult<()> {
|
||||
// 1. 查找活动
|
||||
let event = offline_event::Entity::find()
|
||||
.filter(offline_event::Column::Id.eq(event_id))
|
||||
.filter(offline_event::Column::TenantId.eq(tenant_id))
|
||||
.filter(offline_event::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::OfflineEventNotFound)?;
|
||||
|
||||
// 2. 查找报名记录
|
||||
let reg = offline_event_registration::Entity::find()
|
||||
.filter(offline_event_registration::Column::TenantId.eq(tenant_id))
|
||||
.filter(offline_event_registration::Column::EventId.eq(event_id))
|
||||
.filter(offline_event_registration::Column::PatientId.eq(patient_id))
|
||||
.filter(offline_event_registration::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::Validation("该患者未报名此活动".into()))?;
|
||||
|
||||
if reg.status == "checked_in" {
|
||||
return Err(HealthError::Validation("该患者已签到".into()));
|
||||
}
|
||||
|
||||
// 3. 事务:签到 + 发积分
|
||||
let txn = state.db.begin().await?;
|
||||
let now = Utc::now();
|
||||
|
||||
// 更新报名记录状态
|
||||
let mut reg_active: offline_event_registration::ActiveModel = reg.into();
|
||||
reg_active.status = Set("checked_in".to_string());
|
||||
reg_active.checked_in_at = Set(Some(now));
|
||||
reg_active.checked_in_by = Set(operator_id);
|
||||
reg_active.updated_at = Set(now);
|
||||
reg_active.updated_by = Set(operator_id);
|
||||
reg_active.version = Set(reg_active.version.unwrap() + 1);
|
||||
let updated_reg = reg_active.update(&txn).await?;
|
||||
|
||||
// 4. 如果活动有积分奖励且尚未发放,则发放积分
|
||||
if event.points_reward > 0 && !updated_reg.points_granted {
|
||||
let acc = get_or_create_account(&txn, tenant_id, patient_id).await?;
|
||||
let next_ver = check_version(acc.version, acc.version).unwrap_or(acc.version + 1);
|
||||
|
||||
// 写入积分流水
|
||||
let txn_record = points_transaction::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
account_id: Set(acc.id),
|
||||
r#type: Set("earn".to_string()),
|
||||
amount: Set(event.points_reward),
|
||||
remaining_amount: Set(event.points_reward),
|
||||
status: Set("active".to_string()),
|
||||
expires_at: Set(Some(now + Duration::days(365))),
|
||||
balance_after: Set(acc.balance + event.points_reward),
|
||||
rule_id: Set(None),
|
||||
order_id: Set(None),
|
||||
description: Set(Some(format!("线下活动签到奖励「{}」: +{}", event.title, event.points_reward))),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
txn_record.insert(&txn).await?;
|
||||
|
||||
// 更新账户余额
|
||||
let mut acc_active: points_account::ActiveModel = acc.into();
|
||||
acc_active.balance = Set(acc_active.balance.unwrap() + event.points_reward);
|
||||
acc_active.total_earned = Set(acc_active.total_earned.unwrap() + event.points_reward);
|
||||
acc_active.updated_at = Set(now);
|
||||
acc_active.updated_by = Set(operator_id);
|
||||
acc_active.version = Set(next_ver);
|
||||
acc_active.update(&txn).await?;
|
||||
|
||||
// 标记积分已发放
|
||||
let mut reg_active2: offline_event_registration::ActiveModel = updated_reg.into();
|
||||
reg_active2.points_granted = Set(true);
|
||||
reg_active2.updated_at = Set(now);
|
||||
reg_active2.version = Set(reg_active2.version.unwrap() + 1);
|
||||
reg_active2.update(&txn).await?;
|
||||
}
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "offline_event.checked_in", "offline_event_registration")
|
||||
.with_resource_id(event_id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 积分统计 — 管理端
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 管理端:积分统计汇总
|
||||
pub async fn get_points_statistics(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
) -> HealthResult<PointsStatisticsResp> {
|
||||
use sea_orm::FromQueryResult;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct AggRow {
|
||||
total_issued: Option<i64>,
|
||||
total_spent: Option<i64>,
|
||||
total_expired: Option<i64>,
|
||||
active_accounts: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct TopEarnerRow {
|
||||
id: Uuid,
|
||||
patient_id: Uuid,
|
||||
total_earned: Option<i32>,
|
||||
}
|
||||
|
||||
// 聚合查询:总发放/总消费/总过期/活跃账户数
|
||||
let agg_sql = r#"
|
||||
SELECT
|
||||
COALESCE(SUM(total_earned), 0) AS total_issued,
|
||||
COALESCE(SUM(total_spent), 0) AS total_spent,
|
||||
COALESCE(SUM(total_expired), 0) AS total_expired,
|
||||
COUNT(*) AS active_accounts
|
||||
FROM points_account
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
"#;
|
||||
let agg = AggRow::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
agg_sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.unwrap_or(AggRow {
|
||||
total_issued: Some(0),
|
||||
total_spent: Some(0),
|
||||
total_expired: Some(0),
|
||||
active_accounts: Some(0),
|
||||
});
|
||||
|
||||
// Top 10 积分获取者
|
||||
let top_sql = r#"
|
||||
SELECT id, patient_id, total_earned
|
||||
FROM points_account
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY total_earned DESC
|
||||
LIMIT 10
|
||||
"#;
|
||||
let top_rows = TopEarnerRow::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
top_sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let top_earners = top_rows.into_iter().map(|r| TopEarner {
|
||||
account_id: r.id,
|
||||
patient_id: r.patient_id,
|
||||
total_earned: r.total_earned.unwrap_or(0),
|
||||
}).collect();
|
||||
|
||||
Ok(PointsStatisticsResp {
|
||||
total_issued: agg.total_issued.unwrap_or(0),
|
||||
total_spent: agg.total_spent.unwrap_or(0),
|
||||
total_expired: agg.total_expired.unwrap_or(0),
|
||||
active_accounts: agg.active_accounts.unwrap_or(0),
|
||||
top_earners,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user