Files
hms/docs/superpowers/plans/2026-05-10-media-library-banner-plan.md
iven d6abf45e7e
Some checks failed
CI / security-audit (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
docs(health): 媒体库与轮播图实施计划 — 5 Chunk / 22 Task
Chunk 1: 后端实体 + 迁移 + DTO (Task 1-7)
Chunk 2: 后端 Service 层 (Task 8-9)
Chunk 3: Handler + 路由 + 公开端点 + 签名URL (Task 10-14)
Chunk 4: 前端 API + MediaPicker + 页面 (Task 15-20)
Chunk 5: 小程序访客页改造 (Task 21-22)
2026-05-10 13:35:30 +08:00

52 KiB
Raw Blame History

媒体库与轮播图管理 — 实施计划

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 为 HMS 健康管理平台新增媒体库、轮播图管理能力,并改造小程序访客首页。

Architecture: 在 erp-health 模块内新增 media_folder/media_item/banner 三个实体,复用现有 SeaORM + Axum 分层模式entity → dto → service → handler → module.rs 路由注册)。公开端点通过签名 URL 实现未认证访问。前端新增 MediaLibrary + BannerManage 页面,抽取 MediaPicker 组件复用于文章编辑器。

Tech Stack: Rust/Axum/SeaORM (后端), React 19/Ant Design 6 (Web 前端), Taro 4.2/React 18 (小程序)

Design Spec: docs/superpowers/specs/2026-05-10-media-library-banner-design.md


File Structure

Backend — 新增文件

文件 职责
crates/erp-health/src/entity/media_folder.rs 文件夹实体
crates/erp-health/src/entity/media_item.rs 媒体资源实体
crates/erp-health/src/entity/banner.rs 轮播图实体
crates/erp-health/src/dto/media_dto.rs 媒体库 DTO请求/响应/列表参数)
crates/erp-health/src/dto/banner_dto.rs 轮播图 DTO
crates/erp-health/src/service/media_service.rs 媒体库业务逻辑CRUD + 缩略图 + 裁剪)
crates/erp-health/src/service/banner_service.rs 轮播图业务逻辑CRUD + 排序 + 定时)
crates/erp-health/src/handler/media_handler.rs 媒体库 HTTP 处理
crates/erp-health/src/handler/banner_handler.rs 轮播图 HTTP 处理
crates/erp-server/migration/src/m20260510_000130_create_media_folder.rs 建表迁移
crates/erp-server/migration/src/m20260510_000131_create_media_item.rs 建表迁移
crates/erp-server/migration/src/m20260510_000132_create_banner.rs 建表迁移
crates/erp-server/migration/src/m20260510_000133_seed_media_menus.rs 菜单种子
crates/erp-server/migration/src/m20260510_000134_seed_home_category.rs 首页推荐分类种子

Backend — 修改文件

文件 改动
crates/erp-health/Cargo.toml 添加 image crate 依赖
crates/erp-health/src/entity/mod.rs 导出 3 个新实体
crates/erp-health/src/dto/mod.rs 导出 2 个新 DTO 模块
crates/erp-health/src/service/mod.rs 导出 2 个新 service 模块
crates/erp-health/src/handler/mod.rs 导出 2 个新 handler 模块
crates/erp-health/src/module.rs 注册路由 + 权限码 + 公开路由
crates/erp-server/Cargo.toml 添加 hmac + sha2 依赖
crates/erp-server/src/config.rs StorageConfig 增加 secret_key
crates/erp-server/config/default.toml 增加 secret_key 配置项
crates/erp-server/src/handlers/upload.rs 签名 URL 验证逻辑
crates/erp-server/src/main.rs 公开路由组注册
crates/erp-server/migration/src/lib.rs 注册 5 个新迁移文件

Frontend — 新增文件

文件 职责
apps/web/src/api/health/media.ts 媒体库 API 客户端
apps/web/src/api/health/banners.ts 轮播图 API 客户端
apps/web/src/components/health/MediaPicker/index.tsx 媒体选择器组件
apps/web/src/pages/health/MediaLibrary.tsx 媒体库管理页面
apps/web/src/pages/health/BannerManage.tsx 轮播图管理页面

Frontend — 修改文件

文件 改动
apps/web/src/App.tsx 添加 2 个新路由
apps/web/src/routeConfig.ts 添加权限配置
apps/web/src/pages/health/ArticleEditor.tsx 封面图/插图改用 MediaPicker

Miniprogram — 修改文件

文件 改动
apps/miniprogram/src/services/article.ts 新增公开 API 调用
apps/miniprogram/src/services/request.ts /public/ 路径跳过认证
apps/miniprogram/src/pages/index/index.tsx 轮播图 + 文章列表接入 API
apps/miniprogram/src/pages/index/index.scss 新增文章卡片样式

Chunk 1: Backend Entities + Migrations + DTOs

Task 1: 添加 Cargo 依赖

Files:

  • Modify: crates/erp-health/Cargo.toml

  • Modify: crates/erp-server/Cargo.toml

  • Step 1: 在 erp-health 添加 image crate

crates/erp-health/Cargo.toml[dependencies] 中添加:

image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
  • Step 2: 在 erp-server 添加 hmac + sha2

crates/erp-server/Cargo.toml[dependencies] 中添加:

hmac = "0.12"
sha2 = "0.10"
  • Step 3: 验证编译

Run: cargo check -p erp-health -p erp-server Expected: 编译成功

  • Step 4: 提交
chore(health): 添加 image/hmac/sha2 依赖

Task 2: 创建 media_folder 实体

Files:

  • Create: crates/erp-health/src/entity/media_folder.rs

  • Modify: crates/erp-health/src/entity/mod.rs

  • Step 1: 创建实体文件

创建 crates/erp-health/src/entity/media_folder.rs

use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "media_folder")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: Uuid,
    pub tenant_id: Uuid,
    pub name: String,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub parent_id: Option<Uuid>,
    pub sort_order: i32,
    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 {
    #[sea_orm(belongs_to = "super::media_folder::Entity", from = "Column::ParentId", to = "Column::Id")]
    Parent,
    #[sea_orm(has_many = "super::media_folder::Entity")]
    Children,
    #[sea_orm(has_many = "super::media_item::Entity")]
    MediaItems,
}

