feat: 仪表盘角色自适应重构 — 4角色视图 + 后端个人工作量API
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled

后端:
- 新增 GET /health/admin/statistics/personal-stats 接口
- PersonalStatsResp: 13个个人维度统计字段
- 按医生/护士/管理员/运营角色聚合工作量数据

前端:
- useDashboardRole hook: 按优先级 doctor>nurse>admin>operator 匹配角色
- DoctorDashboard: 今日工作台(日程/审核/消息/统计卡)
- NurseDashboard: 随访监控台(异常提醒/队列/上报率)
- AdminDashboard: 管理中心(5KPI + 健康数据Tab)
- OperatorDashboard: 运营中心(积分/文章/活动)
- StatisticsDashboard.tsx 重写为角色路由组件
- 删除旧区块:快捷入口/积分排行Top10/最近活动
This commit is contained in:
iven
2026-04-28 07:54:08 +08:00
parent 35d4f6c843
commit 2f42ebff1d
11 changed files with 788 additions and 254 deletions

View File

@@ -115,6 +115,28 @@ pub struct HealthDataStatsResp {
pub vital_signs_report_rate: VitalSignsReportRateResp,
}
// ---------------------------------------------------------------------------
// 个人维度统计
// ---------------------------------------------------------------------------
/// 当前用户个人维度的工作量统计
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PersonalStatsResp {
pub my_patients: i64,
pub new_patients_this_month: i64,
pub follow_up_rate: f64,
pub consultations_this_month: i64,
pub pending_consultations: i64,
pub vital_signs_report_rate: f64,
pub today_appointments: i64,
pub overdue_follow_ups: i64,
pub today_follow_ups: i64,
pub abnormal_vital_signs: i64,
pub vital_signs_reported: i64,
pub vital_signs_total: i64,
pub pending_lab_reviews: i64,
}
// ---------------------------------------------------------------------------
// 通用结构
// ---------------------------------------------------------------------------

View File

