diff --git a/docs/superpowers/plans/2026-05-10-media-library-banner-plan.md b/docs/superpowers/plans/2026-05-10-media-library-banner-plan.md new file mode 100644 index 0000000..44fb36b --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-media-library-banner-plan.md @@ -0,0 +1,1695 @@ +# 媒体库与轮播图管理 — 实施计划 + +> **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 +```