impl Related<super::media_folder::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Parent.def()
    }
}

impl Related<super::media_item::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::MediaItems.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}
  • Step 2: 在 mod.rs 导出

crates/erp-health/src/entity/mod.rs 添加:

pub mod media_folder;
pub mod media_item;
pub mod banner;
  • Step 3: 验证编译

Run: cargo check -p erp-health Expected: 编译成功media_item 和 banner 还未创建,暂时报错没关系,下一步创建)

Task 3: 创建 media_item 实体

Files:

  • Create: crates/erp-health/src/entity/media_item.rs

  • Step 1: 创建实体文件

创建 crates/erp-health/src/entity/media_item.rs

use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "media_item")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: Uuid,
    pub tenant_id: Uuid,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub folder_id: Option<Uuid>,
    pub filename: String,
    pub storage_path: String,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub thumbnail_path: Option<String>,
    pub content_type: String,
    pub file_size: i64,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub width: Option<i32>,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub height: Option<i32>,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub alt_text: Option<String>,
    pub is_public: bool,
    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 {
    #[sea_orm(belongs_to = "super::media_folder::Entity", from = "Column::FolderId", to = "Column::Id")]
    Folder,
    #[sea_orm(has_many = "super::banner::Entity")]
    Banners,
}

impl Related<super::media_folder::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Folder.def()
    }
}

impl Related<super::banner::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Banners.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}

Task 4: 创建 banner 实体

Files:

  • Create: crates/erp-health/src/entity/banner.rs

  • Step 1: 创建实体文件

创建 crates/erp-health/src/entity/banner.rs

use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "banner")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: Uuid,
    pub tenant_id: Uuid,
    pub media_item_id: Uuid,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub subtitle: Option<String>,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub link_type: Option<String>,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub link_target: Option<String>,
    pub sort_order: i32,
    pub status: String,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub start_time: Option<DateTimeUtc>,
    #[sea_orm(skip_serializing_if = "Option::is_none")]
    pub end_time: 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 {
    #[sea_orm(belongs_to = "super::media_item::Entity", from = "Column::MediaItemId", to = "Column::Id")]
    MediaItem,
}

impl Related<super::media_item::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::MediaItem.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}
  • Step 2: 验证编译

Run: cargo check -p erp-health Expected: 编译成功

  • Step 3: 提交
feat(health): 新增 media_folder/media_item/banner 实体

Task 5: 创建数据库迁移

Files:

  • Create: crates/erp-server/migration/src/m20260510_000130_create_media_folder.rs

  • Create: crates/erp-server/migration/src/m20260510_000131_create_media_item.rs

  • Create: crates/erp-server/migration/src/m20260510_000132_create_banner.rs

  • Modify: crates/erp-server/migration/src/lib.rs

  • Step 1: 查看最新迁移序号

Run: ls crates/erp-server/migration/src/m2026*.rs | tail -5 确认最新序号,确保 000130-000132 不冲突。如有冲突则递增。

  • Step 2: 创建 media_folder 迁移