@@ -134,3 +134,20 @@ where
let result = stats_service::get_health_data_stats(&state, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
// ---------------------------------------------------------------------------
// 个人维度统计
// ---------------------------------------------------------------------------
pub async fn get_personal_stats<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<PersonalStatsResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.list")?;
let result = stats_service::get_personal_stats(&state, ctx.user_id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -577,6 +577,10 @@ impl HealthModule {
"/health/admin/statistics/health-data",
axum::routing::get(stats_handler::get_health_data_stats),
)
.route(
"/health/admin/statistics/personal-stats",
axum::routing::get(stats_handler::get_personal_stats),
)
// 危急值阈值配置
.route(
"/health/critical-value-thresholds",

View File

@@ -1,4 +1,4 @@
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, FromQueryResult as _};
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult};
use erp_core::error::AppResult;
@@ -6,7 +6,7 @@ use crate::dto::stats_dto::*;
use crate::entity::{
patient, consultation_session, follow_up_task,
points_transaction, dialysis_record, lab_report,
appointment, vital_signs,
appointment, vital_signs, patient_doctor_relation, doctor_profile,
};
use crate::state::HealthState;
@@ -621,3 +621,254 @@ async fn compute_daily_report_rate(
DailyReportRate { date: r.date, reported: r.reported, total, rate }
}).collect())
}
// ---------------------------------------------------------------------------
// 个人维度统计
// ---------------------------------------------------------------------------
pub async fn get_personal_stats(
state: &HealthState,
user_id: uuid::Uuid,
tenant_id: uuid::Uuid,
) -> AppResult<PersonalStatsResp> {
let db = &state.db;
// 通过 user_id 查找 doctor_profile 以获得 doctor_id
let doctor_profile = doctor_profile::Entity::find()
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
.filter(doctor_profile::Column::DeletedAt.is_null())
.filter(doctor_profile::Column::UserId.eq(user_id))
.one(db)
.await?;
let doctor_id = doctor_profile.map(|p| p.id);
// my_patients: 通过 patient_doctor_relation 统计
let my_patients = if let Some(did) = doctor_id {
patient_doctor_relation::Entity::find()
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
.filter(patient_doctor_relation::Column::DeletedAt.is_null())
.filter(patient_doctor_relation::Column::DoctorId.eq(did))
.count(db)
.await? as i64
} else {
0
};
// new_patients_this_month: 本月新增关联患者
let new_patients_this_month = if let Some(did) = doctor_id {
let sql = r#"
SELECT COUNT(*) AS cnt
FROM patient_doctor_relation pdr
INNER JOIN patient p ON p.id = pdr.patient_id AND p.deleted_at IS NULL AND p.tenant_id = $1
WHERE pdr.tenant_id = $1 AND pdr.deleted_at IS NULL
AND pdr.doctor_id = $2
AND p.created_at >= date_trunc('month', NOW())
"#;
#[derive(Debug, FromQueryResult)]
struct Cnt {
cnt: i64,
}
let result: Option<Cnt> = FromQueryResult::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into(), did.into()],
),
)
.one(db)
.await?;
result.map(|r| r.cnt).unwrap_or(0)
} else {
0
};
// follow_up_rate: 分配给当前用户的随访完成率
let sql = r#"
SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt
FROM follow_up_task
WHERE tenant_id = $1 AND deleted_at IS NULL AND assigned_to = $2
GROUP BY GROUPING SETS ((status), ())
"#;
#[derive(Debug, FromQueryResult)]
struct StatusCount {
status: String,
cnt: i64,
}
let fu_rows: Vec<StatusCount> = FromQueryResult::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into(), user_id.into()],
),
)
.all(db)
.await?;
let mut fu_total: i64 = 0;
let mut fu_completed: i64 = 0;
let mut overdue_follow_ups: i64 = 0;
for row in &fu_rows {
match row.status.as_str() {
"__total" => fu_total = row.cnt,
"completed" => fu_completed = row.cnt,
"overdue" => overdue_follow_ups = row.cnt,
_ => {}
}
}
let follow_up_rate = if fu_total > 0 {
(fu_completed as f64 / fu_total as f64) * 100.0
} else {
0.0
};
// consultations_this_month / pending_consultations: 咨询统计
let consultations_this_month = if let Some(did) = doctor_id {
consultation_session::Entity::find()
.filter(consultation_session::Column::TenantId.eq(tenant_id))
.filter(consultation_session::Column::DeletedAt.is_null())
.filter(consultation_session::Column::DoctorId.eq(did))
.filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
.count(db)
.await? as i64
} else {
0
};
let pending_consultations = if let Some(did) = doctor_id {
consultation_session::Entity::find()
.filter(consultation_session::Column::TenantId.eq(tenant_id))
.filter(consultation_session::Column::DeletedAt.is_null())
.filter(consultation_session::Column::DoctorId.eq(did))
.filter(consultation_session::Column::Status.eq("active"))
.count(db)
.await? as i64
} else {
0
};
// today_appointments: 今日预约
let today_appointments = if let Some(did) = doctor_id {
appointment::Entity::find()
.filter(appointment::Column::TenantId.eq(tenant_id))
.filter(appointment::Column::DeletedAt.is_null())
.filter(appointment::Column::DoctorId.eq(did))
.filter(Expr::col(appointment::Column::AppointmentDate).eq(Expr::cust("CURRENT_DATE")))
.count(db)
.await? as i64
} else {
0
};
// today_follow_ups: 今日随访任务
let today_follow_ups = follow_up_task::Entity::find()
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.filter(follow_up_task::Column::AssignedTo.eq(user_id))
.filter(Expr::col(follow_up_task::Column::PlannedDate).eq(Expr::cust("CURRENT_DATE")))
.count(db)
.await? as i64;
// vital_signs_report_rate: 当前医生的患者体征上报率
let (vital_signs_reported, vital_signs_total, vital_signs_report_rate) = if my_patients > 0 {
let vs_sql = r#"
SELECT
COUNT(DISTINCT vs.patient_id) AS reported,
$3::bigint AS total
FROM vital_signs vs
WHERE vs.tenant_id = $1 AND vs.deleted_at IS NULL
AND vs.created_at >= date_trunc('month', NOW())
AND vs.patient_id IN (
SELECT patient_id FROM patient_doctor_relation
WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL
)
"#;
#[derive(Debug, FromQueryResult)]
struct VsCount {
reported: i64,
total: i64,
}
let result: Option<VsCount> = FromQueryResult::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
vs_sql,
[tenant_id.into(), doctor_id.unwrap_or_default().into(), my_patients.into()],
),
)
.one(db)
.await?;
match result {
Some(r) => {
let rate = if r.total > 0 {
(r.reported as f64 / r.total as f64) * 100.0
} else {
0.0
};
(r.reported, r.total, rate)
}
None => (0, my_patients, 0.0),
}
} else {
(0, 0, 0.0)
};
// pending_lab_reviews: 待审核化验报告(与当前医生的患者关联)
let pending_lab_reviews = if doctor_id.is_some() {
let lr_sql = r#"
SELECT COUNT(*) AS cnt
FROM lab_report lr
WHERE lr.tenant_id = $1 AND lr.deleted_at IS NULL
AND lr.status = 'pending'
AND lr.patient_id IN (
SELECT patient_id FROM patient_doctor_relation
WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL
)
"#;
#[derive(Debug, FromQueryResult)]
struct LrCnt {
cnt: i64,
}
let result: Option<LrCnt> = FromQueryResult::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
lr_sql,
[tenant_id.into(), doctor_id.unwrap_or_default().into()],
),
)
.one(db)
.await?;
result.map(|r| r.cnt).unwrap_or(0)
} else {
0
};
// abnormal_vital_signs: 简化实现,返回 0完整实现需要关联危急值阈值配置
let abnormal_vital_signs: i64 = 0;
Ok(PersonalStatsResp {
my_patients,
new_patients_this_month,
follow_up_rate,
consultations_this_month,
pending_consultations,
vital_signs_report_rate,
today_appointments,
overdue_follow_ups,
today_follow_ups,
abnormal_vital_signs,
vital_signs_reported,
vital_signs_total,
pending_lab_reviews,
})
}