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)
1696 lines
52 KiB
Markdown
1696 lines
52 KiB
Markdown
# 媒体库与轮播图管理 — 实施计划
|
||
|
||
> **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
|
||
```
|