创建 crates/erp-server/migration/src/m20260510_000130_create_media_folder.rs

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(MediaFolder::Table)
                    .col(ColumnDef::new(MediaFolder::Id).uuid().not_null().primary_key())
                    .col(ColumnDef::new(MediaFolder::TenantId).uuid().not_null())
                    .col(ColumnDef::new(MediaFolder::Name).string_len(100).not_null())
                    .col(ColumnDef::new(MediaFolder::ParentId).uuid())
                    .col(ColumnDef::new(MediaFolder::SortOrder).integer().not_null().default(0))
                    .col(ColumnDef::new(MediaFolder::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
                    .col(ColumnDef::new(MediaFolder::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
                    .col(ColumnDef::new(MediaFolder::CreatedBy).uuid())
                    .col(ColumnDef::new(MediaFolder::UpdatedBy).uuid())
                    .col(ColumnDef::new(MediaFolder::DeletedAt).timestamp_with_time_zone())
                    .col(ColumnDef::new(MediaFolder::Version).integer().not_null().default(1))
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_media_folder_parent")
                            .from(MediaFolder::Table, MediaFolder::ParentId)
                            .to(MediaFolder::Table, MediaFolder::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .to_owned(),
            )
            .await?;

        manager
            .create_index(
                Index::create()
                    .name("idx_media_folder_tenant_parent")
                    .table(MediaFolder::Table)
                    .col(MediaFolder::TenantId)
                    .col(MediaFolder::ParentId)
                    .to_owned(),
            )
            .await?;

        Ok(())
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager.drop_table(Table::drop().table(MediaFolder::Table).to_owned()).await
    }
}

#[derive(DeriveIden)]
#[allow(clippy::enum_variant_names)]
enum MediaFolder {
    Table,
    Id,
    TenantId,
    Name,
    ParentId,
    SortOrder,
    CreatedAt,
    UpdatedAt,
    CreatedBy,
    UpdatedBy,
    DeletedAt,
    Version,
}
  • Step 3: 创建 media_item 迁移

创建 crates/erp-server/migration/src/m20260510_000131_create_media_item.rs

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(MediaItem::Table)
                    .col(ColumnDef::new(MediaItem::Id).uuid().not_null().primary_key())
                    .col(ColumnDef::new(MediaItem::TenantId).uuid().not_null())
                    .col(ColumnDef::new(MediaItem::FolderId).uuid())
                    .col(ColumnDef::new(MediaItem::Filename).string_len(255).not_null())
                    .col(ColumnDef::new(MediaItem::StoragePath).string_len(500).not_null().unique_key())
                    .col(ColumnDef::new(MediaItem::ThumbnailPath).string_len(500))
                    .col(ColumnDef::new(MediaItem::ContentType).string_len(100).not_null())
                    .col(ColumnDef::new(MediaItem::FileSize).big_integer().not_null())
                    .col(ColumnDef::new(MediaItem::Width).integer())
                    .col(ColumnDef::new(MediaItem::Height).integer())
                    .col(ColumnDef::new(MediaItem::AltText).string_len(255))
                    .col(ColumnDef::new(MediaItem::IsPublic).boolean().not_null().default(false))
                    .col(ColumnDef::new(MediaItem::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
                    .col(ColumnDef::new(MediaItem::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
                    .col(ColumnDef::new(MediaItem::CreatedBy).uuid())
                    .col(ColumnDef::new(MediaItem::UpdatedBy).uuid())
                    .col(ColumnDef::new(MediaItem::DeletedAt).timestamp_with_time_zone())
                    .col(ColumnDef::new(MediaItem::Version).integer().not_null().default(1))
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_media_item_folder")
                            .from(MediaItem::Table, MediaItem::FolderId)
                            .to(MediaFolder::Table, MediaFolder::Id)
                            .on_delete(ForeignKeyAction::SetNull),
                    )
                    .to_owned(),
            )
            .await?;

        manager
            .create_index(Index::create().name("idx_media_item_tenant_folder").table(MediaItem::Table).col(MediaItem::TenantId).col(MediaItem::FolderId).to_owned())
            .await?;
        manager
            .create_index(Index::create().name("idx_media_item_tenant_public").table(MediaItem::Table).col(MediaItem::TenantId).col(MediaItem::IsPublic).to_owned())
            .await?;

        Ok(())
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager.drop_table(Table::drop().table(MediaItem::Table).to_owned()).await
    }
}

#[derive(DeriveIden)]
#[allow(clippy::enum_variant_names)]
enum MediaItem {
    Table,
    Id,
    TenantId,
    FolderId,
    Filename,
    StoragePath,
    ThumbnailPath,
    ContentType,
    FileSize,
    Width,
    Height,
    AltText,
    IsPublic,
    CreatedAt,
    UpdatedAt,
    CreatedBy,
    UpdatedBy,
    DeletedAt,
    Version,
}

#[derive(DeriveIden)]
enum MediaFolder {
    Table,
    Id,
}
  • Step 4: 创建 banner 迁移

创建 crates/erp-server/migration/src/m20260510_000132_create_banner.rs

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(Banner::Table)
                    .col(ColumnDef::new(Banner::Id).uuid().not_null().primary_key())
                    .col(ColumnDef::new(Banner::TenantId).uuid().not_null())
                    .col(ColumnDef::new(Banner::MediaItemId).uuid().not_null())
                    .col(ColumnDef::new(Banner::Title).string_len(100))
                    .col(ColumnDef::new(Banner::Subtitle).string_len(255))
                    .col(ColumnDef::new(Banner::LinkType).string_len(20))
                    .col(ColumnDef::new(Banner::LinkTarget).string_len(500))
                    .col(ColumnDef::new(Banner::SortOrder).integer().not_null().default(0))
                    .col(ColumnDef::new(Banner::Status).string_len(20).not_null().default("active"))
                    .col(ColumnDef::new(Banner::StartTime).timestamp_with_time_zone())
                    .col(ColumnDef::new(Banner::EndTime).timestamp_with_time_zone())
                    .col(ColumnDef::new(Banner::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
                    .col(ColumnDef::new(Banner::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
                    .col(ColumnDef::new(Banner::CreatedBy).uuid())
                    .col(ColumnDef::new(Banner::UpdatedBy).uuid())
                    .col(ColumnDef::new(Banner::DeletedAt).timestamp_with_time_zone())
                    .col(ColumnDef::new(Banner::Version).integer().not_null().default(1))
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_banner_media_item")
                            .from(Banner::Table, Banner::MediaItemId)
                            .to(MediaItem::Table, MediaItem::Id)
                            .on_delete(ForeignKeyAction::Restrict),
                    )
                    .to_owned(),
            )
            .await?;

        manager
            .create_index(Index::create().name("idx_banner_tenant_status").table(Banner::Table).col(Banner::TenantId).col(Banner::Status).to_owned())
            .await?;
        manager
            .create_index(Index::create().name("idx_banner_sort").table(Banner::Table).col(Banner::TenantId).col(Banner::SortOrder).to_owned())
            .await?;

        Ok(())
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager.drop_table(Table::drop().table(Banner::Table).to_owned()).await
    }
}

#[derive(DeriveIden)]
#[allow(clippy::enum_variant_names)]
enum Banner {
    Table,
    Id,
    TenantId,
    MediaItemId,
    Title,
    Subtitle,
    LinkType,
    LinkTarget,
    SortOrder,
    Status,
    StartTime,
    EndTime,
    CreatedAt,
    UpdatedAt,
    CreatedBy,
    UpdatedBy,
    DeletedAt,
    Version,
}

#[derive(DeriveIden)]
enum MediaItem {
    Table,
    Id,
}
  • Step 5: 在 lib.rs 注册迁移文件

crates/erp-server/migration/src/lib.rs 中添加 mod 声明和迁移列表条目(参照现有格式)。

  • Step 6: 验证编译

Run: cargo check -p erp-server Expected: 编译成功

  • Step 7: 提交
feat(db): 新增 media_folder/media_item/banner 迁移

Task 6: 创建媒体库 DTO

Files:

  • Create: crates/erp-health/src/dto/media_dto.rs

  • Modify: crates/erp-health/src/dto/mod.rs

  • Step 1: 创建 DTO 文件

创建 crates/erp-health/src/dto/media_dto.rs

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;

use erp_core::sanitize::{sanitize_option, sanitize_string};

// ===== 媒体资源 =====

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MediaItemResp {
    pub id: Uuid,
    pub folder_id: Option<Uuid>,
    pub folder_name: Option<String>,
    pub filename: String,
    pub url: String,
    pub thumbnail_url: Option<String>,
    pub content_type: String,
    pub file_size: i64,
    pub width: Option<i32>,
    pub height: Option<i32>,
    pub alt_text: Option<String>,
    pub is_public: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub version: i32,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateMediaItemReq {
    #[serde(skip_serializing)]
    pub folder_id: Option<Uuid>,
    #[serde(skip_serializing)]
    pub is_public: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateMediaItemReq {
    pub alt_text: Option<String>,
    pub folder_id: Option<Uuid>,
    pub is_public: Option<bool>,
    pub version: i32,
}

impl UpdateMediaItemReq {
    pub fn sanitize(&mut self) {
        if let Some(ref mut v) = self.alt_text {
            *v = sanitize_string(v);
        }
    }
}

#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct MediaListParams {
    pub page: Option<u64>,
    pub page_size: Option<u64>,
    pub folder_id: Option<Uuid>,
    pub content_type: Option<String>,
    pub keyword: Option<String>,
    pub is_public: Option<bool>,
    pub sort_by: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MoveMediaReq {
    pub folder_id: Option<Uuid>,
    pub version: i32,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchDeleteReq {
    pub ids: Vec<Uuid>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CropReq {
    pub x: u32,
    pub y: u32,
    pub width: u32,
    pub height: u32,
    pub version: i32,
}

// ===== 文件夹 =====

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FolderResp {
    pub id: Uuid,
    pub name: String,
    pub parent_id: Option<Uuid>,
    pub sort_order: i32,
    pub children: Vec<FolderResp>,
    pub item_count: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateFolderReq {
    pub name: String,
    pub parent_id: Option<Uuid>,
}

impl CreateFolderReq {
    pub fn sanitize(&mut self) {
        self.name = sanitize_string(&self.name);
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateFolderReq {
    pub name: Option<String>,
    pub parent_id: Option<Option<Uuid>>,
    pub sort_order: Option<i32>,
    pub version: i32,
}

impl UpdateFolderReq {
    pub fn sanitize(&mut self) {
        if let Some(ref mut v) = self.name {
            *v = sanitize_string(v);
        }
    }
}

Task 7: 创建轮播图 DTO

Files:

  • Create: crates/erp-health/src/dto/banner_dto.rs

  • Step 1: 创建 DTO 文件

创建 crates/erp-health/src/dto/banner_dto.rs

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;

use erp_core::sanitize::{sanitize_option, sanitize_string};

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BannerResp {
    pub id: Uuid,
    pub media_item_id: Uuid,
    pub image_url: String,
    pub thumbnail_url: Option<String>,
    pub title: Option<String>,
    pub subtitle: Option<String>,
    pub link_type: Option<String>,
    pub link_target: Option<String>,
    pub sort_order: i32,
    pub status: String,
    pub start_time: Option<DateTime<Utc>>,
    pub end_time: Option<DateTime<Utc>>,
    pub media_deleted: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub version: i32,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateBannerReq {
    pub media_item_id: Uuid,
    pub title: Option<String>,
    pub subtitle: Option<String>,
    pub link_type: Option<String>,
    pub link_target: Option<String>,
    #[serde(default)]
    pub sort_order: i32,
    #[serde(default = "default_active")]
    pub status: String,
    pub start_time: Option<DateTime<Utc>>,
    pub end_time: Option<DateTime<Utc>>,
}

fn default_active() -> String {
    "active".to_string()
}

impl CreateBannerReq {
    pub fn sanitize(&mut self) {
        self.title = sanitize_option(self.title.take());
        self.subtitle = sanitize_option(self.subtitle.take());
        if let Some(ref mut v) = self.link_target {
            *v = sanitize_string(v);
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateBannerReq {
    pub media_item_id: Option<Uuid>,
    pub title: Option<String>,
    pub subtitle: Option<String>,
    pub link_type: Option<String>,
    pub link_target: Option<String>,
    pub sort_order: Option<i32>,
    pub status: Option<String>,
    pub start_time: Option<Option<DateTime<Utc>>>,
    pub end_time: Option<Option<DateTime<Utc>>>,
    pub version: i32,
}

impl UpdateBannerReq {
    pub fn sanitize(&mut self) {
        self.title = sanitize_option(self.title.take());
        self.subtitle = sanitize_option(self.subtitle.take());
        if let Some(ref mut v) = self.link_target {
            *v = sanitize_string(v);
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SortBannerReq {
    pub items: Vec<SortItem>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SortItem {
    pub id: Uuid,
    pub sort_order: i32,
}

// 公开端点响应(不含内部字段)
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PublicBannerResp {
    pub id: Uuid,
    pub title: Option<String>,
    pub subtitle: Option<String>,
    pub image_url: String,
    pub link_type: Option<String>,
    pub link_target: Option<String>,
}
  • Step 2: 在 dto/mod.rs 导出

crates/erp-health/src/dto/mod.rs 添加:

pub mod media_dto;
pub mod banner_dto;
  • Step 3: 验证编译

Run: cargo check -p erp-health Expected: 编译成功

  • Step 4: 提交
feat(health): 新增媒体库和轮播图 DTO

Chunk 2: Backend Services

Task 8: 创建 media_service — 基础 CRUD

Files:

  • Create: crates/erp-health/src/service/media_service.rs

  • Modify: crates/erp-health/src/service/mod.rs

  • Step 1: 创建 service 文件

创建 crates/erp-health/src/service/media_service.rs,实现以下函数:

关键函数清单:

函数 说明
list_media_items(state, tenant_id, params) 分页列表folder_id/keyword/content_type/is_public 筛选sort_by 排序)
get_media_item(state, tenant_id, id) 获取单个资源详情
upload_media(state, tenant_id, operator_id, file_data, folder_id, is_public, upload_dir) 保存文件 + 生成缩略图 + 创建 DB 记录
update_media_item(state, tenant_id, id, operator_id, req) 更新元数据(乐观锁)
delete_media_item(state, tenant_id, id, operator_id, version) 软删除 + 级联 banner inactive + 删除文件
batch_delete(state, tenant_id, operator_id, ids) 批量软删除(事务)
move_media(state, tenant_id, id, operator_id, req) 移动文件夹
crop_media(state, tenant_id, id, operator_id, req) 裁剪图片 + 重新生成缩略图
list_folders(state, tenant_id) 树形文件夹列表(含 item_count
create_folder(state, tenant_id, operator_id, req) 创建文件夹
update_folder(state, tenant_id, id, operator_id, req) 重命名/移动
delete_folder(state, tenant_id, id, operator_id, version) 删除空文件夹
generate_thumbnail(src_path, thumb_path, max_size) 缩略图生成200×200 居中裁剪)
crop_image(src_path, x, y, w, h) 裁剪图片

核心实现要点:

  1. 缩略图生成 使用 image crate

    pub fn generate_thumbnail(src: &Path, dst: &Path, size: u32) -> Result<(), HealthError> {
        let img = image::open(src).map_err(|e| HealthError::Internal(e.to_string()))?;
        let (w, h) = (img.width(), img.height());
        let crop_size = w.min(h);
        let x = (w - crop_size) / 2;
        let y = (h - crop_size) / 2;
        let thumb = img.crop(x, y, crop_size, crop_size)
            .resize_exact(size, size, image::imageops::FilterType::Lanczos3);
        thumb.save(dst).map_err(|e| HealthError::Internal(e.to_string()))?;
        Ok(())
    }
    
  2. 上传流程

    • 保存原文件到 {upload_dir}/{tenant_id}/{uuid}.ext
    • 调用 generate_thumbnail 生成 thumb_{uuid}.ext
    • image::open 读取 width/height
    • 创建 media_item::ActiveModel 并 insert
  3. 软删除级联

    // 在 delete_media_item 中:
    banner::Entity::update_many()
        .col(banner::Column::Status, Set("inactive".to_string()))
        .filter(banner::Column::MediaItemId.eq(id))
        .filter(banner::Column::TenantId.eq(tenant_id))
        .exec(&state.db)
        .await?;
    
  4. 文件夹树构建:一次查询所有文件夹 → 递归构建树 → 每个节点附带 item_count(用 GROUP BY 一次查询)

  • Step 2: 在 service/mod.rs 导出

添加:pub mod media_service;

  • Step 3: 验证编译

Run: cargo check -p erp-health Expected: 编译成功

  • Step 4: 提交
feat(health): 实现媒体库 service — CRUD + 缩略图 + 裁剪

Task 9: 创建 banner_service

Files:

  • Create: crates/erp-health/src/service/banner_service.rs

  • Modify: crates/erp-health/src/service/mod.rs

  • Step 1: 创建 service 文件

创建 crates/erp-health/src/service/banner_service.rs,实现:

函数 说明
list_banners(state, tenant_id, status) 管理列表(含 media_deleted 标记)
create_banner(state, tenant_id, operator_id, req) 创建(验证 media_item 存在且未删除)
update_banner(state, tenant_id, id, operator_id, req) 更新(乐观锁)
delete_banner(state, tenant_id, id, operator_id, version) 软删除
sort_banners(state, tenant_id, items) 批量更新排序
list_public_banners(state, tenant_id, base_url) 公开列表active + 时间范围 + 签名 URL
generate_signed_url(path, secret_key, ttl_secs) 生成签名 URL
verify_signed_url(path, token, expires, secret_key) 验证签名 URL

签名 URL 实现:

use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

pub fn generate_signed_url(path: &str, secret_key: &str, ttl_secs: u64) -> (String, i64) {
    let expires = chrono::Utc::now().timestamp() + ttl_secs as i64;
    let message = format!("{}\n{}", path, expires);
    let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())
        .expect("HMAC key length valid");
    mac.update(message.as_bytes());
    let token = hex::encode(mac.finalize().into_bytes());
    (token, expires)
}

公开列表查询

WHERE tenant_id = ? AND status = 'active' AND deleted_at IS NULL
  AND (start_time IS NULL OR start_time <= NOW())
  AND (end_time IS NULL OR end_time >= NOW())
ORDER BY sort_order ASC
  • Step 2: 在 service/mod.rs 导出

添加:pub mod banner_service;

  • Step 3: 验证编译

Run: cargo check -p erp-health Expected: 编译成功

  • Step 4: 提交
feat(health): 实现轮播图 service — CRUD + 排序 + 签名 URL

Chunk 3: Backend Handlers + Routes + Public Endpoints

Task 10: 创建 media_handler

Files:

  • Create: crates/erp-health/src/handler/media_handler.rs

  • Modify: crates/erp-health/src/handler/mod.rs

  • Step 1: 创建 handler 文件

创建 crates/erp-health/src/handler/media_handler.rs,实现以下函数:

函数 路由 权限 说明
list_media GET /health/media health.media.list Query(MediaListParams) → 分页列表
upload_media POST /health/media/upload health.media.manage Multipart(file + folder_id + is_public)
get_media GET /health/media/{id} health.media.list Path(id) → 详情
update_media PUT /health/media/{id} health.media.manage Path(id) + Json(UpdateMediaItemReq)
delete_media DELETE /health/media/{id} health.media.manage Path(id) + Json(DeleteWithVersion)
move_media POST /health/media/{id}/move health.media.manage Path(id) + Json(MoveMediaReq)
batch_delete POST /health/media/batch-delete health.media.manage Json(BatchDeleteReq)
crop_media POST /health/media/{id}/crop health.media.manage Path(id) + Json(CropReq)
list_folders GET /health/media-folders health.media.list → 文件夹树
create_folder POST /health/media-folders health.media.manage Json(CreateFolderReq)
update_folder PUT /health/media-folders/{id} health.media.manage Path(id) + Json(UpdateFolderReq)
delete_folder DELETE /health/media-folders/{id} health.media.manage Path(id) + Json(DeleteWithVersion)

所有 handler 遵循现有模式:

  • 泛型 <S> + where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static
  • require_permission(&ctx, "...")?; 在最前面
  • req.sanitize(); 在权限检查后
  • Json(ApiResponse::ok(result)) 包装返回

upload_media 特殊处理:

  • 使用 axum::extract::Multipart 接收文件

  • Extension(ctx) 获取 tenant_id

  • State 获取 upload_dir 配置

  • 调用 media_service::upload_media

  • Step 2: 在 handler/mod.rs 导出

添加:pub mod media_handler;

  • Step 3: 验证编译

Run: cargo check -p erp-health Expected: 编译成功

  • Step 4: 提交
feat(health): 实现媒体库 handler — 12 个端点

Task 11: 创建 banner_handler

Files:

  • Create: crates/erp-health/src/handler/banner_handler.rs

  • Modify: crates/erp-health/src/handler/mod.rs

  • Step 1: 创建 handler 文件

函数 路由 权限 说明
list_banners GET /health/banners health.banners.list Query(status) → 列表
create_banner POST /health/banners health.banners.manage Json(CreateBannerReq)
update_banner PUT /health/banners/{id} health.banners.manage Path(id) + Json(UpdateBannerReq)
delete_banner DELETE /health/banners/{id} health.banners.manage Path(id) + Json(DeleteWithVersion)
sort_banners PUT /health/banners/sort health.banners.manage Json(SortBannerReq)
  • Step 2: 在 handler/mod.rs 导出

添加:pub mod banner_handler;

  • Step 3: 验证编译 + 提交
feat(health): 实现轮播图 handler — 5 个端点

Task 12: 注册路由和权限码

Files:

  • Modify: crates/erp-health/src/module.rs

  • Step 1: 在 authenticated_routes() 中添加路由

// 媒体库
.route(
    "/health/media",
    axum::routing::get(media_handler::list_media),
)
.route(
    "/health/media/upload",
    axum::routing::post(media_handler::upload_media),
)
.route(
    "/health/media/batch-delete",
    axum::routing::post(media_handler::batch_delete),
)
.route(
    "/health/media/{id}",
    axum::routing::get(media_handler::get_media)
        .put(media_handler::update_media)
        .delete(media_handler::delete_media),
)
.route(
    "/health/media/{id}/move",
    axum::routing::post(media_handler::move_media),
)
.route(
    "/health/media/{id}/crop",
    axum::routing::post(media_handler::crop_media),
)
// 媒体文件夹
.route(
    "/health/media-folders",
    axum::routing::get(media_handler::list_folders)
        .post(media_handler::create_folder),
)
.route(
    "/health/media-folders/{id}",
    axum::routing::put(media_handler::update_folder)
        .delete(media_handler::delete_folder),
)
// 轮播图
.route(
    "/health/banners",
    axum::routing::get(banner_handler::list_banners)
        .post(banner_handler::create_banner),
)
.route(
    "/health/banners/sort",
    axum::routing::put(banner_handler::sort_banners),
)
.route(
    "/health/banners/{id}",
    axum::routing::put(banner_handler::update_banner)
        .delete(banner_handler::delete_banner),
)
  • Step 2: 在 permissions() 中添加权限码
PermissionDescriptor {
    code: "health.media.list".into(),
    name: "查看媒体库".into(),
    description: "查看和管理媒体资源列表".into(),
    module: "health".into(),
},
PermissionDescriptor {
    code: "health.media.manage".into(),
    name: "管理媒体库".into(),
    description: "上传、编辑、删除、裁剪媒体资源".into(),
    module: "health".into(),
},
PermissionDescriptor {
    code: "health.banners.list".into(),
    name: "查看轮播图".into(),
    description: "查看轮播图管理列表".into(),
    module: "health".into(),
},
PermissionDescriptor {
    code: "health.banners.manage".into(),
    name: "管理轮播图".into(),
    description: "创建、编辑、删除、排序轮播图".into(),
    module: "health".into(),
},
  • Step 3: 在 public_routes() 中添加公开端点
.route(
    "/public/banners",
    axum::routing::get(banner_handler::list_public_banners),
)
.route(
    "/public/articles",
    axum::routing::get(media_handler::list_public_articles),
)

注意:公开端点需要在 handler 中从 Query/Header 提取 tenant_id而非从 JWT

  • Step 4: 验证编译 + 提交
feat(health): 注册媒体库和轮播图路由 + 权限码 + 公开端点

Task 13: 配置 secret_key + 签名 URL 中间件

Files:

  • Modify: crates/erp-server/src/config.rs

  • Modify: crates/erp-server/config/default.toml

  • Modify: crates/erp-server/src/handlers/upload.rs

  • Modify: crates/erp-server/src/main.rs

  • Step 1: StorageConfig 添加 secret_key

config.rsStorageConfig 结构体中添加:

pub secret_key: String,

default.toml[storage] 中添加:

secret_key = "change-me-in-production"
  • Step 2: 修改 upload_auth_middleware

upload.rs 的认证中间件中增加签名 URL 验证分支:

  1. 检查请求是否包含 ?token=&expires= 参数
  2. 如果有:
    • 规范化请求路径(std::path::Path::canonicalize 或手动去除 ..
    • 验证路径以 /uploads/{tenant}/ 为前缀
    • 用 HMAC-SHA256 验证签名
    • 检查 expires 未过期
    • 查询 DB 确认 media_item.is_public = true
  3. 如果没有,走原有 JWT 逻辑
  • Step 3: 在 main.rs 注册公开路由

在 main.rs 中将 HealthModule::public_routes() 的路由注册到不挂 JWT 中间件的路由组。

  • Step 4: 启动时密钥检查

在 server 启动逻辑中添加:如果 secret_key 是默认值且是生产环境,panic!

  • Step 5: 验证编译 + 启动测试

Run: cargo check -p erp-server && cargo run -p erp-server Expected: 编译成功,服务正常启动,迁移自动执行

  • Step 6: 提交
feat(server): 签名 URL 验证 + secret_key 配置 + 公开路由注册

Task 14: 种子迁移 — 菜单 + 首页推荐分类

Files:

  • Create: crates/erp-server/migration/src/m20260510_000133_seed_media_menus.rs

  • Create: crates/erp-server/migration/src/m20260510_000134_seed_home_category.rs

  • Modify: crates/erp-server/migration/src/lib.rs

  • Step 1: 创建菜单种子迁移

参照现有 m20260426_000059_seed_menus.rs 格式,插入:

  • "媒体库" 菜单项(路径 /health/media,权限 health.media.list

  • "轮播图管理" 菜单项(路径 /health/banners,权限 health.banners.list

  • 放在健康模块菜单组下

  • Step 2: 创建首页推荐分类种子

插入一条 article_category 记录:

  • name: "首页推荐"

  • slug: "home-featured"

  • 硬编码 ID用于小程序配置

  • Step 3: 注册迁移 + 验证 + 提交

feat(db): 种子迁移 — 媒体库/轮播图菜单 + 首页推荐分类

Chunk 4: Frontend — API + MediaPicker + Pages

Task 15: 创建媒体库 API 客户端

Files:

  • Create: apps/web/src/api/health/media.ts

  • Step 1: 创建 API 文件

遵循现有 articles.ts 模式:

import client from '../client';
import type { PaginatedResponse } from '../types';

export interface MediaItem {
  id: string;
  folder_id: string | null;
  folder_name: string | null;
  filename: string;
  url: string;
  thumbnail_url: string | null;
  content_type: string;
  file_size: number;
  width: number | null;
  height: number | null;
  alt_text: string | null;
  is_public: boolean;
  created_at: string;
  updated_at: string;
  version: number;
}

export interface MediaListParams {
  page?: number;
  page_size?: number;
  folder_id?: string;
  content_type?: string;
  keyword?: string;
  is_public?: boolean;
}

export interface FolderNode {
  id: string;
  name: string;
  parent_id: string | null;
  sort_order: number;
  children: FolderNode[];
  item_count: number;
}

export const mediaApi = {
  list: async (params: MediaListParams) => {
    const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MediaItem> }>('/health/media', { params });
    return data.data;
  },
  get: async (id: string) => {
    const { data } = await client.get<{ success: boolean; data: MediaItem }>(`/health/media/${id}`);
    return data.data;
  },
  upload: async (file: File, folderId?: string, isPublic?: boolean) => {
    const formData = new FormData();
    formData.append('file', file);
    if (folderId) formData.append('folder_id', folderId);
    if (isPublic !== undefined) formData.append('is_public', String(isPublic));
    const { data } = await client.post<{ success: boolean; data: MediaItem }>('/health/media/upload', formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
    });
    return data.data;
  },
  update: async (id: string, req: Partial<MediaItem> & { version: number }) => {
    const { data } = await client.put<{ success: boolean; data: MediaItem }>(`/health/media/${id}`, req);
    return data.data;
  },
  delete: async (id: string, version: number) => {
    const { data } = await client.delete<{ success: boolean; data: null }>(`/health/media/${id}`, { data: { version } });
    return data.data;
  },
  batchDelete: async (ids: string[]) => {
    const { data } = await client.post<{ success: boolean; data: null }>('/health/media/batch-delete', { ids });
    return data.data;
  },
  move: async (id: string, folderId: string | null, version: number) => {
    const { data } = await client.post<{ success: boolean; data: MediaItem }>(`/health/media/${id}/move`, { folder_id: folderId, version });
    return data.data;
  },
  crop: async (id: string, crop: { x: number; y: number; width: number; height: number }, version: number) => {
    const { data } = await client.post<{ success: boolean; data: MediaItem }>(`/health/media/${id}/crop`, { ...crop, version });
    return data.data;
  },
  listFolders: async () => {
    const { data } = await client.get<{ success: boolean; data: FolderNode[] }>('/health/media-folders');
    return data.data;
  },
  createFolder: async (name: string, parentId?: string) => {
    const { data } = await client.post<{ success: boolean; data: FolderNode }>('/health/media-folders', { name, parent_id: parentId || null });
    return data.data;
  },
  updateFolder: async (id: string, req: { name?: string; parent_id?: string | null; version: number }) => {
    const { data } = await client.put<{ success: boolean; data: FolderNode }>(`/health/media-folders/${id}`, req);
    return data.data;
  },
  deleteFolder: async (id: string, version: number) => {
    const { data } = await client.delete<{ success: boolean; data: null }>(`/health/media-folders/${id}`, { data: { version } });
    return data.data;
  },
};

Task 16: 创建轮播图 API 客户端

Files:

  • Create: apps/web/src/api/health/banners.ts

  • Step 1: 创建 API 文件

import client from '../client';

export interface Banner {
  id: string;
  media_item_id: string;
  image_url: string;
  thumbnail_url: string | null;
  title: string | null;
  subtitle: string | null;
  link_type: string | null;
  link_target: string | null;
  sort_order: number;
  status: string;
  start_time: string | null;
  end_time: string | null;
  media_deleted: boolean;
  created_at: string;
  updated_at: string;
  version: number;
}

export interface CreateBannerReq {
  media_item_id: string;
  title?: string;
  subtitle?: string;
  link_type?: string;
  link_target?: string;
  sort_order?: number;
  status?: string;
  start_time?: string;
  end_time?: string;
}

export const bannerApi = {
  list: async (status?: string) => {
    const { data } = await client.get<{ success: boolean; data: Banner[] }>('/health/banners', { params: { status } });
    return data.data;
  },
  create: async (req: CreateBannerReq) => {
    const { data } = await client.post<{ success: boolean; data: Banner }>('/health/banners', req);
    return data.data;
  },
  update: async (id: string, req: Partial<CreateBannerReq> & { version: number }) => {
    const { data } = await client.put<{ success: boolean; data: Banner }>(`/health/banners/${id}`, req);
    return data.data;
  },
  delete: async (id: string, version: number) => {
    const { data } = await client.delete<{ success: boolean; data: null }>(`/health/banners/${id}`, { data: { version } });
    return data.data;
  },
  sort: async (items: { id: string; sort_order: number }[]) => {
    const { data } = await client.put<{ success: boolean; data: null }>('/health/banners/sort', { items });
    return data.data;
  },
};
  • Step 2: 提交
feat(web): 新增媒体库和轮播图 API 客户端

Task 17: 创建 MediaPicker 组件

Files:

  • Create: apps/web/src/components/health/MediaPicker/index.tsx

  • Step 1: 创建组件

核心功能:

  • Modal 弹窗,打开时加载媒体库列表
  • 文件夹下拉选择 + 搜索框 + 上传按钮
  • 网格缩略图展示,单选/多选模式
  • 点击图片卡片高亮选中,底部"确认"按钮触发 onSelect
  • 支持弹窗内直接上传新图片
  • Propsvisible, onSelect, onCancel, multiple?, accept?

遵循现有组件模式(参照 AuthButton、DrawerForm使用 Ant Design Modal + Grid 布局。

  • Step 2: 提交
feat(web): 新增 MediaPicker 媒体选择器组件

Task 18: 创建媒体库管理页面

Files:

  • Create: apps/web/src/pages/health/MediaLibrary.tsx

  • Step 1: 创建页面

布局PageContainer 包裹,内部水平分割:

左侧栏200px

  • 标题"文件夹" + 新建按钮
  • 树形列表antd Tree每项显示名称 + 资源数量
  • "全部资源"为默认选中

右侧主区域:

  • 工具栏:上传按钮 + 搜索 Input + 类型 Select + 公开状态 Select
  • 网格视图Ant Design Card 4 列展示缩略图卡片
    • 每张卡片:缩略图 + 公开 badge + hover 操作(复制 URL/编辑/删除)
    • 点击卡片打开预览 Modal
  • 分页Ant Design Pagination

使用 usePaginatedData hook 获取数据,AuthButton 包裹操作按钮。

  • Step 2: 添加路由

App.tsx 添加:

const MediaLibrary = lazy(() => import('./pages/health/MediaLibrary'));
// <Route path="/health/media" element={<MediaLibrary />} />

routeConfig.ts 添加权限:

{ path: "/health/media", permissions: ["health.media.list"] },
  • Step 3: 验证 + 提交
feat(web): 新增媒体库管理页面 — 左树+右网格布局

Task 19: 创建轮播图管理页面

Files:

  • Create: apps/web/src/pages/health/BannerManage.tsx

  • Step 1: 创建页面

布局PageContainer + Table + Drawer

  • 标题栏 + "新增轮播图"按钮AuthButton 包裹)

  • Ant Design Table排序/缩略图/标题/副标题/跳转/状态/时间/操作

  • 拖拽排序:@dnd-kit/core + @dnd-kit/sortable

  • Drawer 表单:

    • 图片选择:点击区域 → 打开 MediaPicker
    • 标题/副标题输入
    • 跳转设置Select(类型) + Input(目标)
    • DateRangePicker生效时间
    • 数字输入(排序值)
    • Switch启用/禁用)
  • Step 2: 添加路由和权限

  • Step 3: 验证 + 提交

feat(web): 新增轮播图管理页面 — 表格+拖拽排序+Drawer表单

Task 20: 改造文章编辑器

Files:

  • Modify: apps/web/src/pages/health/ArticleEditor.tsx

  • Step 1: 封面图改用 MediaPicker

将现有封面图上传组件替换为 MediaPicker

  • 点击封面图区域 → 打开 MediaPicker

  • 选中后回填 cover_image URL

  • Step 2: 富文本插图改造

改造 Wangeditor 的 MENU_CONF.uploadImage

  • customUpload 调用 mediaApi.upload(上传到媒体库)

  • 插入 <img> 时 URL 附加 ?token=xxx

  • 新增"从媒体库选择"按钮(通过 Wangeditor 的 insertMenu 或 panel

  • Step 3: 验证 + 提交

refactor(web): 文章编辑器封面图和插图改用 MediaPicker

Chunk 5: Miniprogram Changes

Task 21: 小程序 API 服务改造

Files:

  • Modify: apps/miniprogram/src/services/article.ts

  • Modify: apps/miniprogram/src/services/request.ts

  • Step 1: 新增公开 API 调用

article.ts 添加:

export interface PublicBanner {
  id: string;
  title: string | null;
  subtitle: string | null;
  image_url: string;
  link_type: string | null;
  link_target: string | null;
}

export async function getPublicBanners(): Promise<PublicBanner[]> {
  const { data } = await publicClient.get('/public/banners');
  return data;
}

export async function getPublicArticles(categoryId: string, pageSize = 4) {
  const { data } = await publicClient.get('/public/articles', {
    params: { category_id: categoryId, page_size: pageSize },
  });
  return data;
}
  • Step 2: request.ts 增加 publicClient

添加一个不附加 Authorization header 的 client 实例:

const publicClient = axios.create({
  baseURL: process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1',
  headers: { 'X-Tenant-Id': process.env.TARO_APP_TENANT_ID || '' },
});
  • Step 3: 提交
feat(miniprogram): 新增公开 API 调用 — 轮播图和文章

Task 22: 改造访客首页

Files:

  • Modify: apps/miniprogram/src/pages/index/index.tsx

  • Modify: apps/miniprogram/src/pages/index/index.scss

  • Step 1: 替换硬编码轮播图

  1. 删除 CAROUSEL_SLIDES 常量
  2. 新增 useState<PublicBanner[]>([])useEffect 调用 getPublicBanners()
  3. <SwiperItem> 从 banners 数据渲染:
    • 使用 <Image src={banner.image_url} mode="aspectFill" />
    • 叠加标题/副标题 overlay
  4. 加载失败不显示轮播图try/catch + 空数组降级)
  • Step 2: 替换"核心功能"区域
  1. 删除 3 张硬编码卡片
  2. 新增 useEffect 调用 getPublicArticles(HOME_CATEGORY_ID)
  3. 前 2 篇:两列网格(封面图 + 标题 + 摘要)
  4. 后 2 篇:横向列表(左图右文)
  5. 区域标题改为"健康资讯"
  6. 底部"查看更多"链接
  7. 空数据时显示空状态占位
  • Step 3: 添加新样式

index.scss 中添加 .mp-article-grid.mp-article-card 等样式(参照 HTML 可视化方案)。

  • Step 4: 验证 + 提交
feat(miniprogram): 访客首页轮播图和文章列表接入 API