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)
52 KiB
媒体库与轮播图管理 — 实施计划
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 为 HMS 健康管理平台新增媒体库、轮播图管理能力,并改造小程序访客首页。
Architecture: 在 erp-health 模块内新增 media_folder/media_item/banner 三个实体,复用现有 SeaORM + Axum 分层模式(entity → dto → service → handler → module.rs 路由注册)。公开端点通过签名 URL 实现未认证访问。前端新增 MediaLibrary + BannerManage 页面,抽取 MediaPicker 组件复用于文章编辑器。
Tech Stack: Rust/Axum/SeaORM (后端), React 19/Ant Design 6 (Web 前端), Taro 4.2/React 18 (小程序)
Design Spec: docs/superpowers/specs/2026-05-10-media-library-banner-design.md
File Structure
Backend — 新增文件
| 文件 | 职责 |
|---|---|
crates/erp-health/src/entity/media_folder.rs |
文件夹实体 |
crates/erp-health/src/entity/media_item.rs |
媒体资源实体 |
crates/erp-health/src/entity/banner.rs |
轮播图实体 |
crates/erp-health/src/dto/media_dto.rs |
媒体库 DTO(请求/响应/列表参数) |
crates/erp-health/src/dto/banner_dto.rs |
轮播图 DTO |
crates/erp-health/src/service/media_service.rs |
媒体库业务逻辑(CRUD + 缩略图 + 裁剪) |
crates/erp-health/src/service/banner_service.rs |
轮播图业务逻辑(CRUD + 排序 + 定时) |
crates/erp-health/src/handler/media_handler.rs |
媒体库 HTTP 处理 |
crates/erp-health/src/handler/banner_handler.rs |
轮播图 HTTP 处理 |
crates/erp-server/migration/src/m20260510_000130_create_media_folder.rs |
建表迁移 |
crates/erp-server/migration/src/m20260510_000131_create_media_item.rs |
建表迁移 |
crates/erp-server/migration/src/m20260510_000132_create_banner.rs |
建表迁移 |
crates/erp-server/migration/src/m20260510_000133_seed_media_menus.rs |
菜单种子 |
crates/erp-server/migration/src/m20260510_000134_seed_home_category.rs |
首页推荐分类种子 |
Backend — 修改文件
| 文件 | 改动 |
|---|---|
crates/erp-health/Cargo.toml |
添加 image crate 依赖 |
crates/erp-health/src/entity/mod.rs |
导出 3 个新实体 |
crates/erp-health/src/dto/mod.rs |
导出 2 个新 DTO 模块 |
crates/erp-health/src/service/mod.rs |
导出 2 个新 service 模块 |
crates/erp-health/src/handler/mod.rs |
导出 2 个新 handler 模块 |
crates/erp-health/src/module.rs |
注册路由 + 权限码 + 公开路由 |
crates/erp-server/Cargo.toml |
添加 hmac + sha2 依赖 |
crates/erp-server/src/config.rs |
StorageConfig 增加 secret_key |
crates/erp-server/config/default.toml |
增加 secret_key 配置项 |
crates/erp-server/src/handlers/upload.rs |
签名 URL 验证逻辑 |
crates/erp-server/src/main.rs |
公开路由组注册 |
crates/erp-server/migration/src/lib.rs |
注册 5 个新迁移文件 |
Frontend — 新增文件
| 文件 | 职责 |
|---|---|
apps/web/src/api/health/media.ts |
媒体库 API 客户端 |
apps/web/src/api/health/banners.ts |
轮播图 API 客户端 |
apps/web/src/components/health/MediaPicker/index.tsx |
媒体选择器组件 |
apps/web/src/pages/health/MediaLibrary.tsx |
媒体库管理页面 |
apps/web/src/pages/health/BannerManage.tsx |
轮播图管理页面 |
Frontend — 修改文件
| 文件 | 改动 |
|---|---|
apps/web/src/App.tsx |
添加 2 个新路由 |
apps/web/src/routeConfig.ts |
添加权限配置 |
apps/web/src/pages/health/ArticleEditor.tsx |
封面图/插图改用 MediaPicker |
Miniprogram — 修改文件
| 文件 | 改动 |
|---|---|
apps/miniprogram/src/services/article.ts |
新增公开 API 调用 |
apps/miniprogram/src/services/request.ts |
/public/ 路径跳过认证 |
apps/miniprogram/src/pages/index/index.tsx |
轮播图 + 文章列表接入 API |
apps/miniprogram/src/pages/index/index.scss |
新增文章卡片样式 |
Chunk 1: Backend Entities + Migrations + DTOs
Task 1: 添加 Cargo 依赖
Files:
-
Modify:
crates/erp-health/Cargo.toml -
Modify:
crates/erp-server/Cargo.toml -
Step 1: 在 erp-health 添加 image crate
在 crates/erp-health/Cargo.toml 的 [dependencies] 中添加:
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
- Step 2: 在 erp-server 添加 hmac + sha2
在 crates/erp-server/Cargo.toml 的 [dependencies] 中添加:
hmac = "0.12"
sha2 = "0.10"
- Step 3: 验证编译
Run: cargo check -p erp-health -p erp-server
Expected: 编译成功
- Step 4: 提交
chore(health): 添加 image/hmac/sha2 依赖
Task 2: 创建 media_folder 实体
Files:
-
Create:
crates/erp-health/src/entity/media_folder.rs -
Modify:
crates/erp-health/src/entity/mod.rs -
Step 1: 创建实体文件
创建 crates/erp-health/src/entity/media_folder.rs:
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "media_folder")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Uuid>,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(belongs_to = "super::media_folder::Entity", from = "Column::ParentId", to = "Column::Id")]
Parent,
#[sea_orm(has_many = "super::media_folder::Entity")]
Children,
#[sea_orm(has_many = "super::media_item::Entity")]
MediaItems,
}
impl Related<super::media_folder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Parent.def()
}
}
impl Related<super::media_item::Entity> for Entity {
fn to() -> RelationDef {
Relation::MediaItems.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
- Step 2: 在 mod.rs 导出
在 crates/erp-health/src/entity/mod.rs 添加:
pub mod media_folder;
pub mod media_item;
pub mod banner;
- Step 3: 验证编译
Run: cargo check -p erp-health
Expected: 编译成功(media_item 和 banner 还未创建,暂时报错没关系,下一步创建)
Task 3: 创建 media_item 实体
Files:
-
Create:
crates/erp-health/src/entity/media_item.rs -
Step 1: 创建实体文件
创建 crates/erp-health/src/entity/media_item.rs:
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "media_item")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub folder_id: Option<Uuid>,
pub filename: String,
pub storage_path: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub thumbnail_path: Option<String>,
pub content_type: String,
pub file_size: i64,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub width: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub height: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub alt_text: Option<String>,
pub is_public: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(belongs_to = "super::media_folder::Entity", from = "Column::FolderId", to = "Column::Id")]
Folder,
#[sea_orm(has_many = "super::banner::Entity")]
Banners,
}
impl Related<super::media_folder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Folder.def()
}
}
impl Related<super::banner::Entity> for Entity {
fn to() -> RelationDef {
Relation::Banners.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
Task 4: 创建 banner 实体
Files:
-
Create:
crates/erp-health/src/entity/banner.rs -
Step 1: 创建实体文件
创建 crates/erp-health/src/entity/banner.rs:
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "banner")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub media_item_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub subtitle: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub link_type: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub link_target: Option<String>,
pub sort_order: i32,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub start_time: Option<DateTimeUtc>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub end_time: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(belongs_to = "super::media_item::Entity", from = "Column::MediaItemId", to = "Column::Id")]
MediaItem,
}
impl Related<super::media_item::Entity> for Entity {
fn to() -> RelationDef {
Relation::MediaItem.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
- Step 2: 验证编译
Run: cargo check -p erp-health
Expected: 编译成功
- Step 3: 提交
feat(health): 新增 media_folder/media_item/banner 实体
Task 5: 创建数据库迁移
Files:
-
Create:
crates/erp-server/migration/src/m20260510_000130_create_media_folder.rs -
Create:
crates/erp-server/migration/src/m20260510_000131_create_media_item.rs -
Create:
crates/erp-server/migration/src/m20260510_000132_create_banner.rs -
Modify:
crates/erp-server/migration/src/lib.rs -
Step 1: 查看最新迁移序号
Run: ls crates/erp-server/migration/src/m2026*.rs | tail -5
确认最新序号,确保 000130-000132 不冲突。如有冲突则递增。
- Step 2: 创建 media_folder 迁移
创建 crates/erp-server/migration/src/m20260510_000130_create_media_folder.rs:
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(MediaFolder::Table)
.col(ColumnDef::new(MediaFolder::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(MediaFolder::TenantId).uuid().not_null())
.col(ColumnDef::new(MediaFolder::Name).string_len(100).not_null())
.col(ColumnDef::new(MediaFolder::ParentId).uuid())
.col(ColumnDef::new(MediaFolder::SortOrder).integer().not_null().default(0))
.col(ColumnDef::new(MediaFolder::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(MediaFolder::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(MediaFolder::CreatedBy).uuid())
.col(ColumnDef::new(MediaFolder::UpdatedBy).uuid())
.col(ColumnDef::new(MediaFolder::DeletedAt).timestamp_with_time_zone())
.col(ColumnDef::new(MediaFolder::Version).integer().not_null().default(1))
.foreign_key(
ForeignKey::create()
.name("fk_media_folder_parent")
.from(MediaFolder::Table, MediaFolder::ParentId)
.to(MediaFolder::Table, MediaFolder::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_media_folder_tenant_parent")
.table(MediaFolder::Table)
.col(MediaFolder::TenantId)
.col(MediaFolder::ParentId)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(MediaFolder::Table).to_owned()).await
}
}
#[derive(DeriveIden)]
#[allow(clippy::enum_variant_names)]
enum MediaFolder {
Table,
Id,
TenantId,
Name,
ParentId,
SortOrder,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}
- Step 3: 创建 media_item 迁移
创建 crates/erp-server/migration/src/m20260510_000131_create_media_item.rs:
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(MediaItem::Table)
.col(ColumnDef::new(MediaItem::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(MediaItem::TenantId).uuid().not_null())
.col(ColumnDef::new(MediaItem::FolderId).uuid())
.col(ColumnDef::new(MediaItem::Filename).string_len(255).not_null())
.col(ColumnDef::new(MediaItem::StoragePath).string_len(500).not_null().unique_key())
.col(ColumnDef::new(MediaItem::ThumbnailPath).string_len(500))
.col(ColumnDef::new(MediaItem::ContentType).string_len(100).not_null())
.col(ColumnDef::new(MediaItem::FileSize).big_integer().not_null())
.col(ColumnDef::new(MediaItem::Width).integer())
.col(ColumnDef::new(MediaItem::Height).integer())
.col(ColumnDef::new(MediaItem::AltText).string_len(255))
.col(ColumnDef::new(MediaItem::IsPublic).boolean().not_null().default(false))
.col(ColumnDef::new(MediaItem::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(MediaItem::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(MediaItem::CreatedBy).uuid())
.col(ColumnDef::new(MediaItem::UpdatedBy).uuid())
.col(ColumnDef::new(MediaItem::DeletedAt).timestamp_with_time_zone())
.col(ColumnDef::new(MediaItem::Version).integer().not_null().default(1))
.foreign_key(
ForeignKey::create()
.name("fk_media_item_folder")
.from(MediaItem::Table, MediaItem::FolderId)
.to(MediaFolder::Table, MediaFolder::Id)
.on_delete(ForeignKeyAction::SetNull),
)
.to_owned(),
)
.await?;
manager
.create_index(Index::create().name("idx_media_item_tenant_folder").table(MediaItem::Table).col(MediaItem::TenantId).col(MediaItem::FolderId).to_owned())
.await?;
manager
.create_index(Index::create().name("idx_media_item_tenant_public").table(MediaItem::Table).col(MediaItem::TenantId).col(MediaItem::IsPublic).to_owned())
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(MediaItem::Table).to_owned()).await
}
}
#[derive(DeriveIden)]
#[allow(clippy::enum_variant_names)]
enum MediaItem {
Table,
Id,
TenantId,
FolderId,
Filename,
StoragePath,
ThumbnailPath,
ContentType,
FileSize,
Width,
Height,
AltText,
IsPublic,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}
#[derive(DeriveIden)]
enum MediaFolder {
Table,
Id,
}
- Step 4: 创建 banner 迁移
创建 crates/erp-server/migration/src/m20260510_000132_create_banner.rs:
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Banner::Table)
.col(ColumnDef::new(Banner::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(Banner::TenantId).uuid().not_null())
.col(ColumnDef::new(Banner::MediaItemId).uuid().not_null())
.col(ColumnDef::new(Banner::Title).string_len(100))
.col(ColumnDef::new(Banner::Subtitle).string_len(255))
.col(ColumnDef::new(Banner::LinkType).string_len(20))
.col(ColumnDef::new(Banner::LinkTarget).string_len(500))
.col(ColumnDef::new(Banner::SortOrder).integer().not_null().default(0))
.col(ColumnDef::new(Banner::Status).string_len(20).not_null().default("active"))
.col(ColumnDef::new(Banner::StartTime).timestamp_with_time_zone())
.col(ColumnDef::new(Banner::EndTime).timestamp_with_time_zone())
.col(ColumnDef::new(Banner::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Banner::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Banner::CreatedBy).uuid())
.col(ColumnDef::new(Banner::UpdatedBy).uuid())
.col(ColumnDef::new(Banner::DeletedAt).timestamp_with_time_zone())
.col(ColumnDef::new(Banner::Version).integer().not_null().default(1))
.foreign_key(
ForeignKey::create()
.name("fk_banner_media_item")
.from(Banner::Table, Banner::MediaItemId)
.to(MediaItem::Table, MediaItem::Id)
.on_delete(ForeignKeyAction::Restrict),
)
.to_owned(),
)
.await?;
manager
.create_index(Index::create().name("idx_banner_tenant_status").table(Banner::Table).col(Banner::TenantId).col(Banner::Status).to_owned())
.await?;
manager
.create_index(Index::create().name("idx_banner_sort").table(Banner::Table).col(Banner::TenantId).col(Banner::SortOrder).to_owned())
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Banner::Table).to_owned()).await
}
}
#[derive(DeriveIden)]
#[allow(clippy::enum_variant_names)]
enum Banner {
Table,
Id,
TenantId,
MediaItemId,
Title,
Subtitle,
LinkType,
LinkTarget,
SortOrder,
Status,
StartTime,
EndTime,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}
#[derive(DeriveIden)]
enum MediaItem {
Table,
Id,
}
- Step 5: 在 lib.rs 注册迁移文件
在 crates/erp-server/migration/src/lib.rs 中添加 mod 声明和迁移列表条目(参照现有格式)。
- Step 6: 验证编译
Run: cargo check -p erp-server
Expected: 编译成功
- Step 7: 提交
feat(db): 新增 media_folder/media_item/banner 迁移
Task 6: 创建媒体库 DTO
Files:
-
Create:
crates/erp-health/src/dto/media_dto.rs -
Modify:
crates/erp-health/src/dto/mod.rs -
Step 1: 创建 DTO 文件
创建 crates/erp-health/src/dto/media_dto.rs:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::sanitize::{sanitize_option, sanitize_string};
// ===== 媒体资源 =====
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MediaItemResp {
pub id: Uuid,
pub folder_id: Option<Uuid>,
pub folder_name: Option<String>,
pub filename: String,
pub url: String,
pub thumbnail_url: Option<String>,
pub content_type: String,
pub file_size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub alt_text: Option<String>,
pub is_public: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateMediaItemReq {
#[serde(skip_serializing)]
pub folder_id: Option<Uuid>,
#[serde(skip_serializing)]
pub is_public: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateMediaItemReq {
pub alt_text: Option<String>,
pub folder_id: Option<Uuid>,
pub is_public: Option<bool>,
pub version: i32,
}
impl UpdateMediaItemReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.alt_text {
*v = sanitize_string(v);
}
}
}
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct MediaListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub folder_id: Option<Uuid>,
pub content_type: Option<String>,
pub keyword: Option<String>,
pub is_public: Option<bool>,
pub sort_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MoveMediaReq {
pub folder_id: Option<Uuid>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchDeleteReq {
pub ids: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CropReq {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub version: i32,
}
// ===== 文件夹 =====
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FolderResp {
pub id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub sort_order: i32,
pub children: Vec<FolderResp>,
pub item_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateFolderReq {
pub name: String,
pub parent_id: Option<Uuid>,
}
impl CreateFolderReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateFolderReq {
pub name: Option<String>,
pub parent_id: Option<Option<Uuid>>,
pub sort_order: Option<i32>,
pub version: i32,
}
impl UpdateFolderReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.name {
*v = sanitize_string(v);
}
}
}
Task 7: 创建轮播图 DTO
Files:
-
Create:
crates/erp-health/src/dto/banner_dto.rs -
Step 1: 创建 DTO 文件
创建 crates/erp-health/src/dto/banner_dto.rs:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use erp_core::sanitize::{sanitize_option, sanitize_string};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BannerResp {
pub id: Uuid,
pub media_item_id: Uuid,
pub image_url: String,
pub thumbnail_url: Option<String>,
pub title: Option<String>,
pub subtitle: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
pub sort_order: i32,
pub status: String,
pub start_time: Option<DateTime<Utc>>,
pub end_time: Option<DateTime<Utc>>,
pub media_deleted: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateBannerReq {
pub media_item_id: Uuid,
pub title: Option<String>,
pub subtitle: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
#[serde(default)]
pub sort_order: i32,
#[serde(default = "default_active")]
pub status: String,
pub start_time: Option<DateTime<Utc>>,
pub end_time: Option<DateTime<Utc>>,
}
fn default_active() -> String {
"active".to_string()
}
impl CreateBannerReq {
pub fn sanitize(&mut self) {
self.title = sanitize_option(self.title.take());
self.subtitle = sanitize_option(self.subtitle.take());
if let Some(ref mut v) = self.link_target {
*v = sanitize_string(v);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateBannerReq {
pub media_item_id: Option<Uuid>,
pub title: Option<String>,
pub subtitle: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
pub sort_order: Option<i32>,
pub status: Option<String>,
pub start_time: Option<Option<DateTime<Utc>>>,
pub end_time: Option<Option<DateTime<Utc>>>,
pub version: i32,
}
impl UpdateBannerReq {
pub fn sanitize(&mut self) {
self.title = sanitize_option(self.title.take());
self.subtitle = sanitize_option(self.subtitle.take());
if let Some(ref mut v) = self.link_target {
*v = sanitize_string(v);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SortBannerReq {
pub items: Vec<SortItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SortItem {
pub id: Uuid,
pub sort_order: i32,
}
// 公开端点响应(不含内部字段)
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PublicBannerResp {
pub id: Uuid,
pub title: Option<String>,
pub subtitle: Option<String>,
pub image_url: String,
pub link_type: Option<String>,
pub link_target: Option<String>,
}
- Step 2: 在 dto/mod.rs 导出
在 crates/erp-health/src/dto/mod.rs 添加:
pub mod media_dto;
pub mod banner_dto;
- Step 3: 验证编译
Run: cargo check -p erp-health
Expected: 编译成功
- Step 4: 提交
feat(health): 新增媒体库和轮播图 DTO
Chunk 2: Backend Services
Task 8: 创建 media_service — 基础 CRUD
Files:
-
Create:
crates/erp-health/src/service/media_service.rs -
Modify:
crates/erp-health/src/service/mod.rs -
Step 1: 创建 service 文件
创建 crates/erp-health/src/service/media_service.rs,实现以下函数:
关键函数清单:
| 函数 | 说明 |
|---|---|
list_media_items(state, tenant_id, params) |
分页列表(folder_id/keyword/content_type/is_public 筛选,sort_by 排序) |
get_media_item(state, tenant_id, id) |
获取单个资源详情 |
upload_media(state, tenant_id, operator_id, file_data, folder_id, is_public, upload_dir) |
保存文件 + 生成缩略图 + 创建 DB 记录 |
update_media_item(state, tenant_id, id, operator_id, req) |
更新元数据(乐观锁) |
delete_media_item(state, tenant_id, id, operator_id, version) |
软删除 + 级联 banner inactive + 删除文件 |
batch_delete(state, tenant_id, operator_id, ids) |
批量软删除(事务) |
move_media(state, tenant_id, id, operator_id, req) |
移动文件夹 |
crop_media(state, tenant_id, id, operator_id, req) |
裁剪图片 + 重新生成缩略图 |
list_folders(state, tenant_id) |
树形文件夹列表(含 item_count) |
create_folder(state, tenant_id, operator_id, req) |
创建文件夹 |
update_folder(state, tenant_id, id, operator_id, req) |
重命名/移动 |
delete_folder(state, tenant_id, id, operator_id, version) |
删除空文件夹 |
generate_thumbnail(src_path, thumb_path, max_size) |
缩略图生成(200×200 居中裁剪) |
crop_image(src_path, x, y, w, h) |
裁剪图片 |
核心实现要点:
-
缩略图生成 使用
imagecrate: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(()) } -
上传流程:
- 保存原文件到
{upload_dir}/{tenant_id}/{uuid}.ext - 调用
generate_thumbnail生成thumb_{uuid}.ext - 用
image::open读取 width/height - 创建
media_item::ActiveModel并 insert
- 保存原文件到
-
软删除级联:
// 在 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?; -
文件夹树构建:一次查询所有文件夹 → 递归构建树 → 每个节点附带
item_count(用 GROUP BY 一次查询)
- Step 2: 在 service/mod.rs 导出
添加:pub mod media_service;
- Step 3: 验证编译
Run: cargo check -p erp-health
Expected: 编译成功
- Step 4: 提交
feat(health): 实现媒体库 service — CRUD + 缩略图 + 裁剪
Task 9: 创建 banner_service
Files:
-
Create:
crates/erp-health/src/service/banner_service.rs -
Modify:
crates/erp-health/src/service/mod.rs -
Step 1: 创建 service 文件
创建 crates/erp-health/src/service/banner_service.rs,实现:
| 函数 | 说明 |
|---|---|
list_banners(state, tenant_id, status) |
管理列表(含 media_deleted 标记) |
create_banner(state, tenant_id, operator_id, req) |
创建(验证 media_item 存在且未删除) |
update_banner(state, tenant_id, id, operator_id, req) |
更新(乐观锁) |
delete_banner(state, tenant_id, id, operator_id, version) |
软删除 |
sort_banners(state, tenant_id, items) |
批量更新排序 |
list_public_banners(state, tenant_id, base_url) |
公开列表(active + 时间范围 + 签名 URL) |
generate_signed_url(path, secret_key, ttl_secs) |
生成签名 URL |
verify_signed_url(path, token, expires, secret_key) |
验证签名 URL |
签名 URL 实现:
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub fn generate_signed_url(path: &str, secret_key: &str, ttl_secs: u64) -> (String, i64) {
let expires = chrono::Utc::now().timestamp() + ttl_secs as i64;
let message = format!("{}\n{}", path, expires);
let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())
.expect("HMAC key length valid");
mac.update(message.as_bytes());
let token = hex::encode(mac.finalize().into_bytes());
(token, expires)
}
公开列表查询:
WHERE tenant_id = ? AND status = 'active' AND deleted_at IS NULL
AND (start_time IS NULL OR start_time <= NOW())
AND (end_time IS NULL OR end_time >= NOW())
ORDER BY sort_order ASC
- Step 2: 在 service/mod.rs 导出
添加:pub mod banner_service;
- Step 3: 验证编译
Run: cargo check -p erp-health
Expected: 编译成功
- Step 4: 提交
feat(health): 实现轮播图 service — CRUD + 排序 + 签名 URL
Chunk 3: Backend Handlers + Routes + Public Endpoints
Task 10: 创建 media_handler
Files:
-
Create:
crates/erp-health/src/handler/media_handler.rs -
Modify:
crates/erp-health/src/handler/mod.rs -
Step 1: 创建 handler 文件
创建 crates/erp-health/src/handler/media_handler.rs,实现以下函数:
| 函数 | 路由 | 权限 | 说明 |
|---|---|---|---|
list_media |
GET /health/media | health.media.list | Query(MediaListParams) → 分页列表 |
upload_media |
POST /health/media/upload | health.media.manage | Multipart(file + folder_id + is_public) |
get_media |
GET /health/media/{id} | health.media.list | Path(id) → 详情 |
update_media |
PUT /health/media/{id} | health.media.manage | Path(id) + Json(UpdateMediaItemReq) |
delete_media |
DELETE /health/media/{id} | health.media.manage | Path(id) + Json(DeleteWithVersion) |
move_media |
POST /health/media/{id}/move | health.media.manage | Path(id) + Json(MoveMediaReq) |
batch_delete |
POST /health/media/batch-delete | health.media.manage | Json(BatchDeleteReq) |
crop_media |
POST /health/media/{id}/crop | health.media.manage | Path(id) + Json(CropReq) |
list_folders |
GET /health/media-folders | health.media.list | → 文件夹树 |
create_folder |
POST /health/media-folders | health.media.manage | Json(CreateFolderReq) |
update_folder |
PUT /health/media-folders/{id} | health.media.manage | Path(id) + Json(UpdateFolderReq) |
delete_folder |
DELETE /health/media-folders/{id} | health.media.manage | Path(id) + Json(DeleteWithVersion) |
所有 handler 遵循现有模式:
- 泛型
<S>+where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static require_permission(&ctx, "...")?;在最前面req.sanitize();在权限检查后Json(ApiResponse::ok(result))包装返回
upload_media 特殊处理:
-
使用
axum::extract::Multipart接收文件 -
从
Extension(ctx)获取 tenant_id -
从
State获取 upload_dir 配置 -
调用
media_service::upload_media -
Step 2: 在 handler/mod.rs 导出
添加:pub mod media_handler;
- Step 3: 验证编译
Run: cargo check -p erp-health
Expected: 编译成功
- Step 4: 提交
feat(health): 实现媒体库 handler — 12 个端点
Task 11: 创建 banner_handler
Files:
-
Create:
crates/erp-health/src/handler/banner_handler.rs -
Modify:
crates/erp-health/src/handler/mod.rs -
Step 1: 创建 handler 文件
| 函数 | 路由 | 权限 | 说明 |
|---|---|---|---|
list_banners |
GET /health/banners | health.banners.list | Query(status) → 列表 |
create_banner |
POST /health/banners | health.banners.manage | Json(CreateBannerReq) |
update_banner |
PUT /health/banners/{id} | health.banners.manage | Path(id) + Json(UpdateBannerReq) |
delete_banner |
DELETE /health/banners/{id} | health.banners.manage | Path(id) + Json(DeleteWithVersion) |
sort_banners |
PUT /health/banners/sort | health.banners.manage | Json(SortBannerReq) |
- Step 2: 在 handler/mod.rs 导出
添加:pub mod banner_handler;
- Step 3: 验证编译 + 提交
feat(health): 实现轮播图 handler — 5 个端点
Task 12: 注册路由和权限码
Files:
-
Modify:
crates/erp-health/src/module.rs -
Step 1: 在 authenticated_routes() 中添加路由
// 媒体库
.route(
"/health/media",
axum::routing::get(media_handler::list_media),
)
.route(
"/health/media/upload",
axum::routing::post(media_handler::upload_media),
)
.route(
"/health/media/batch-delete",
axum::routing::post(media_handler::batch_delete),
)
.route(
"/health/media/{id}",
axum::routing::get(media_handler::get_media)
.put(media_handler::update_media)
.delete(media_handler::delete_media),
)
.route(
"/health/media/{id}/move",
axum::routing::post(media_handler::move_media),
)
.route(
"/health/media/{id}/crop",
axum::routing::post(media_handler::crop_media),
)
// 媒体文件夹
.route(
"/health/media-folders",
axum::routing::get(media_handler::list_folders)
.post(media_handler::create_folder),
)
.route(
"/health/media-folders/{id}",
axum::routing::put(media_handler::update_folder)
.delete(media_handler::delete_folder),
)
// 轮播图
.route(
"/health/banners",
axum::routing::get(banner_handler::list_banners)
.post(banner_handler::create_banner),
)
.route(
"/health/banners/sort",
axum::routing::put(banner_handler::sort_banners),
)
.route(
"/health/banners/{id}",
axum::routing::put(banner_handler::update_banner)
.delete(banner_handler::delete_banner),
)
- Step 2: 在 permissions() 中添加权限码
PermissionDescriptor {
code: "health.media.list".into(),
name: "查看媒体库".into(),
description: "查看和管理媒体资源列表".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.media.manage".into(),
name: "管理媒体库".into(),
description: "上传、编辑、删除、裁剪媒体资源".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.banners.list".into(),
name: "查看轮播图".into(),
description: "查看轮播图管理列表".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.banners.manage".into(),
name: "管理轮播图".into(),
description: "创建、编辑、删除、排序轮播图".into(),
module: "health".into(),
},
- Step 3: 在 public_routes() 中添加公开端点
.route(
"/public/banners",
axum::routing::get(banner_handler::list_public_banners),
)
.route(
"/public/articles",
axum::routing::get(media_handler::list_public_articles),
)
注意:公开端点需要在 handler 中从 Query/Header 提取 tenant_id(而非从 JWT)。
- Step 4: 验证编译 + 提交
feat(health): 注册媒体库和轮播图路由 + 权限码 + 公开端点
Task 13: 配置 secret_key + 签名 URL 中间件
Files:
-
Modify:
crates/erp-server/src/config.rs -
Modify:
crates/erp-server/config/default.toml -
Modify:
crates/erp-server/src/handlers/upload.rs -
Modify:
crates/erp-server/src/main.rs -
Step 1: StorageConfig 添加 secret_key
在 config.rs 的 StorageConfig 结构体中添加:
pub secret_key: String,
在 default.toml 的 [storage] 中添加:
secret_key = "change-me-in-production"
- Step 2: 修改 upload_auth_middleware
在 upload.rs 的认证中间件中增加签名 URL 验证分支:
- 检查请求是否包含
?token=&expires=参数 - 如果有:
- 规范化请求路径(
std::path::Path::canonicalize或手动去除..) - 验证路径以
/uploads/{tenant}/为前缀 - 用 HMAC-SHA256 验证签名
- 检查 expires 未过期
- 查询 DB 确认 media_item.is_public = true
- 规范化请求路径(
- 如果没有,走原有 JWT 逻辑
- Step 3: 在 main.rs 注册公开路由
在 main.rs 中将 HealthModule::public_routes() 的路由注册到不挂 JWT 中间件的路由组。
- Step 4: 启动时密钥检查
在 server 启动逻辑中添加:如果 secret_key 是默认值且是生产环境,panic!。
- Step 5: 验证编译 + 启动测试
Run: cargo check -p erp-server && cargo run -p erp-server
Expected: 编译成功,服务正常启动,迁移自动执行
- Step 6: 提交
feat(server): 签名 URL 验证 + secret_key 配置 + 公开路由注册
Task 14: 种子迁移 — 菜单 + 首页推荐分类
Files:
-
Create:
crates/erp-server/migration/src/m20260510_000133_seed_media_menus.rs -
Create:
crates/erp-server/migration/src/m20260510_000134_seed_home_category.rs -
Modify:
crates/erp-server/migration/src/lib.rs -
Step 1: 创建菜单种子迁移
参照现有 m20260426_000059_seed_menus.rs 格式,插入:
-
"媒体库" 菜单项(路径
/health/media,权限health.media.list) -
"轮播图管理" 菜单项(路径
/health/banners,权限health.banners.list) -
放在健康模块菜单组下
-
Step 2: 创建首页推荐分类种子
插入一条 article_category 记录:
-
name: "首页推荐"
-
slug: "home-featured"
-
硬编码 ID(用于小程序配置)
-
Step 3: 注册迁移 + 验证 + 提交
feat(db): 种子迁移 — 媒体库/轮播图菜单 + 首页推荐分类
Chunk 4: Frontend — API + MediaPicker + Pages
Task 15: 创建媒体库 API 客户端
Files:
-
Create:
apps/web/src/api/health/media.ts -
Step 1: 创建 API 文件
遵循现有 articles.ts 模式:
import client from '../client';
import type { PaginatedResponse } from '../types';
export interface MediaItem {
id: string;
folder_id: string | null;
folder_name: string | null;
filename: string;
url: string;
thumbnail_url: string | null;
content_type: string;
file_size: number;
width: number | null;
height: number | null;
alt_text: string | null;
is_public: boolean;
created_at: string;
updated_at: string;
version: number;
}
export interface MediaListParams {
page?: number;
page_size?: number;
folder_id?: string;
content_type?: string;
keyword?: string;
is_public?: boolean;
}
export interface FolderNode {
id: string;
name: string;
parent_id: string | null;
sort_order: number;
children: FolderNode[];
item_count: number;
}
export const mediaApi = {
list: async (params: MediaListParams) => {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MediaItem> }>('/health/media', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{ success: boolean; data: MediaItem }>(`/health/media/${id}`);
return data.data;
},
upload: async (file: File, folderId?: string, isPublic?: boolean) => {
const formData = new FormData();
formData.append('file', file);
if (folderId) formData.append('folder_id', folderId);
if (isPublic !== undefined) formData.append('is_public', String(isPublic));
const { data } = await client.post<{ success: boolean; data: MediaItem }>('/health/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data;
},
update: async (id: string, req: Partial<MediaItem> & { version: number }) => {
const { data } = await client.put<{ success: boolean; data: MediaItem }>(`/health/media/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
const { data } = await client.delete<{ success: boolean; data: null }>(`/health/media/${id}`, { data: { version } });
return data.data;
},
batchDelete: async (ids: string[]) => {
const { data } = await client.post<{ success: boolean; data: null }>('/health/media/batch-delete', { ids });
return data.data;
},
move: async (id: string, folderId: string | null, version: number) => {
const { data } = await client.post<{ success: boolean; data: MediaItem }>(`/health/media/${id}/move`, { folder_id: folderId, version });
return data.data;
},
crop: async (id: string, crop: { x: number; y: number; width: number; height: number }, version: number) => {
const { data } = await client.post<{ success: boolean; data: MediaItem }>(`/health/media/${id}/crop`, { ...crop, version });
return data.data;
},
listFolders: async () => {
const { data } = await client.get<{ success: boolean; data: FolderNode[] }>('/health/media-folders');
return data.data;
},
createFolder: async (name: string, parentId?: string) => {
const { data } = await client.post<{ success: boolean; data: FolderNode }>('/health/media-folders', { name, parent_id: parentId || null });
return data.data;
},
updateFolder: async (id: string, req: { name?: string; parent_id?: string | null; version: number }) => {
const { data } = await client.put<{ success: boolean; data: FolderNode }>(`/health/media-folders/${id}`, req);
return data.data;
},
deleteFolder: async (id: string, version: number) => {
const { data } = await client.delete<{ success: boolean; data: null }>(`/health/media-folders/${id}`, { data: { version } });
return data.data;
},
};
Task 16: 创建轮播图 API 客户端
Files:
-
Create:
apps/web/src/api/health/banners.ts -
Step 1: 创建 API 文件
import client from '../client';
export interface Banner {
id: string;
media_item_id: string;
image_url: string;
thumbnail_url: string | null;
title: string | null;
subtitle: string | null;
link_type: string | null;
link_target: string | null;
sort_order: number;
status: string;
start_time: string | null;
end_time: string | null;
media_deleted: boolean;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateBannerReq {
media_item_id: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order?: number;
status?: string;
start_time?: string;
end_time?: string;
}
export const bannerApi = {
list: async (status?: string) => {
const { data } = await client.get<{ success: boolean; data: Banner[] }>('/health/banners', { params: { status } });
return data.data;
},
create: async (req: CreateBannerReq) => {
const { data } = await client.post<{ success: boolean; data: Banner }>('/health/banners', req);
return data.data;
},
update: async (id: string, req: Partial<CreateBannerReq> & { version: number }) => {
const { data } = await client.put<{ success: boolean; data: Banner }>(`/health/banners/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
const { data } = await client.delete<{ success: boolean; data: null }>(`/health/banners/${id}`, { data: { version } });
return data.data;
},
sort: async (items: { id: string; sort_order: number }[]) => {
const { data } = await client.put<{ success: boolean; data: null }>('/health/banners/sort', { items });
return data.data;
},
};
- Step 2: 提交
feat(web): 新增媒体库和轮播图 API 客户端
Task 17: 创建 MediaPicker 组件
Files:
-
Create:
apps/web/src/components/health/MediaPicker/index.tsx -
Step 1: 创建组件
核心功能:
- Modal 弹窗,打开时加载媒体库列表
- 文件夹下拉选择 + 搜索框 + 上传按钮
- 网格缩略图展示,单选/多选模式
- 点击图片卡片高亮选中,底部"确认"按钮触发
onSelect - 支持弹窗内直接上传新图片
- 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 添加:
const MediaLibrary = lazy(() => import('./pages/health/MediaLibrary'));
// <Route path="/health/media" element={<MediaLibrary />} />
在 routeConfig.ts 添加权限:
{ path: "/health/media", permissions: ["health.media.list"] },
- Step 3: 验证 + 提交
feat(web): 新增媒体库管理页面 — 左树+右网格布局
Task 19: 创建轮播图管理页面
Files:
-
Create:
apps/web/src/pages/health/BannerManage.tsx -
Step 1: 创建页面
布局:PageContainer + Table + Drawer
-
标题栏 + "新增轮播图"按钮(AuthButton 包裹)
-
Ant Design Table:排序/缩略图/标题/副标题/跳转/状态/时间/操作
-
拖拽排序:
@dnd-kit/core+@dnd-kit/sortable -
Drawer 表单:
- 图片选择:点击区域 → 打开 MediaPicker
- 标题/副标题输入
- 跳转设置:Select(类型) + Input(目标)
- DateRangePicker(生效时间)
- 数字输入(排序值)
- Switch(启用/禁用)
-
Step 2: 添加路由和权限
-
Step 3: 验证 + 提交
feat(web): 新增轮播图管理页面 — 表格+拖拽排序+Drawer表单
Task 20: 改造文章编辑器
Files:
-
Modify:
apps/web/src/pages/health/ArticleEditor.tsx -
Step 1: 封面图改用 MediaPicker
将现有封面图上传组件替换为 MediaPicker:
-
点击封面图区域 → 打开 MediaPicker
-
选中后回填 cover_image URL
-
Step 2: 富文本插图改造
改造 Wangeditor 的 MENU_CONF.uploadImage:
-
customUpload调用mediaApi.upload(上传到媒体库) -
插入
<img>时 URL 附加?token=xxx -
新增"从媒体库选择"按钮(通过 Wangeditor 的
insertMenu或 panel) -
Step 3: 验证 + 提交
refactor(web): 文章编辑器封面图和插图改用 MediaPicker
Chunk 5: Miniprogram Changes
Task 21: 小程序 API 服务改造
Files:
-
Modify:
apps/miniprogram/src/services/article.ts -
Modify:
apps/miniprogram/src/services/request.ts -
Step 1: 新增公开 API 调用
在 article.ts 添加:
export interface PublicBanner {
id: string;
title: string | null;
subtitle: string | null;
image_url: string;
link_type: string | null;
link_target: string | null;
}
export async function getPublicBanners(): Promise<PublicBanner[]> {
const { data } = await publicClient.get('/public/banners');
return data;
}
export async function getPublicArticles(categoryId: string, pageSize = 4) {
const { data } = await publicClient.get('/public/articles', {
params: { category_id: categoryId, page_size: pageSize },
});
return data;
}
- Step 2: request.ts 增加 publicClient
添加一个不附加 Authorization header 的 client 实例:
const publicClient = axios.create({
baseURL: process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1',
headers: { 'X-Tenant-Id': process.env.TARO_APP_TENANT_ID || '' },
});
- Step 3: 提交
feat(miniprogram): 新增公开 API 调用 — 轮播图和文章
Task 22: 改造访客首页
Files:
-
Modify:
apps/miniprogram/src/pages/index/index.tsx -
Modify:
apps/miniprogram/src/pages/index/index.scss -
Step 1: 替换硬编码轮播图
- 删除
CAROUSEL_SLIDES常量 - 新增
useState<PublicBanner[]>([])和useEffect调用getPublicBanners() <SwiperItem>从 banners 数据渲染:- 使用
<Image src={banner.image_url} mode="aspectFill" /> - 叠加标题/副标题 overlay
- 使用
- 加载失败不显示轮播图(try/catch + 空数组降级)
- Step 2: 替换"核心功能"区域
- 删除 3 张硬编码卡片
- 新增
useEffect调用getPublicArticles(HOME_CATEGORY_ID) - 前 2 篇:两列网格(封面图 + 标题 + 摘要)
- 后 2 篇:横向列表(左图右文)
- 区域标题改为"健康资讯"
- 底部"查看更多"链接
- 空数据时显示空状态占位
- Step 3: 添加新样式
在 index.scss 中添加 .mp-article-grid、.mp-article-card 等样式(参照 HTML 可视化方案)。
- Step 4: 验证 + 提交
feat(miniprogram): 访客首页轮播图和文章列表接入 API