diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 4f1f70b..fbc959d 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts
index 0af35ef..86e3a12 100644
--- a/apps/web/src/api/health/points.ts
+++ b/apps/web/src/api/health/points.ts
@@ -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) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PaginatedResponse;
+ }>('/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 & { 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;
+ },
};
diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx
index 641cc2a..84c53ad 100644
--- a/apps/web/src/layouts/MainLayout.tsx
+++ b/apps/web/src/layouts/MainLayout.tsx
@@ -66,6 +66,7 @@ const healthMenuItems: MenuItem[] = [
{ key: '/health/points-rules', icon: , label: '积分规则' },
{ key: '/health/points-products', icon: , label: '商品管理' },
{ key: '/health/points-orders', icon: , label: '订单管理' },
+ { key: '/health/offline-events', icon: , label: '线下活动' },
];
const sysMenuItems: MenuItem[] = [
@@ -95,6 +96,7 @@ const routeTitleMap: Record = {
'/health/points-rules': '积分规则管理',
'/health/points-products': '商品管理',
'/health/points-orders': '订单管理',
+ '/health/offline-events': '线下活动管理',
};
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
diff --git a/apps/web/src/pages/health/OfflineEventList.tsx b/apps/web/src/pages/health/OfflineEventList.tsx
new file mode 100644
index 0000000..bb2f183
--- /dev/null
+++ b/apps/web/src/pages/health/OfflineEventList.tsx
@@ -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 = {
+ 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([]);
+ 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 [modalOpen, setModalOpen] = useState(false);
+ const [editing, setEditing] = useState(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) => {val},
+ },
+ {
+ 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 (
+
+
+ {current}
+
+ {' / '}
+ {max === 0 ? '不限' : max}
+
+ );
+ },
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ width: 90,
+ render: (val: string) => {
+ const info = STATUS_MAP[val] || { text: val, color: 'default' as const };
+ return ;
+ },
+ },
+ {
+ 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) => (
+
+ }
+ onClick={() => openEdit(record)}
+ >
+ 编辑
+
+ }
+ onClick={() => handleCheckin(record)}
+ disabled={record.status === 'draft' || record.status === 'cancelled'}
+ >
+ 签到
+
+ handleDelete(record)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ {/* 筛选栏 */}
+
+
+
+
+
+
+ } onClick={openCreate}>
+ 新建活动
+
+
+
+
+ {/* 数据表格 */}
+ `共 ${t} 条`,
+ onChange: (p, ps) => {
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ {/* 新建 / 编辑弹窗 */}
+ {
+ setModalOpen(false);
+ form.resetFields();
+ }}
+ onOk={() => form.submit()}
+ destroyOnClose
+ width={620}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/crates/erp-health/src/dto/points_dto.rs b/crates/erp-health/src/dto/points_dto.rs
index 0bafa8b..43fa5ab 100644
--- a/crates/erp-health/src/dto/points_dto.rs
+++ b/crates/erp-health/src/dto/points_dto.rs
@@ -259,3 +259,38 @@ pub struct OfflineEventResp {
pub updated_at: chrono::DateTime,
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,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct TopEarner {
+ pub account_id: Uuid,
+ pub patient_id: Uuid,
+ pub total_earned: i32,
+}
diff --git a/crates/erp-health/src/handler/points_handler.rs b/crates/erp-health/src/handler/points_handler.rs
index 974fe7b..e3460a3 100644
--- a/crates/erp-health/src/handler/points_handler.rs
+++ b/crates/erp-health/src/handler/points_handler.rs
@@ -262,6 +262,111 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static,
Ok(Json(ApiResponse::ok(result)))
}
+// ---------------------------------------------------------------------------
+// 线下活动 — 管理端 CRUD + 签到
+// ---------------------------------------------------------------------------
+
+pub async fn admin_create_event(
+ State(state): State,
+ Extension(ctx): Extension,
+ Json(req): Json,
+) -> Result>, AppError>
+where HealthState: FromRef, 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(event_id): Path,
+ Json(wrapper): Json,
+) -> Result>, AppError>
+where HealthState: FromRef, 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(event_id): Path,
+ Json(wrapper): Json,
+) -> Result>, AppError>
+where HealthState: FromRef, 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,
+ pub page_size: Option,
+ pub status: Option,
+}
+
+pub async fn admin_list_events(
+ State(state): State,
+ Extension(ctx): Extension,
+ Query(params): Query,
+) -> Result>>, AppError>
+where HealthState: FromRef, 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(event_id): Path,
+ Json(req): Json,
+) -> Result>, AppError>
+where HealthState: FromRef, 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(
+ State(state): State,
+ Extension(ctx): Extension,
+) -> Result>, AppError>
+where HealthState: FromRef, 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
// ---------------------------------------------------------------------------
diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs
index f5cb753..4b4e2a6 100644
--- a/crates/erp-health/src/module.rs
+++ b/crates/erp-health/src/module.rs
@@ -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),
+ )
}
}
diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs
index 9bf62fb..eef6a81 100644
--- a/crates/erp-health/src/service/points_service.rs
+++ b/crates/erp-health/src/service/points_service.rs
@@ -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,
+ req: CreateOfflineEventReq,
+) -> HealthResult {
+ 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,
+ req: UpdateOfflineEventReq,
+ 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();
+ 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,
+ 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,
+ page: u64,
+ page_size: u64,
+) -> HealthResult> {
+ 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,
+) -> 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 {
+ use sea_orm::FromQueryResult;
+
+ #[derive(Debug, FromQueryResult)]
+ struct AggRow {
+ total_issued: Option,
+ total_spent: Option,
+ total_expired: Option,
+ active_accounts: Option,
+ }
+
+ #[derive(Debug, FromQueryResult)]
+ struct TopEarnerRow {
+ id: Uuid,
+ patient_id: Uuid,
+ total_earned: Option,
+ }
+
+ // 聚合查询:总发放/总消费/总过期/活跃账户数
+ 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,
+ })
+}