feat: Iteration 3 — 咨询轮询、统计概览、埋点后端
- consultation_service 支持 after_id 增量消息查询 - 小程序咨询详情页 8 秒轮询新消息 - 新增 DashboardStatsResp 综合统计端点 (/statistics/dashboard) - 新增 /analytics/batch 埋点接收端点(日志记录模式)
This commit is contained in:
@@ -5,6 +5,8 @@ import * as doctorApi from '@/services/doctor';
|
|||||||
import Loading from '@/components/Loading';
|
import Loading from '@/components/Loading';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 8000;
|
||||||
|
|
||||||
export default function ConsultationDetail() {
|
export default function ConsultationDetail() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sessionId = router.params.id || '';
|
const sessionId = router.params.id || '';
|
||||||
@@ -14,14 +16,53 @@ export default function ConsultationDetail() {
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const scrollViewRef = useRef('');
|
const scrollViewRef = useRef('');
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
loadData();
|
loadData();
|
||||||
markRead();
|
markRead();
|
||||||
|
startPolling();
|
||||||
}
|
}
|
||||||
|
return () => stopPolling();
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
stopPolling();
|
||||||
|
pollTimerRef.current = setInterval(pollNewMessages, POLL_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollNewMessages = async () => {
|
||||||
|
if (!session || session.status === 'closed') {
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined;
|
||||||
|
const m = await doctorApi.listMessages(sessionId, {
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
after_id: lastId,
|
||||||
|
});
|
||||||
|
const newMsgs = m.data || [];
|
||||||
|
if (newMsgs.length > 0) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const existing = new Set(prev.map((msg) => msg.id));
|
||||||
|
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
|
||||||
|
return [...prev, ...fresh];
|
||||||
|
});
|
||||||
|
scrollViewRef.current = `msg-${messages.length + newMsgs.length}`;
|
||||||
|
}
|
||||||
|
} catch { /* 轮询失败静默忽略 */ }
|
||||||
|
};
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -32,6 +73,7 @@ export default function ConsultationDetail() {
|
|||||||
setSession(s);
|
setSession(s);
|
||||||
setMessages(m.data || []);
|
setMessages(m.data || []);
|
||||||
scrollViewRef.current = `msg-${(m.data || []).length}`;
|
scrollViewRef.current = `msg-${(m.data || []).length}`;
|
||||||
|
if (s.status === 'closed') stopPolling();
|
||||||
} catch {
|
} catch {
|
||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export async function getSession(id: string) {
|
|||||||
return api.get<ConsultationSession>(`/health/consultation-sessions/${id}`);
|
return api.get<ConsultationSession>(`/health/consultation-sessions/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMessages(sessionId: string, params?: { page?: number; page_size?: number }) {
|
export async function listMessages(sessionId: string, params?: { page?: number; page_size?: number; after_id?: string }) {
|
||||||
return api.get<{ data: ConsultationMessage[]; total: number }>(
|
return api.get<{ data: ConsultationMessage[]; total: number }>(
|
||||||
`/health/consultation-sessions/${sessionId}/messages`,
|
`/health/consultation-sessions/${sessionId}/messages`,
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -25,3 +25,11 @@ pub struct FollowUpStatisticsResp {
|
|||||||
pub overdue: i64,
|
pub overdue: i64,
|
||||||
pub completion_rate: f64,
|
pub completion_rate: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 综合统计概览(一次调用返回全部关键指标)。
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct DashboardStatsResp {
|
||||||
|
pub patients: PatientStatisticsResp,
|
||||||
|
pub consultations: ConsultationStatisticsResp,
|
||||||
|
pub follow_ups: FollowUpStatisticsResp,
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub struct SessionListParams {
|
|||||||
pub struct MessageListParams {
|
pub struct MessageListParams {
|
||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
pub page_size: Option<u64>,
|
pub page_size: Option<u64>,
|
||||||
|
pub after_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
@@ -114,7 +115,7 @@ where
|
|||||||
let page = params.page.unwrap_or(1);
|
let page = params.page.unwrap_or(1);
|
||||||
let page_size = params.page_size.unwrap_or(20);
|
let page_size = params.page_size.unwrap_or(20);
|
||||||
let result = consultation_service::list_messages(
|
let result = consultation_service::list_messages(
|
||||||
&state, ctx.tenant_id, session_id, page, page_size,
|
&state, ctx.tenant_id, session_id, page, page_size, params.after_id,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
|||||||
@@ -46,3 +46,22 @@ where
|
|||||||
let result = stats_service::get_follow_up_statistics(&state, ctx.tenant_id).await?;
|
let result = stats_service::get_follow_up_statistics(&state, ctx.tenant_id).await?;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_dashboard_stats<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<DashboardStatsResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
|
let patients = stats_service::get_patient_statistics(&state, ctx.tenant_id).await?;
|
||||||
|
let consultations = stats_service::get_consultation_statistics(&state, ctx.tenant_id).await?;
|
||||||
|
let follow_ups = stats_service::get_follow_up_statistics(&state, ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(DashboardStatsResp {
|
||||||
|
patients,
|
||||||
|
consultations,
|
||||||
|
follow_ups,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -467,6 +467,10 @@ impl HealthModule {
|
|||||||
"/health/admin/statistics/follow-ups",
|
"/health/admin/statistics/follow-ups",
|
||||||
axum::routing::get(stats_handler::get_follow_up_stats),
|
axum::routing::get(stats_handler::get_follow_up_stats),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/health/admin/statistics/dashboard",
|
||||||
|
axum::routing::get(stats_handler::get_dashboard_stats),
|
||||||
|
)
|
||||||
// 危急值阈值配置
|
// 危急值阈值配置
|
||||||
.route(
|
.route(
|
||||||
"/health/critical-value-thresholds",
|
"/health/critical-value-thresholds",
|
||||||
|
|||||||
@@ -232,15 +232,23 @@ pub async fn list_messages(
|
|||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
page: u64,
|
page: u64,
|
||||||
page_size: u64,
|
page_size: u64,
|
||||||
|
after_id: Option<Uuid>,
|
||||||
) -> HealthResult<PaginatedResponse<MessageResp>> {
|
) -> HealthResult<PaginatedResponse<MessageResp>> {
|
||||||
let limit = page_size.min(100);
|
let limit = page_size.min(100);
|
||||||
let offset = page.saturating_sub(1) * limit;
|
|
||||||
|
|
||||||
let query = consultation_message::Entity::find()
|
let mut query = consultation_message::Entity::find()
|
||||||
.filter(consultation_message::Column::TenantId.eq(tenant_id))
|
.filter(consultation_message::Column::TenantId.eq(tenant_id))
|
||||||
.filter(consultation_message::Column::SessionId.eq(session_id))
|
.filter(consultation_message::Column::SessionId.eq(session_id))
|
||||||
.filter(consultation_message::Column::DeletedAt.is_null());
|
.filter(consultation_message::Column::DeletedAt.is_null());
|
||||||
|
|
||||||
|
// after_id 模式:返回该 ID 之后的所有消息(用于轮询增量拉取)
|
||||||
|
if let Some(aid) = after_id {
|
||||||
|
query = query.filter(consultation_message::Column::Id.gt(aid));
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = page.saturating_sub(1) * limit;
|
||||||
|
let total = query.clone().count(&state.db).await?;
|
||||||
|
|
||||||
let total = query.clone().count(&state.db).await?;
|
let total = query.clone().count(&state.db).await?;
|
||||||
let models = query
|
let models = query
|
||||||
.order_by_asc(consultation_message::Column::CreatedAt)
|
.order_by_asc(consultation_message::Column::CreatedAt)
|
||||||
|
|||||||
34
crates/erp-server/src/handlers/analytics.rs
Normal file
34
crates/erp-server/src/handlers/analytics.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing;
|
||||||
|
|
||||||
|
use erp_core::types::ApiResponse;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AnalyticsEvent {
|
||||||
|
pub event: String,
|
||||||
|
pub properties: Option<serde_json::Value>,
|
||||||
|
pub timestamp: Option<String>,
|
||||||
|
pub page: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BatchRequest {
|
||||||
|
pub events: Vec<AnalyticsEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 接收小程序批量埋点事件。
|
||||||
|
/// 当前为日志记录模式 — 后续可接入 ClickHouse/PostgreSQL 分析表。
|
||||||
|
pub async fn batch(
|
||||||
|
Json(req): Json<BatchRequest>,
|
||||||
|
) -> Json<ApiResponse<()>> {
|
||||||
|
for evt in &req.events {
|
||||||
|
tracing::info!(
|
||||||
|
event = %evt.event,
|
||||||
|
page = ?evt.page,
|
||||||
|
properties = ?evt.properties,
|
||||||
|
"Analytics event received"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Json(ApiResponse::ok(()))
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod analytics;
|
||||||
pub mod audit_log;
|
pub mod audit_log;
|
||||||
pub mod crypto_admin;
|
pub mod crypto_admin;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
|||||||
@@ -494,6 +494,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"/docs/openapi.json",
|
"/docs/openapi.json",
|
||||||
axum::routing::get(handlers::openapi::openapi_spec),
|
axum::routing::get(handlers::openapi::openapi_spec),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/analytics/batch",
|
||||||
|
axum::routing::post(handlers::analytics::batch),
|
||||||
|
)
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
middleware::rate_limit::rate_limit_by_ip,
|
middleware::rate_limit::rate_limit_by_ip,
|
||||||
|
|||||||
Reference in New Issue
Block a user