# 媒体库与轮播图管理 — 实施计划 > **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]` 中添加: ```toml image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } ``` - [ ] **Step 2: 在 erp-server 添加 hmac + sha2** 在 `crates/erp-server/Cargo.toml` 的 `[dependencies]` 中添加: ```toml 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`: ```rust 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, pub sort_order: i32, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(skip_serializing_if = "Option::is_none")] pub created_by: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub updated_by: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub deleted_at: Option, 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 for Entity { fn to() -> RelationDef { Relation::Parent.def() } } impl Related for Entity { fn to() -> RelationDef { Relation::MediaItems.def() } } impl ActiveModelBehavior for ActiveModel {} ``` - [ ] **Step 2: 在 mod.rs 导出** 在 `crates/erp-health/src/entity/mod.rs` 添加: ```rust 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`: ```rust 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, pub filename: String, pub storage_path: String, #[sea_orm(skip_serializing_if = "Option::is_none")] pub thumbnail_path: Option, pub content_type: String, pub file_size: i64, #[sea_orm(skip_serializing_if = "Option::is_none")] pub width: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub height: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub alt_text: Option, pub is_public: bool, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(skip_serializing_if = "Option::is_none")] pub created_by: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub updated_by: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub deleted_at: Option, 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 for Entity { fn to() -> RelationDef { Relation::Folder.def() } } impl Related 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`: ```rust 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, #[sea_orm(skip_serializing_if = "Option::is_none")] pub subtitle: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub link_type: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub link_target: Option, pub sort_order: i32, pub status: String, #[sea_orm(skip_serializing_if = "Option::is_none")] pub start_time: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub end_time: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(skip_serializing_if = "Option::is_none")] pub created_by: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub updated_by: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub deleted_at: Option, 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 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`: ```rust 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`: ```rust 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`: ```rust 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`: ```rust 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, pub folder_name: Option, pub filename: String, pub url: String, pub thumbnail_url: Option, pub content_type: String, pub file_size: i64, pub width: Option, pub height: Option, pub alt_text: Option, pub is_public: bool, pub created_at: DateTime, pub updated_at: DateTime, pub version: i32, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateMediaItemReq { #[serde(skip_serializing)] pub folder_id: Option, #[serde(skip_serializing)] pub is_public: bool, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateMediaItemReq { pub alt_text: Option, pub folder_id: Option, pub is_public: Option, 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, pub page_size: Option, pub folder_id: Option, pub content_type: Option, pub keyword: Option, pub is_public: Option, pub sort_by: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct MoveMediaReq { pub folder_id: Option, pub version: i32, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct BatchDeleteReq { pub ids: Vec, } #[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, pub sort_order: i32, pub children: Vec, pub item_count: i64, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateFolderReq { pub name: String, pub parent_id: Option, } 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, pub parent_id: Option>, pub sort_order: Option, 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`: ```rust 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, pub title: Option, pub subtitle: Option, pub link_type: Option, pub link_target: Option, pub sort_order: i32, pub status: String, pub start_time: Option>, pub end_time: Option>, pub media_deleted: bool, pub created_at: DateTime, pub updated_at: DateTime, pub version: i32, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateBannerReq { pub media_item_id: Uuid, pub title: Option, pub subtitle: Option, pub link_type: Option, pub link_target: Option, #[serde(default)] pub sort_order: i32, #[serde(default = "default_active")] pub status: String, pub start_time: Option>, pub end_time: Option>, } 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, pub title: Option, pub subtitle: Option, pub link_type: Option, pub link_target: Option, pub sort_order: Option, pub status: Option, pub start_time: Option>>, pub end_time: Option>>, 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, } #[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, pub subtitle: Option, pub image_url: String, pub link_type: Option, pub link_target: Option, } ``` - [ ] **Step 2: 在 dto/mod.rs 导出** 在 `crates/erp-health/src/dto/mod.rs` 添加: ```rust 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: ```rust 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. **软删除级联**: ```rust // 在 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 实现:** ```rust use hmac::{Hmac, Mac}; use sha2::Sha256; type HmacSha256 = Hmac; 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) } ``` **公开列表查询**: ```sql 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 遵循现有模式:** - 泛型 `` + `where HealthState: FromRef, 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() 中添加路由** ```rust // 媒体库 .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() 中添加权限码** ```rust 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() 中添加公开端点** ```rust .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.rs` 的 `StorageConfig` 结构体中添加: ```rust pub secret_key: String, ``` 在 `default.toml` 的 `[storage]` 中添加: ```toml 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` 模式: ```typescript 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 }>('/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 & { 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 文件** ```typescript 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 & { 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` - 支持弹窗内直接上传新图片 - Props:`visible`, `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` 添加: ```tsx const MediaLibrary = lazy(() => import('./pages/health/MediaLibrary')); // } /> ``` 在 `routeConfig.ts` 添加权限: ```tsx { 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`(上传到媒体库) - 插入 `` 时 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` 添加: ```typescript 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 { 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 实例: ```typescript 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([])` 和 `useEffect` 调用 `getPublicBanners()` 3. `` 从 banners 数据渲染: - 使用 `` - 叠加标题/副标题 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 ```