feat(health+miniprogram): 预约/报告/随访/资讯/家庭管理 — Chunk 4-6
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

后端:
- 添加 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:
iven
2026-04-24 00:58:40 +08:00
parent ee9a5c4da1
commit 9ef65b9a9f
53 changed files with 6044 additions and 32 deletions

View 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>,
}

View File

@@ -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,

View File

@@ -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;

View 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 {}

View File

@@ -1,4 +1,5 @@
pub mod appointment;
pub mod article;
pub mod consultation_message;
pub mod consultation_session;
pub mod doctor_profile;

View File

@@ -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,

View 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)))
}

View File

@@ -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,
}

View File

@@ -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;

View File

@@ -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(),
},
]
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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),
]
}
}

View File

@@ -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,
}