diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index 1006d8f..d31156b 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -38,6 +38,7 @@ export default defineAppConfig({ 'pages/doctor/followup/detail/index', 'pages/doctor/report/index', 'pages/doctor/report/detail/index', + 'pages/events/index', ], tabBar: { color: '#94A3B8', diff --git a/apps/miniprogram/src/pages/events/index.scss b/apps/miniprogram/src/pages/events/index.scss new file mode 100644 index 0000000..5f45bc9 --- /dev/null +++ b/apps/miniprogram/src/pages/events/index.scss @@ -0,0 +1,119 @@ +.events-page { + min-height: 100vh; + background: #f0f4f8; + padding-bottom: 120px; +} + +.events-header { + background: linear-gradient(135deg, #0891b2, #06b6d4); + padding: 40px 32px; + color: #fff; + + &__title { + font-size: 40px; + font-weight: 700; + display: block; + margin-bottom: 8px; + } + + &__subtitle { + font-size: 26px; + opacity: 0.85; + } +} + +.event-list { + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.event-card { + background: #fff; + border-radius: 16px; + padding: 28px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + &__status { + padding: 4px 14px; + border-radius: 12px; + font-size: 22px; + font-weight: 500; + } + + &__points { + font-size: 28px; + font-weight: 700; + color: #f59e0b; + } + + &__title { + font-size: 30px; + font-weight: 600; + color: #0f172a; + display: block; + margin-bottom: 12px; + } + + &__desc { + font-size: 26px; + color: #64748b; + display: block; + margin-bottom: 16px; + line-height: 1.5; + } + + &__info { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; + } + + &__date { + font-size: 24px; + color: #475569; + } + + &__location { + font-size: 24px; + color: #94a3b8; + } + + &__footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 16px; + border-top: 1px solid #f1f5f9; + } + + &__participants { + font-size: 24px; + color: #94a3b8; + } + + &__btn { + background: #0891b2; + border-radius: 12px; + padding: 12px 28px; + + &--disabled { + background: #cbd5e1; + } + + &-text { + font-size: 26px; + color: #fff; + font-weight: 500; + } + } +} diff --git a/apps/miniprogram/src/pages/events/index.tsx b/apps/miniprogram/src/pages/events/index.tsx new file mode 100644 index 0000000..be28bf9 --- /dev/null +++ b/apps/miniprogram/src/pages/events/index.tsx @@ -0,0 +1,115 @@ +import { useState, useEffect } from 'react'; +import { View, Text, ScrollView } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import * as pointsApi from '@/services/points'; +import Loading from '@/components/Loading'; +import EmptyState from '@/components/EmptyState'; +import './index.scss'; + +const STATUS_MAP: Record = { + published: { label: '报名中', color: '#0891b2' }, + ongoing: { label: '进行中', color: '#10b981' }, + completed: { label: '已结束', color: '#94a3b8' }, + cancelled: { label: '已取消', color: '#ef4444' }, +}; + +export default function EventsPage() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [registering, setRegistering] = useState(null); + + useEffect(() => { + loadEvents(); + }, []); + + const loadEvents = async () => { + setLoading(true); + try { + const res = await pointsApi.listOfflineEvents({ page: 1, page_size: 50, status: 'published' }); + setEvents(res.data || []); + } catch { + Taro.showToast({ title: '加载失败', icon: 'none' }); + } finally { + setLoading(false); + } + }; + + const handleRegister = async (event: pointsApi.OfflineEvent) => { + setRegistering(event.id); + try { + await pointsApi.registerEvent(event.id); + Taro.showToast({ title: '报名成功', icon: 'success' }); + loadEvents(); + } catch (err: any) { + const msg = err?.message || '报名失败'; + Taro.showToast({ title: msg.substring(0, 20), icon: 'none' }); + } finally { + setRegistering(null); + } + }; + + const formatDate = (d: string) => { + return new Date(d).toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + if (loading) return ; + + return ( + + + 线下活动 + 参加活动赢取积分 + + + {events.length === 0 ? ( + + ) : ( + + {events.map((event) => { + const st = STATUS_MAP[event.status] || { label: event.status, color: '#94a3b8' }; + const isFull = event.max_participants != null && event.current_participants >= event.max_participants; + const isRegistering = registering === event.id; + + return ( + + + + {st.label} + + +{event.points_reward} 积分 + + {event.title} + {event.description && ( + {event.description} + )} + + {formatDate(event.event_date)} + {event.location && ( + {event.location} + )} + + + + {event.current_participants}{event.max_participants ? `/${event.max_participants}` : ''} 人已报名 + + !isFull && !isRegistering && handleRegister(event)} + > + + {isRegistering ? '报名中...' : isFull ? '已满' : '立即报名'} + + + + + ); + })} + + )} + + ); +} diff --git a/apps/miniprogram/src/services/points.ts b/apps/miniprogram/src/services/points.ts index b891a51..a89125a 100644 --- a/apps/miniprogram/src/services/points.ts +++ b/apps/miniprogram/src/services/points.ts @@ -98,3 +98,40 @@ export async function listMyOrders(params?: { page?: number; page_size?: number export async function listMyTransactions(params?: { page?: number; page_size?: number }) { return api.get<{ data: PointsTransaction[]; total: number }>('/health/points/transactions', params); } + +// ===== 线下活动 ===== + +export interface OfflineEvent { + id: string; + title: string; + description: string | null; + location: string | null; + event_date: string; + points_reward: number; + max_participants: number | null; + current_participants: number; + status: string; // draft / published / ongoing / completed / cancelled + image_url: string | null; + created_at: string; +} + +export interface EventRegistration { + id: string; + event_id: string; + patient_id: string; + status: string; // registered / checked_in / cancelled + points_granted: boolean; + created_at: string; +} + +export async function listOfflineEvents(params?: { + page?: number; + page_size?: number; + status?: string; +}) { + return api.get<{ data: OfflineEvent[]; total: number }>('/health/offline-events', params); +} + +export async function registerEvent(eventId: string) { + return api.post(`/health/offline-events/${eventId}/register`); +} diff --git a/crates/erp-message/src/module.rs b/crates/erp-message/src/module.rs index 8b73fb9..7701f1e 100644 --- a/crates/erp-message/src/module.rs +++ b/crates/erp-message/src/module.rs @@ -1,5 +1,6 @@ use axum::Router; use axum::routing::{delete, get, put}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use std::sync::Arc; use tokio::sync::Semaphore; use uuid::Uuid; @@ -8,6 +9,7 @@ use erp_core::error::AppResult; use erp_core::events::EventBus; use erp_core::module::ErpModule; +use crate::entity::message_subscription; use crate::handler::{message_handler, subscription_handler, template_handler}; /// 消息中心模块,实现 ErpModule trait。 @@ -131,6 +133,45 @@ impl ErpModule for MessageModule { } } +/// 检查用户是否启用了 DND(免打扰)且当前时间在 DND 窗口内。 +/// 返回 true 表示应该跳过发送。 +async fn should_skip_for_dnd( + tenant_id: Uuid, + recipient_id: Uuid, + priority: &str, + db: &sea_orm::DatabaseConnection, +) -> bool { + // 紧急消息永远不跳过 + if priority == "urgent" { + return false; + } + let sub = match message_subscription::Entity::find() + .filter(message_subscription::Column::TenantId.eq(tenant_id)) + .filter(message_subscription::Column::UserId.eq(recipient_id)) + .filter(message_subscription::Column::DeletedAt.is_null()) + .one(db) + .await + { + Ok(Some(s)) => s, + _ => return false, + }; + if !sub.dnd_enabled { + return false; + } + let (start, end) = match (sub.dnd_start, sub.dnd_end) { + (Some(s), Some(e)) => (s, e), + _ => return false, + }; + let now = chrono::Local::now(); + let now_time = now.format("%H:%M").to_string(); + // DND 窗口比较(支持跨午夜,如 22:00-08:00) + if start <= end { + now_time >= start && now_time < end + } else { + now_time >= start || now_time < end + } +} + /// 处理工作流事件,生成对应的消息通知。 async fn handle_workflow_event( event: &erp_core::events::DomainEvent, @@ -151,6 +192,9 @@ async fn handle_workflow_event( Ok(id) => id, Err(_) => return Ok(()), }; + if should_skip_for_dnd(event.tenant_id, recipient, "normal", db).await { + return Ok(()); + } let _ = crate::service::message_service::MessageService::send_system( event.tenant_id, recipient, @@ -167,7 +211,6 @@ async fn handle_workflow_event( } } "task.completed" => { - // 任务完成时通知流程发起人 let task_id = event .payload .get("task_id") @@ -180,6 +223,9 @@ async fn handle_workflow_event( Ok(id) => id, Err(_) => return Ok(()), }; + if should_skip_for_dnd(event.tenant_id, recipient, "normal", db).await { + return Ok(()); + } let _ = crate::service::message_service::MessageService::send_system( event.tenant_id, recipient, @@ -209,6 +255,9 @@ async fn handle_workflow_event( .and_then(|s| uuid::Uuid::parse_str(s).ok()); if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } let _ = crate::service::message_service::MessageService::send_system( event.tenant_id, pid, @@ -230,6 +279,11 @@ async fn handle_workflow_event( .get("appointment_id") .and_then(|v| v.as_str()) .unwrap_or("unknown"); + let appointment_date = event + .payload + .get("appointment_date") + .and_then(|v| v.as_str()) + .unwrap_or(""); let patient_id = event .payload .get("patient_id") @@ -237,11 +291,19 @@ async fn handle_workflow_event( .and_then(|s| uuid::Uuid::parse_str(s).ok()); if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "important", db).await { + return Ok(()); + } + let date_info = if appointment_date.is_empty() { + String::new() + } else { + format!("({})", appointment_date) + }; let _ = crate::service::message_service::MessageService::send_system( event.tenant_id, pid, "预约已确认".to_string(), - format!("您的预约 {} 已确认,请按时就诊。", &appointment_id[..8.min(appointment_id.len())]), + format!("您的预约{}已确认,请按时就诊。", date_info), "important", Some("appointment".to_string()), uuid::Uuid::parse_str(appointment_id).ok(), @@ -265,6 +327,9 @@ async fn handle_workflow_event( .and_then(|s| uuid::Uuid::parse_str(s).ok()); if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } let _ = crate::service::message_service::MessageService::send_system( event.tenant_id, pid, @@ -305,7 +370,7 @@ async fn handle_workflow_event( _ => "偏高", }; - // 通知责任医生(优先) + // 通知责任医生(优先)— urgent 不跳过 DND if let Some(doctor_uid) = event .payload .get("doctor_user_id") @@ -337,7 +402,6 @@ async fn handle_workflow_event( .and_then(|v| v.as_str()) .and_then(|s| uuid::Uuid::parse_str(s).ok()) { - // 避免医生和操作人是同一人时重复通知 let is_doctor = event .payload .get("doctor_user_id") @@ -346,6 +410,9 @@ async fn handle_workflow_event( .unwrap_or(false); if !is_doctor { + if should_skip_for_dnd(event.tenant_id, operator_uid, "important", db).await { + return Ok(()); + } let _ = crate::service::message_service::MessageService::send_system( event.tenant_id, operator_uid, @@ -381,15 +448,28 @@ async fn handle_workflow_event( .get("planned_date") .and_then(|v| v.as_str()) .unwrap_or("未知日期"); + let patient_name = event + .payload + .get("patient_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); if let Some(assignee) = assigned_to { + if should_skip_for_dnd(event.tenant_id, assignee, "important", db).await { + return Ok(()); + } + let patient_info = if patient_name.is_empty() { + String::new() + } else { + format!("(患者:{})", patient_name) + }; let _ = crate::service::message_service::MessageService::send_system( event.tenant_id, assignee, "随访任务逾期提醒".to_string(), format!( - "您的随访任务(计划日期:{})已逾期,请尽快处理。", - planned_date + "您的随访任务{}(计划日期:{})已逾期,请尽快处理。", + patient_info, planned_date ), "important", Some("follow_up".to_string()), @@ -401,6 +481,80 @@ async fn handle_workflow_event( .map_err(|e| e.to_string())?; } } + // 咨询新消息通知医生 + "consultation.new_message" => { + let doctor_id = event + .payload + .get("doctor_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let patient_name = event + .payload + .get("patient_name") + .and_then(|v| v.as_str()) + .unwrap_or("患者"); + let session_id = event + .payload + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(did) = doctor_id { + if should_skip_for_dnd(event.tenant_id, did, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + did, + format!("新咨询消息 — {}", patient_name), + format!("患者 {} 发来了一条咨询消息,请及时回复。", patient_name), + "normal", + Some("consultation".to_string()), + uuid::Uuid::parse_str(session_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 化验报告审核完成通知患者 + "lab_report.reviewed" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let report_type = event + .payload + .get("report_type") + .and_then(|v| v.as_str()) + .unwrap_or("化验报告"); + let report_id = event + .payload + .get("report_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + format!("{}已审核", report_type), + format!("您的{}已由医生审核完成,请查看医生注释。", report_type), + "normal", + Some("lab_report".to_string()), + uuid::Uuid::parse_str(report_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } _ => {} } Ok(())