功能修复: 1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查 2. 仪表盘统计容错:单个查询失败返回零值而非 500 3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致 4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径 5. 积分端点权限码:health.health-data.list → health.points.list 6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage 7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档 Clippy 全 workspace 清零(14→0 errors): - erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处 - erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处 - erp-ai: 修复 dead_code、unused import 等 11 处 - erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处 - erp-server-migration: 修复 enum_variant_names 5 处 - erp-auth/config/workflow/message: 各 1-3 处 工程改进: - lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy) - cargo fmt 统一格式化
177 lines
5.7 KiB
Rust
177 lines
5.7 KiB
Rust
use axum::Extension;
|
||
use axum::extract::{FromRef, Json, State};
|
||
use axum::response::Json as JsonResponse;
|
||
|
||
use erp_core::error::AppError;
|
||
use erp_core::rbac::require_permission;
|
||
use erp_core::types::{ApiResponse, TenantContext};
|
||
|
||
use crate::config_state::ConfigState;
|
||
use crate::dto::{PublicBrandResp, SetSettingParams, ThemeResp};
|
||
use crate::error::ConfigError;
|
||
use crate::service::setting_service::SettingService;
|
||
|
||
/// 默认主题配置。
|
||
fn default_theme() -> ThemeResp {
|
||
ThemeResp {
|
||
primary_color: None,
|
||
logo_url: None,
|
||
sidebar_style: None,
|
||
brand_name: Some("HMS 健康管理平台".into()),
|
||
brand_slogan: Some("新一代健康管理平台".into()),
|
||
brand_features: Some("患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),
|
||
brand_copyright: Some("HMS 健康管理平台 · ©汕头市智界科技有限公司".into()),
|
||
}
|
||
}
|
||
|
||
#[utoipa::path(
|
||
get,
|
||
path = "/api/v1/themes",
|
||
responses(
|
||
(status = 200, description = "成功", body = ApiResponse<ThemeResp>),
|
||
(status = 401, description = "未授权"),
|
||
(status = 403, description = "权限不足"),
|
||
),
|
||
security(("bearer_auth" = [])),
|
||
tag = "主题设置"
|
||
)]
|
||
/// GET /api/v1/theme
|
||
///
|
||
/// 获取当前租户的主题配置。
|
||
/// 主题配置存储在 settings 表中,key 为 "theme",scope 为 "tenant"。
|
||
/// 当没有任何主题配置时,返回默认主题(所有字段为 null)。
|
||
/// 需要 `theme.read` 权限。
|
||
pub async fn get_theme<S>(
|
||
State(state): State<ConfigState>,
|
||
Extension(ctx): Extension<TenantContext>,
|
||
) -> Result<JsonResponse<ApiResponse<ThemeResp>>, AppError>
|
||
where
|
||
ConfigState: FromRef<S>,
|
||
S: Clone + Send + Sync + 'static,
|
||
{
|
||
require_permission(&ctx, "theme.read")?;
|
||
|
||
let theme = match SettingService::get("theme", "tenant", &None, ctx.tenant_id, &state.db).await
|
||
{
|
||
Ok(setting) => serde_json::from_value(setting.setting_value)
|
||
.map_err(|e| AppError::Validation(format!("主题配置解析失败: {e}")))?,
|
||
Err(ConfigError::NotFound(_)) => default_theme(),
|
||
Err(e) => return Err(e.into()),
|
||
};
|
||
|
||
Ok(JsonResponse(ApiResponse::ok(theme)))
|
||
}
|
||
|
||
#[utoipa::path(
|
||
put,
|
||
path = "/api/v1/themes",
|
||
request_body = ThemeResp,
|
||
responses(
|
||
(status = 200, description = "成功", body = ApiResponse<ThemeResp>),
|
||
(status = 401, description = "未授权"),
|
||
(status = 403, description = "权限不足"),
|
||
),
|
||
security(("bearer_auth" = [])),
|
||
tag = "主题设置"
|
||
)]
|
||
/// PUT /api/v1/theme
|
||
///
|
||
/// 更新当前租户的主题配置。
|
||
/// 将主题配置序列化为 JSON 存储到 settings 表。
|
||
/// 需要 `theme.update` 权限。
|
||
pub async fn update_theme<S>(
|
||
State(state): State<ConfigState>,
|
||
Extension(ctx): Extension<TenantContext>,
|
||
Json(req): Json<ThemeResp>,
|
||
) -> Result<JsonResponse<ApiResponse<ThemeResp>>, AppError>
|
||
where
|
||
ConfigState: FromRef<S>,
|
||
S: Clone + Send + Sync + 'static,
|
||
{
|
||
require_permission(&ctx, "theme.update")?;
|
||
|
||
let value = serde_json::to_value(&req)
|
||
.map_err(|e| AppError::Validation(format!("主题配置序列化失败: {e}")))?;
|
||
|
||
SettingService::set(
|
||
SetSettingParams {
|
||
key: "theme".to_string(),
|
||
scope: "tenant".to_string(),
|
||
scope_id: None,
|
||
value,
|
||
version: None,
|
||
},
|
||
ctx.tenant_id,
|
||
ctx.user_id,
|
||
&state.db,
|
||
&state.event_bus,
|
||
)
|
||
.await?;
|
||
|
||
Ok(JsonResponse(ApiResponse::ok(req)))
|
||
}
|
||
|
||
#[utoipa::path(
|
||
get,
|
||
path = "/api/v1/public/brand",
|
||
responses(
|
||
(status = 200, description = "成功", body = ApiResponse<PublicBrandResp>),
|
||
),
|
||
tag = "主题设置"
|
||
)]
|
||
/// GET /api/v1/public/brand
|
||
///
|
||
/// 获取公开品牌信息(无需认证)。
|
||
pub async fn get_public_brand() -> JsonResponse<ApiResponse<PublicBrandResp>> {
|
||
let defaults = default_theme();
|
||
JsonResponse(ApiResponse::ok(PublicBrandResp {
|
||
brand_name: defaults
|
||
.brand_name
|
||
.unwrap_or_else(|| "HMS 健康管理平台".into()),
|
||
brand_slogan: defaults
|
||
.brand_slogan
|
||
.unwrap_or_else(|| "新一代健康管理平台".into()),
|
||
brand_features: defaults
|
||
.brand_features
|
||
.unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),
|
||
brand_copyright: defaults
|
||
.brand_copyright
|
||
.unwrap_or_else(|| "HMS 健康管理平台 · ©汕头市智界科技有限公司".into()),
|
||
}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn default_theme_has_brand_defaults() {
|
||
let theme = default_theme();
|
||
assert!(theme.primary_color.is_none());
|
||
assert!(theme.logo_url.is_none());
|
||
assert!(theme.sidebar_style.is_none());
|
||
assert_eq!(theme.brand_name, Some("HMS 健康管理平台".to_string()));
|
||
assert_eq!(theme.brand_slogan, Some("新一代健康管理平台".to_string()));
|
||
assert!(theme.brand_features.is_some());
|
||
assert!(theme.brand_copyright.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn theme_resp_serde_roundtrip() {
|
||
let theme = ThemeResp {
|
||
primary_color: Some("#1890ff".to_string()),
|
||
logo_url: None,
|
||
sidebar_style: Some("dark".to_string()),
|
||
brand_name: Some("测试平台".to_string()),
|
||
brand_slogan: None,
|
||
brand_features: None,
|
||
brand_copyright: None,
|
||
};
|
||
let json = serde_json::to_string(&theme).unwrap();
|
||
let back: ThemeResp = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(back.primary_color, Some("#1890ff".to_string()));
|
||
assert_eq!(back.brand_name, Some("测试平台".to_string()));
|
||
assert!(back.brand_slogan.is_none());
|
||
}
|
||
}
|