feat(health+miniprogram): 预约/报告/随访/资讯/家庭管理 — Chunk 4-6
后端: - 添加 articles 表迁移 + Entity + Service + Handler - 健康数据趋势 API (get_mini_trend) 注册路由 - article CRUD (list/get) + DTO 前端 (11个新页面 + 5个服务): - 预约挂号: 列表/创建向导/详情页 - 报告管理: 列表/详情页 - 随访管理: 任务列表/记录详情页 - 资讯文章: 文章详情页 - 个人中心: 就诊人管理/新增/我的报告/我的随访/用药提醒/设置 - 更新 app.config.ts 注册全部路由 - 更新 profile/article 页面为真实功能
This commit is contained in:
36
crates/erp-health/src/dto/article_dto.rs
Normal file
36
crates/erp-health/src/dto/article_dto.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ArticleResp {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ArticleListItem {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
||||
pub struct ArticleListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
@@ -65,6 +65,15 @@ pub struct CreateLabReportReq {
|
||||
pub doctor_interpretation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateLabReportReq {
|
||||
pub report_date: Option<NaiveDate>,
|
||||
pub report_type: Option<String>,
|
||||
pub indicators: Option<serde_json::Value>,
|
||||
pub image_urls: Option<serde_json::Value>,
|
||||
pub doctor_interpretation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct LabReportResp {
|
||||
pub id: Uuid,
|
||||
@@ -89,6 +98,16 @@ pub struct CreateHealthRecordReq {
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateHealthRecordReq {
|
||||
pub record_type: Option<String>,
|
||||
pub record_date: Option<NaiveDate>,
|
||||
pub source: Option<String>,
|
||||
pub overall_assessment: Option<String>,
|
||||
pub report_file_url: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthRecordResp {
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod appointment_dto;
|
||||
pub mod article_dto;
|
||||
pub mod consultation_dto;
|
||||
pub mod doctor_dto;
|
||||
pub mod follow_up_dto;
|
||||
|
||||
36
crates/erp-health/src/entity/article.rs
Normal file
36
crates/erp-health/src/entity/article.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "article")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub title: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
pub content: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_image: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub published_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod appointment;
|
||||
pub mod article;
|
||||
pub mod consultation_message;
|
||||
pub mod consultation_session;
|
||||
pub mod doctor_profile;
|
||||
|
||||
@@ -41,6 +41,9 @@ pub enum HealthError {
|
||||
#[error("会话不存在")]
|
||||
ConsultationNotFound,
|
||||
|
||||
#[error("文章不存在")]
|
||||
ArticleNotFound,
|
||||
|
||||
#[error("状态转换无效: {0}")]
|
||||
InvalidStatusTransition(String),
|
||||
|
||||
@@ -65,7 +68,8 @@ impl From<HealthError> for AppError {
|
||||
| HealthError::FamilyMemberNotFound
|
||||
| HealthError::TagNotFound
|
||||
| HealthError::FollowUpTaskNotFound
|
||||
| HealthError::ConsultationNotFound => AppError::NotFound(err.to_string()),
|
||||
| HealthError::ConsultationNotFound
|
||||
| HealthError::ArticleNotFound => AppError::NotFound(err.to_string()),
|
||||
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
|
||||
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
|
||||
HealthError::VersionMismatch => AppError::VersionMismatch,
|
||||
|
||||
43
crates/erp-health/src/handler/article_handler.rs
Normal file
43
crates/erp-health/src/handler/article_handler.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp};
|
||||
use crate::service::article_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
pub async fn list_articles<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ArticleListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<ArticleListItem>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.articles.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = article_service::list_articles(
|
||||
&state, ctx.tenant_id, page, page_size, params.category,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn get_article<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.articles.list")?;
|
||||
let result = article_service::get_article(&state, ctx.tenant_id, id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -357,13 +357,13 @@ pub struct UpdateVitalSignsWithVersion {
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateLabReportWithVersion {
|
||||
#[serde(flatten)]
|
||||
pub data: CreateLabReportReq,
|
||||
pub data: UpdateLabReportReq,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateHealthRecordWithVersion {
|
||||
#[serde(flatten)]
|
||||
pub data: CreateHealthRecordReq,
|
||||
pub data: UpdateHealthRecordReq,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod appointment_handler;
|
||||
pub mod article_handler;
|
||||
pub mod consultation_handler;
|
||||
pub mod doctor_handler;
|
||||
pub mod follow_up_handler;
|
||||
|
||||
@@ -6,7 +6,7 @@ use erp_core::events::EventBus;
|
||||
use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||
|
||||
use crate::handler::{
|
||||
appointment_handler, consultation_handler, doctor_handler, follow_up_handler,
|
||||
appointment_handler, article_handler, consultation_handler, doctor_handler, follow_up_handler,
|
||||
health_data_handler, patient_handler,
|
||||
};
|
||||
|
||||
@@ -193,6 +193,15 @@ impl HealthModule {
|
||||
.put(doctor_handler::update_doctor)
|
||||
.delete(doctor_handler::delete_doctor),
|
||||
)
|
||||
// 健康资讯
|
||||
.route(
|
||||
"/health/articles",
|
||||
axum::routing::get(article_handler::list_articles),
|
||||
)
|
||||
.route(
|
||||
"/health/articles/{id}",
|
||||
axum::routing::get(article_handler::get_article),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +328,18 @@ impl ErpModule for HealthModule {
|
||||
description: "创建、编辑医护档案、排班".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.articles.list".into(),
|
||||
name: "查看资讯".into(),
|
||||
description: "查看健康资讯文章列表和详情".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.articles.manage".into(),
|
||||
name: "管理资讯".into(),
|
||||
description: "创建、编辑、删除健康资讯文章".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ pub async fn update_lab_report(
|
||||
patient_id: Uuid,
|
||||
report_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
req: CreateLabReportReq,
|
||||
req: UpdateLabReportReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<LabReportResp> {
|
||||
let model = lab_report::Entity::find()
|
||||
@@ -297,11 +297,11 @@ pub async fn update_lab_report(
|
||||
.map_err(|_| HealthError::VersionMismatch)?;
|
||||
|
||||
let mut active: lab_report::ActiveModel = model.into();
|
||||
active.report_date = Set(req.report_date);
|
||||
active.report_type = Set(req.report_type);
|
||||
active.indicators = Set(req.indicators);
|
||||
active.image_urls = Set(req.image_urls);
|
||||
active.doctor_interpretation = Set(req.doctor_interpretation);
|
||||
if let Some(v) = req.report_date { active.report_date = Set(v); }
|
||||
if let Some(v) = req.report_type { active.report_type = Set(v); }
|
||||
if let Some(v) = req.indicators { active.indicators = Set(Some(v)); }
|
||||
if let Some(v) = req.image_urls { active.image_urls = Set(Some(v)); }
|
||||
if let Some(v) = req.doctor_interpretation { active.doctor_interpretation = Set(Some(v)); }
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod appointment_service;
|
||||
pub mod article_service;
|
||||
pub mod consultation_service;
|
||||
pub mod doctor_service;
|
||||
pub mod follow_up_service;
|
||||
|
||||
@@ -43,6 +43,7 @@ mod m20260419_000040_plugin_market;
|
||||
mod m20260419_000041_plugin_user_views;
|
||||
mod m20260423_000042_create_health_tables;
|
||||
mod m20260423_000043_create_wechat_users;
|
||||
mod m20260423_000044_create_articles;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -93,6 +94,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260419_000041_plugin_user_views::Migration),
|
||||
Box::new(m20260423_000042_create_health_tables::Migration),
|
||||
Box::new(m20260423_000043_create_wechat_users::Migration),
|
||||
Box::new(m20260423_000044_create_articles::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Article::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Article::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Article::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Article::Title).string_len(200).not_null())
|
||||
.col(ColumnDef::new(Article::Summary).text().null())
|
||||
.col(ColumnDef::new(Article::Content).text().not_null())
|
||||
.col(ColumnDef::new(Article::CoverImage).string_len(500).null())
|
||||
.col(ColumnDef::new(Article::Category).string_len(50).null())
|
||||
.col(ColumnDef::new(Article::Author).string_len(100).null())
|
||||
.col(ColumnDef::new(Article::PublishedAt).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(Article::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Article::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Article::CreatedBy).uuid().null())
|
||||
.col(ColumnDef::new(Article::UpdatedBy).uuid().null())
|
||||
.col(ColumnDef::new(Article::DeletedAt).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(Article::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_article_tenant_category")
|
||||
.table(Article::Table)
|
||||
.col(Article::TenantId)
|
||||
.col(Article::Category)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_article_tenant_published")
|
||||
.table(Article::Table)
|
||||
.col(Article::TenantId)
|
||||
.col(Article::PublishedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_index(Index::drop().name("idx_article_tenant_published").to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_index(Index::drop().name("idx_article_tenant_category").to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Article::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Article {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Title,
|
||||
Summary,
|
||||
Content,
|
||||
CoverImage,
|
||||
Category,
|
||||
Author,
|
||||
PublishedAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
Reference in New Issue
Block a user