fix(mp): DevTools 卡死 + 主包 2MB→766KB + 代码质量 4 项全通过

根因:主包 2MB 全量组件注入导致 DevTools 渲染引擎内存渐增,
叠加离线时固定 3s 抑制期后的请求洪泛。

修复:
- app.config.ts 添加 lazyCodeLoading: requiredComponents
  主包 2.0MB→766KB,taro.js 526→131KB,vendors.js 230→28KB
- request.ts 离线抑制改为指数退避(3s→6s→12s→30s cap)
  后端不可达时自动延长抑制,防止请求风暴
- SegmentTabs Tab 接口改为 readonly,修复 TS 编译错误
- AbortController polyfill 补齐小程序运行时缺失
- 健康首页/设备同步/健康档案/报告/设置页 UI 重构
- 文章页公开端点适配游客访问
- 健康首页 Swiper 间隔优化 4s→5s,动画 500→300ms
This commit is contained in:
iven
2026-05-24 11:32:40 +08:00
parent 675f8a4b10
commit 1e59007bd5
58 changed files with 4950 additions and 494 deletions

View File

@@ -7,6 +7,10 @@ use erp_core::sanitize::{
sanitize_option, sanitize_rich_html_option, sanitize_string, strip_html_tags,
};
const fn default_true() -> bool {
true
}
// ---------------------------------------------------------------------------
// 文章 DTOs
// ---------------------------------------------------------------------------
@@ -29,6 +33,8 @@ pub struct ArticleResp {
pub review_note: Option<String>,
pub view_count: i32,
pub sort_order: i32,
/// 是否公开(游客可访问)
pub is_public: bool,
/// 文章关联的分类 ID来自 article_category 表)
pub category_id: Option<Uuid>,
/// 文章关联的标签名称列表
@@ -49,6 +55,8 @@ pub struct ArticleListItem {
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
pub status: String,
pub view_count: i32,
/// 是否公开(游客可访问)
pub is_public: bool,
/// 分类 ID
pub category_id: Option<Uuid>,
/// 标签名称列表
@@ -96,6 +104,9 @@ pub struct CreateArticleReq {
/// 标签 ID 列表
#[serde(default)]
pub tag_ids: Vec<Uuid>,
/// 是否公开(游客可访问),默认 true
#[serde(default = "default_true")]
pub is_public: bool,
}
impl CreateArticleReq {
@@ -134,6 +145,8 @@ pub struct UpdateArticleReq {
/// 标签 ID 列表(传入则整体替换)
pub tag_ids: Option<Vec<Uuid>>,
pub sort_order: Option<i32>,
/// 是否公开(游客可访问)
pub is_public: Option<bool>,
pub version: i32,
}

View File

@@ -41,6 +41,8 @@ pub struct Model {
pub view_count: i32,
/// 排序权重
pub sort_order: i32,
/// 是否公开(游客可访问)
pub is_public: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]

View File

@@ -1,7 +1,7 @@
//! 文章分类 Handler
use axum::Extension;
use axum::extract::{FromRef, Json, Path, State};
use axum::extract::{FromRef, Json, Path, Query, State};
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
@@ -12,6 +12,32 @@ use crate::state::HealthState;
use validator::Validate;
// ---------------------------------------------------------------------------
// 公开端点(小程序游客 / 无需认证)
// ---------------------------------------------------------------------------
#[derive(Debug, serde::Deserialize)]
pub struct PublicCategoryQuery {
pub tenant_id: uuid::Uuid,
}
/// GET /public/article-categories — 公开分类列表(无需认证)
pub async fn list_public_categories<S>(
State(state): State<HealthState>,
Query(params): Query<PublicCategoryQuery>,
) -> Result<Json<ApiResponse<Vec<CategoryResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let result = article_category_service::list_categories(&state, params.tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
// ---------------------------------------------------------------------------
// 管理端端点(需要认证)
// ---------------------------------------------------------------------------
pub async fn list_categories<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,

View File

@@ -45,6 +45,7 @@ where
params.category_id,
params.tag_id,
params.keyword,
None, // 管理端不过滤 is_public
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -69,6 +70,7 @@ pub async fn list_public_articles(
params.category_id,
params.tag_id,
params.keyword,
Some(true), // 公开端点只返回 is_public=true 的文章
)
.await?;
Ok(Json(ApiResponse::ok(result)))

View File

@@ -5,7 +5,9 @@ use erp_core::error::AppResult;
use erp_core::events::EventBus;
use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{article_handler, banner_handler, ble_gateway_handler};
use crate::handler::{
article_category_handler, article_handler, banner_handler, ble_gateway_handler,
};
pub struct HealthModule;
@@ -203,6 +205,10 @@ impl HealthModule {
"/public/articles/{id}",
axum::routing::get(article_handler::get_public_article),
)
.route(
"/public/article-categories",
axum::routing::get(article_category_handler::list_public_categories),
)
}
/// FHIR R4 只读路由(使用 OAuth client_credentials 认证)

View File

@@ -21,7 +21,7 @@ use crate::error::{HealthError, HealthResult};
use crate::service::validation;
use crate::state::HealthState;
/// 文章列表(管理端,支持状态/分类/标签/关键词筛选)
/// 文章列表(管理端,支持状态/分类/标签/关键词/公开状态筛选)
#[allow(clippy::too_many_arguments)]
pub async fn list_articles(
state: &HealthState,
@@ -33,6 +33,7 @@ pub async fn list_articles(
category_id: Option<Uuid>,
tag_id: Option<Uuid>,
keyword: Option<String>,
is_public: Option<bool>,
) -> HealthResult<PaginatedResponse<ArticleListItem>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
@@ -47,6 +48,9 @@ pub async fn list_articles(
if let Some(ref s) = status {
query = query.filter(article::Column::Status.eq(s));
}
if let Some(pub_flag) = is_public {
query = query.filter(article::Column::IsPublic.eq(pub_flag));
}
if let Some(cid) = category_id {
query = query.filter(article::Column::CategoryId.eq(cid));
}
@@ -104,6 +108,7 @@ pub async fn list_articles(
published_at: m.published_at,
status: m.status,
view_count: m.view_count,
is_public: m.is_public,
category_id: m.category_id,
tags,
version: m.version,
@@ -374,6 +379,7 @@ pub async fn create_article(
review_note: Set(None),
view_count: Set(0),
sort_order: Set(0),
is_public: Set(req.is_public),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
@@ -445,6 +451,9 @@ pub async fn update_article(
if let Some(v) = req.sort_order {
active.sort_order = Set(v);
}
if let Some(v) = req.is_public {
active.is_public = Set(v);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
@@ -530,6 +539,7 @@ fn full_model_to_resp(m: article::Model, tags: Vec<String>) -> ArticleResp {
review_note: m.review_note,
view_count: m.view_count,
sort_order: m.sort_order,
is_public: m.is_public,
category_id: m.category_id,
tags,
created_at: m.created_at,

View File

@@ -0,0 +1,3 @@
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.81s
Running `G:\hms\target\debug\erp-server.exe`
error: process didn't exit successfully: `G:\hms\target\debug\erp-server.exe` (exit code: 1)

View File

@@ -168,6 +168,7 @@ mod m20260521_000163_reorganize_menus_by_business_flow;
mod m20260521_000164_reorganize_menus_scheme_b;
mod m20260522_000160_article_add_is_public;
mod m20260522_000161_patient_points_manage_perm;
mod m20260522_000162_seed_patient_miniprogram_permissions;
pub struct Migrator;
@@ -343,6 +344,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260521_000164_reorganize_menus_scheme_b::Migration),
Box::new(m20260522_000160_article_add_is_public::Migration),
Box::new(m20260522_000161_patient_points_manage_perm::Migration),
Box::new(m20260522_000162_seed_patient_miniprogram_permissions::Migration),
]
}
}

View File

@@ -0,0 +1,37 @@
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> {
// alerts 表新增 source告警来源和 original_id关联原始告警字段
manager
.alter_table(
Table::alter()
.table(Alias::new("alerts"))
.add_column(
ColumnDef::new(Alias::new("source"))
.string()
.not_null()
.default("rule_engine"),
)
.add_column(ColumnDef::new(Alias::new("original_id")).uuid().null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("alerts"))
.drop_column(Alias::new("source"))
.drop_column(Alias::new("original_id"))
.to_owned(),
)
.await
}
}

View File

@@ -0,0 +1,108 @@
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> {
// 1. patient 表新增 phone 和 phone_hash 字段
manager
.alter_table(
Table::alter()
.table(Alias::new("patient"))
.add_column(ColumnDef::new(Alias::new("phone")).text().null())
.add_column(ColumnDef::new(Alias::new("phone_hash")).text().null())
.to_owned(),
)
.await?;
// 2. 为所有现有活跃患者自动授予 data_processing 同意(默认拒绝策略下保持向后兼容)
let seed_consent_sql_1 = r#"
INSERT INTO consent (id, tenant_id, patient_id, consent_type, consent_scope, status, granted_at, consent_method, created_at, updated_at, version)
SELECT
gen_random_uuid(),
p.tenant_id,
p.id,
'data_processing',
'all',
'granted',
NOW(),
'system_auto',
NOW(),
NOW(),
1
FROM patient p
WHERE p.status = 'active'
AND p.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM consent c
WHERE c.patient_id = p.id
AND c.tenant_id = p.tenant_id
AND c.consent_type = 'data_processing'
AND c.deleted_at IS NULL
)
"#;
manager
.get_connection()
.execute_unprepared(seed_consent_sql_1)
.await?;
// 3. 为所有现有活跃患者自动授予 health_data_collection 同意
let seed_consent_sql_2 = r#"
INSERT INTO consent (id, tenant_id, patient_id, consent_type, consent_scope, status, granted_at, consent_method, created_at, updated_at, version)
SELECT
gen_random_uuid(),
p.tenant_id,
p.id,
'health_data_collection',
'all',
'granted',
NOW(),
'system_auto',
NOW(),
NOW(),
1
FROM patient p
WHERE p.status = 'active'
AND p.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM consent c
WHERE c.patient_id = p.id
AND c.tenant_id = p.tenant_id
AND c.consent_type = 'health_data_collection'
AND c.deleted_at IS NULL
)
"#;
manager
.get_connection()
.execute_unprepared(seed_consent_sql_2)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 删除系统自动生成的 consent 记录
let delete_sql = r#"
DELETE FROM consent WHERE consent_method = 'system_auto'
"#;
manager
.get_connection()
.execute_unprepared(delete_sql)
.await?;
// 移除 phone 和 phone_hash 列
manager
.alter_table(
Table::alter()
.table(Alias::new("patient"))
.drop_column(Alias::new("phone"))
.drop_column(Alias::new("phone_hash"))
.to_owned(),
)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,34 @@
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
.alter_table(
Table::alter()
.table(Alias::new("article"))
.add_column(
ColumnDef::new(Alias::new("is_public"))
.boolean()
.not_null()
.default(true),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("article"))
.drop_column(Alias::new("is_public"))
.to_owned(),
)
.await
}
}

View File

@@ -0,0 +1,170 @@
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> {
let db = manager.get_connection();
// 1) 注册 system.analytics.submit 幽灵权限(代码中 require_permission 使用但未注册)
let sys = "00000000-0000-0000-0000-000000000000";
db.execute_unprepared(&format!(
"INSERT INTO permissions (id, tenant_id, name, code, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), t.id, '提交埋点数据', 'system.analytics.submit', 'system', 'submit', '小程序端埋点数据批量提交', NOW(), NOW(), '{sys}', '{sys}', NULL, 1 \
FROM tenant t \
WHERE NOT EXISTS (SELECT 1 FROM permissions p WHERE p.tenant_id = t.id AND p.code = 'system.analytics.submit' AND p.deleted_at IS NULL)"
)).await?;
// 2) 患者角色缺失的 .manage 权限(小程序端写入操作)
let patient_manage_perms: &[&str] = &[
// 体征录入
"health.health-data.manage",
// 日常监测创建
"health.daily-monitoring.manage",
// 预约创建/取消
"health.appointment.manage",
// 医生列表(预约选医生)
"health.doctor.list",
// 随访提交
"health.follow-up.manage",
// 咨询创建/发送消息
"health.consultation.manage",
// 药物提醒 CRUD
"health.medication-reminders.manage",
// 知情同意授权/撤回
"health.consent.manage",
// 设备数据上传
"health.device-readings.manage",
// 患者自更新(绑定手机、自助建档)
"health.patient.manage",
// AI 分析报告查看
"ai.analysis.list",
// AI 聊天会话列表
"ai.chat.session.list",
// AI 聊天会话管理
"ai.chat.session.manage",
// 埋点提交
"system.analytics.submit",
];
// 为所有租户的 patient 角色批量分配幂等data_scope=self
assign_perms_by_codes(db, "patient", patient_manage_perms).await?;
// 3) 患者角色缺失的 .list 权限
let patient_list_perms: &[&str] = &[
// 化验报告 + 健康记录 + 诊断记录 + 体征列表(共享 health.health-data.list
"health.health-data.list",
// 行动收件箱(首页工作台)
"health.action-inbox.list",
];
assign_perms_by_codes(db, "patient", patient_list_perms).await?;
// 4) 为 admin/doctor/nurse/health_manager 角色 also 分配 system.analytics.submit
// 这些角色可能也需要埋点权限
let analytics_roles: &[&str] = &["admin", "doctor", "nurse", "health_manager"];
for role in analytics_roles {
assign_single_perm(db, role, "system.analytics.submit").await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 移除 patient 角色新增的权限关联
let remove_codes: &[&str] = &[
"health.health-data.manage",
"health.health-data.list",
"health.daily-monitoring.manage",
"health.appointment.manage",
"health.doctor.list",
"health.follow-up.manage",
"health.consultation.manage",
"health.medication-reminders.manage",
"health.consent.manage",
"health.device-readings.manage",
"health.patient.manage",
"ai.analysis.list",
"ai.chat.session.list",
"ai.chat.session.manage",
"system.analytics.submit",
"health.action-inbox.list",
];
let codes_csv: String = remove_codes
.iter()
.map(|c| format!("'{}'", c))
.collect::<Vec<_>>()
.join(",");
db.execute_unprepared(&format!(
"DELETE FROM role_permissions \
WHERE role_id IN (SELECT id FROM roles WHERE code = 'patient') \
AND permission_id IN (SELECT id FROM permissions WHERE code IN ({codes_csv}))"
))
.await?;
// 移除其他角色的 system.analytics.submit
let analytics_roles: &[&str] = &["admin", "doctor", "nurse", "health_manager"];
for role in analytics_roles {
db.execute_unprepared(&format!(
"DELETE FROM role_permissions \
WHERE role_id IN (SELECT id FROM roles WHERE code = '{role}') \
AND permission_id IN (SELECT id FROM permissions WHERE code = 'system.analytics.submit')"
)).await?;
}
// 软删除 system.analytics.submit 权限
db.execute_unprepared(
"UPDATE permissions SET deleted_at = NOW() WHERE code = 'system.analytics.submit' AND deleted_at IS NULL"
).await?;
Ok(())
}
}
async fn assign_perms_by_codes(
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
role_code: &str,
perm_codes: &[&str],
) -> Result<(), DbErr> {
let codes_csv: String = perm_codes
.iter()
.map(|c| format!("'{}'", c))
.collect::<Vec<_>>()
.join(",");
db.execute_unprepared(&format!(
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT r.id, p.id, r.tenant_id, 'self', NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ({codes_csv}) AND p.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
)).await?;
Ok(())
}
async fn assign_single_perm(
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
role_code: &str,
perm_code: &str,
) -> Result<(), DbErr> {
db.execute_unprepared(&format!(
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{perm_code}' AND p.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
)).await?;
Ok(())
}

View File

@@ -24,6 +24,7 @@ fn default_create_article_req() -> CreateArticleReq {
content_type: None,
category_id: None,
tag_ids: vec![],
is_public: true,
}
}
@@ -206,6 +207,7 @@ async fn test_article_update() {
category_id: None,
tag_ids: None,
sort_order: None,
is_public: None,
version: article.version,
},
)
@@ -248,6 +250,7 @@ async fn test_article_list_filter() {
None,
None,
None,
None,
)
.await
.unwrap();
@@ -263,6 +266,7 @@ async fn test_article_list_filter() {
None,
None,
None,
None,
)
.await
.unwrap();
@@ -417,6 +421,7 @@ async fn test_tag_crud_and_article_association() {
content_type: None,
category_id: None,
sort_order: None,
is_public: None,
},
)
.await
@@ -489,6 +494,7 @@ async fn test_article_version_conflict() {
category_id: None,
tag_ids: None,
sort_order: None,
is_public: None,
},
)
.await
@@ -514,6 +520,7 @@ async fn test_article_version_conflict() {
category_id: None,
tag_ids: None,
sort_order: None,
is_public: None,
},
)
.await;