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

1696 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 媒体库与轮播图管理 — 实施计划
> **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<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` 添加:
```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<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`
```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<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`
```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<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`
```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<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` 添加:
```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<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)
}
```
**公开列表查询**
```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 遵循现有模式:**
- 泛型 `<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() 中添加路由**
```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<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 文件**
```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<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`
- 支持弹窗内直接上传新图片
- 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'));
// <Route path="/health/media" element={<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`(上传到媒体库)
- 插入 `<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` 添加:
```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<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 实例:
```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<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
```