Files
hms/docs/superpowers/specs/2026-05-10-media-library-banner-design.md
iven a12fe0e8a9 docs(health): 媒体库与轮播图管理设计规格 + UI 可视化方案
新增设计规格涵盖:
- media_folder / media_item / banner 三个实体
- 媒体库 API(CRUD + 上传 + 裁剪 + 文件夹管理)
- 轮播图管理 API(CRUD + 排序 + 定时上下架)
- 公开端点(签名 URL 机制)+ 公开/私有访问控制
- 管理后台 UI(方案 A 左树+网格)+ MediaPicker 组件
- 小程序访客页改造(动态轮播图 + 文章卡片列表)
2026-05-10 11:32:38 +08:00

24 KiB
Raw Blame History

媒体库与轮播图管理 — 设计规格

日期: 2026-05-10 | 模块: erp-health | 状态: 设计中

1. 背景与目标

1.1 问题

小程序访客首页存在两个硬编码问题:

  1. 轮播图硬编码 — CSS 渐变背景,无图片,无后台管理。运营人员无法动态更新轮播内容。
  2. "核心功能"区域硬编码 — 3 张静态功能卡片,应改为按分类筛选的文章列表,让运营人员通过发布文章控制首页展示内容。

此外,当前系统缺少通用媒体库能力:

  • 上传的文件没有数据库记录,无法搜索、分类、复用
  • 文章编辑器和轮播图各自独立上传图片,无法共享资源
  • 没有文件夹组织,上传量大后难以管理

1.2 目标

  1. 新增媒体库模块:上传图片 → 文件夹分类 → 搜索筛选 → 多处复用(轮播图、文章封面、文章插图)
  2. 新增轮播图管理:从媒体库选图 → 配置标题/副标题/跳转 → 排序/定时上下架
  3. 改造小程序访客首页:轮播图从 API 动态获取,"核心功能"改为文章卡片列表
  4. 公开/私有访问控制:媒体资源可标记为公开,未登录用户可查看

1.3 范围

  • 媒体库放在 erp-health 模块内(当前只有健康管理使用媒体库)
  • 支持图片类型JPEG/PNG/GIF/WebP文档和视频不在本期范围
  • 在线编辑仅支持裁剪+缩放,不做复杂图片编辑
  • 缩略图自动生成,尺寸固定 200×200

1.4 依赖

  • 现有 POST /api/v1/upload 端点和本地文件存储
  • 现有文章管理模块entity/handler/service/dto
  • 现有 Wangeditor 富文本编辑器
  • 现有 Ant Design 组件库和主题系统

2. 数据模型

2.1 media_folder — 媒体文件夹

树形结构,支持嵌套分类。

字段 类型 约束 说明
id UUID v7 PK 主键
tenant_id UUID NOT NULL, IDX 租户隔离
name String(100) NOT NULL 文件夹名称
parent_id UUID NULL, FK→media_folder.id 父文件夹NULL 表示根级)
sort_order i32 DEFAULT 0 同级排序
created_at DateTime NOT NULL 创建时间
updated_at DateTime NOT NULL 更新时间
created_by UUID NULL 创建人
updated_by UUID NULL 更新人
deleted_at DateTime NULL 软删除标记
version i32 NOT NULL, DEFAULT 1 乐观锁

约束:

  • 同一 tenant_id + parent_idname 唯一(未删除的)
  • 删除文件夹时,必须先清空其下所有子文件夹和媒体资源(或级联移动到父级)

2.2 media_item — 媒体资源

字段 类型 约束 说明
id UUID v7 PK 主键
tenant_id UUID NOT NULL, IDX 租户隔离
folder_id UUID NULL, FK→media_folder.id 所属文件夹NULL 表示未分类)
filename String(255) NOT NULL 原始文件名
storage_path String(500) NOT NULL, UNIQUE 存储路径 /uploads/{tenant}/{uuid}.ext
thumbnail_path String(500) NULL 缩略图路径 /uploads/{tenant}/thumb_{uuid}.ext
content_type String(100) NOT NULL MIME 类型
file_size i64 NOT NULL 文件大小bytes
width i32 NULL 图片宽度px
height i32 NULL 图片高度px
alt_text String(255) NULL 替代文本(用于无障碍和 SEO
is_public bool NOT NULL, DEFAULT false 公开标记(公开资源无需认证即可访问)
created_at DateTime NOT NULL 创建时间
updated_at DateTime NOT NULL 更新时间
created_by UUID NULL 上传人
updated_by UUID NULL 更新人
deleted_at DateTime NULL 软删除标记
version i32 NOT NULL, DEFAULT 1 乐观锁

索引:

  • idx_media_item_tenant_folder ON (tenant_id, folder_id) — 按文件夹查询
  • idx_media_item_tenant_public ON (tenant_id, is_public) — 筛选公开资源
  • idx_media_item_content_type ON (content_type) — 按类型筛选

2.3 banner — 轮播图

字段 类型 约束 说明
id UUID v7 PK 主键
tenant_id UUID NOT NULL, IDX 租户隔离
media_item_id UUID NOT NULL, FK→media_item.id 关联媒体资源
title String(100) NULL 标题(覆盖媒体库 alt_text
subtitle String(255) NULL 副标题
link_type String(20) NULL 跳转类型:article / external / none
link_target String(500) NULL 跳转目标(文章 ID 或 URL
sort_order i32 NOT NULL, DEFAULT 0 展示排序
status String(20) NOT NULL, DEFAULT 'active' 状态:active / inactive
start_time DateTime NULL 定时上架时间NULL 表示立即)
end_time DateTime NULL 定时下架时间NULL 表示永久)
created_at DateTime NOT NULL 创建时间
updated_at DateTime NOT NULL 更新时间
created_by UUID NULL 创建人
updated_by UUID NULL 更新人
deleted_at DateTime NULL 软删除标记
version i32 NOT NULL, DEFAULT 1 乐观锁

索引:

  • idx_banner_tenant_status ON (tenant_id, status) — 按状态筛选
  • idx_banner_sort ON (tenant_id, sort_order) — 排序查询

约束:

  • link_type 仅允许 article / external / none
  • status 仅允许 active / inactive
  • end_time > start_time(当两者都不为 NULL 时)

3. API 设计

3.1 媒体库 — /api/v1/health/media

方法 路径 说明 权限码
GET /media 分页列表,支持 folder_id / content_type / keyword / is_public 筛选 health.media.list
POST /media/upload 上传文件到媒体库multipart/form-data可选指定 folder_id 和 is_public health.media.manage
GET /media/{id} 获取单个资源详情(含完整 URL health.media.list
PUT /media/{id} 更新元数据alt_text / folder_id / is_public含 version 乐观锁 health.media.manage
DELETE /media/{id} 软删除,同时删除存储文件和缩略图 health.media.manage
POST /media/{id}/move 移动到指定文件夹body: { folder_id, version } health.media.manage
POST /media/batch-delete 批量软删除body: { ids: [] } health.media.manage

上传流程:

  1. 客户端 POST /media/uploadmultipart 包含 file + 可选 folder_id + is_public
  2. 后端调用现有上传逻辑保存文件到 /uploads/{tenant_id}/{uuid}.ext
  3. 读取图片尺寸width/height生成 200×200 缩略图保存到 /uploads/{tenant_id}/thumb_{uuid}.ext
  4. 创建 media_item 数据库记录
  5. 返回 MediaItemResp

列表查询参数:

  • folder_id — 按文件夹筛选NULL 表示未分类,不传表示全部)
  • content_type — 按 MIME 类型前缀筛选(如 image/
  • keyword — 按文件名模糊搜索
  • is_public — 按公开状态筛选
  • page / page_size — 分页
  • sort_by — 排序字段created_at / file_size / filename默认 created_at DESC

3.2 文件夹 — /api/v1/health/media-folders

方法 路径 说明 权限码
GET /media-folders 返回当前租户的完整文件夹树 health.media.list
POST /media-folders 创建文件夹name + parent_id health.media.manage
PUT /media-folders/{id} 更新(重命名 / 移动 parent_id含 version health.media.manage
DELETE /media-folders/{id} 删除空文件夹(文件夹内有资源时拒绝) health.media.manage

文件夹树响应格式:

{
  "data": [
    {
      "id": "uuid",
      "name": "轮播图",
      "parent_id": null,
      "sort_order": 0,
      "children": [],
      "item_count": 5
    }
  ]
}

3.3 轮播图管理 — /api/v1/health/banners

方法 路径 说明 权限码
GET /banners 管理列表(含所有状态,支持 status 筛选) health.banners.manage
POST /banners 创建轮播图 health.banners.manage
PUT /banners/{id} 更新,含 version 乐观锁 health.banners.manage
DELETE /banners/{id} 软删除 health.banners.manage
PUT /banners/sort 批量更新排序body: [{ id, sort_order }] health.banners.manage

创建/更新 DTO

{
  "media_item_id": "uuid",
  "title": "专业血透中心",
  "subtitle": "三甲级医护团队全程守护",
  "link_type": "article",
  "link_target": "article-uuid",
  "sort_order": 1,
  "status": "active",
  "start_time": null,
  "end_time": null
}

3.4 公开端点(无需认证)

方法 路径 说明
GET /public/banners 返回当前生效的轮播图列表
GET /public/articles 返回指定分类下的已发布文章(小程序访客页用)

GET /public/banners 逻辑:

  1. 不需要 JWT 认证
  2. 查询条件:status = 'active' AND deleted_at IS NULL AND 当前时间在 [start_time, end_time] 范围内start_time/end_time 为 NULL 时不限)
  3. sort_order ASC 排序
  4. 返回图片 URL 时附带短时效签名 token1 小时有效)
  5. 返回格式:
{
  "data": [
    {
      "id": "uuid",
      "title": "专业血透中心",
      "subtitle": "三甲级医护团队全程守护",
      "image_url": "/uploads/{tenant}/xxx.jpg?token=xxx&expires=xxx",
      "link_type": "article",
      "link_target": "article-uuid"
    }
  ]
}

GET /public/articles 逻辑:

  1. 复用现有 list_articles service 逻辑,但跳过认证
  2. 固定筛选 status = 'published'
  3. 必须传 category_id 参数(指定"首页推荐"分类)
  4. 分页:默认 page_size=4
  5. 返回 ArticleListItem[](含 cover_image

3.5 新增权限码

权限码 说明 默认角色
health.media.list 查看媒体库 admin, operator
health.media.manage 上传/编辑/删除媒体资源 admin, operator
health.banners.manage 管理轮播图 admin, operator

4. 公开访问机制

4.1 问题

当前 /uploads/ 路径受 JWT 中间件保护,所有文件访问需要认证。但小程序访客页面的轮播图需要未登录用户可见。

4.2 方案:公开标记 + 签名 URL

数据库层: media_item.is_public 标记资源是否可公开访问。

访问流程:

  1. GET /public/banners 返回图片 URL 时,对 is_public = true 的资源生成签名 URL
    • 格式:/uploads/{tenant}/{file}?token={hmac_signature}&expires={unix_timestamp}
    • HMAC-SHA256 签名,密钥从配置读取(storage.secret_key
    • 有效期 1 小时
  2. is_public = false 的资源,仍通过标准 JWT 认证访问

文件服务中间件改动:

  • 现有 upload_auth_middleware 增加签名 URL 验证分支:
    • 请求携带 ?token=&expires= 参数时,验证签名和过期时间
    • 验证通过则放行,不通过返回 401
    • 不携带签名参数时,走原有 JWT 认证逻辑

安全考虑:

  • 签名密钥不暴露给客户端,仅后端生成和验证
  • 签名 URL 有时效性,过期自动失效
  • 私有资源不受影响,仍需 JWT
  • 每个签名 URL 绑定具体文件路径,无法篡改

4.3 配置新增

[storage]
upload_dir = "./uploads"
max_file_size = "10MB"
secret_key = "change-me-in-production"  # 签名 URL 密钥

5. 管理后台 UI

5.1 媒体库页面 — MediaLibrary

路径: apps/web/src/pages/health/MediaLibrary.tsx

布局:方案 A左树 + 右网格)

采用 PageContainer 包裹,内部水平分割:

左侧栏200px

  • 标题"文件夹" + 新建按钮
  • 树形列表,每项显示图标 + 名称 + 资源数量
  • "全部资源"为默认选中项(不对应 folder_id
  • 右键/更多菜单:重命名、删除
  • 支持拖拽排序

右侧主区域:

  • 工具栏上传按钮primary + 搜索框 + 类型下拉 + 公开状态下拉 + 视图切换(网格/列表)
  • 网格视图4 列缩略图卡片,每张 200×200
    • 卡片内容:缩略图 + 左上角公开标记 badge + 右上角 hover 操作按钮(复制 URL、编辑、删除
    • 下方显示文件名和尺寸信息
    • 点击卡片打开大图预览 Modal
  • 列表视图Ant Design Table列为缩略图(40×40)/文件名/类型/大小/公开状态/创建时间/操作
  • 分页:底部分页条

上传交互:

  • 点击"上传图片"按钮打开文件选择器accept="image/*"multiple
  • 上传过程中显示进度条
  • 上传完成自动刷新列表

编辑 Modal

  • alt_text 文本输入
  • 文件夹下拉选择(移动到...
  • 公开/私有开关
  • 保存后刷新列表

批量操作:

  • 勾选多个卡片 → 底部浮动操作栏:批量删除 / 批量移动 / 批量设置公开

5.2 轮播图管理页面 — BannerManage

路径: apps/web/src/pages/health/BannerManage.tsx

布局PageContainer + Table + Drawer

  • 标题栏:标题 + "新增轮播图"按钮
  • 表格列:排序 / 缩略图(40×40) / 标题 / 副标题 / 跳转(类型+目标) / 状态(Tag) / 生效时间 / 操作(编辑/删除)
  • 拖拽排序:使用 @dnd-kit 实现行拖拽,松手后调用 PUT /banners/sort
  • Drawer 表单(新增/编辑):
    • 图片选择:点击区域打开 MediaPicker 弹窗
    • 标题、副标题文本输入
    • 跳转设置:类型下拉 + 目标输入(选择"文章"时弹出文章搜索选择器)
    • 生效时间DateRangePicker
    • 排序值:数字输入
    • 状态开关

5.3 MediaPicker 组件

路径: apps/web/src/components/MediaPicker/index.tsx

从媒体库页面抽取的独立选图组件:

  • Props
    • visible: boolean — 显示/隐藏
    • onSelect: (item: MediaItemResp) => void — 选中回调
    • accept?: string — 文件类型限制
    • multiple?: boolean — 是否多选
  • 布局: Modal 弹窗,内部复用网格视图(不带左侧树,用下拉选择文件夹)
  • 交互: 点击图片卡片选中(高亮边框),点击确认按钮触发 onSelect
  • 集成上传: 弹窗内可直接上传新图片,上传后自动出现在列表中
  • 使用场景:
    1. 轮播图管理 — 选择图片
    2. 文章编辑器 — 选择封面图
    3. 文章编辑器 — Wangeditor 插入图片时选图

5.4 ArticleEditor 增强

改造现有 ArticleEditor.tsx

  1. 封面图:从当前的上传组件改为 MediaPicker 组件。点击封面图区域打开 MediaPicker选中后回填 URL。
  2. 富文本插图:改造 Wangeditor 的 MENU_CONF.uploadImage
    • customUpload 改为调用 POST /media/upload(上传到媒体库并返回 media_item
    • 新增 customBrowse 按钮打开 MediaPicker选中后插入 <img> 标签
    • 插入的图片 URL 附加 ?token=xxx 用于认证

6. 小程序改动

6.1 访客首页轮播图

文件: apps/miniprogram/src/pages/index/index.tsx

改动:

  1. 删除 CAROUSEL_SLIDES 硬编码常量
  2. 新增 useEffect 在组件挂载时调用 GET /public/banners
  3. 数据存入组件 statebanners 数组)
  4. <SwiperItem> 从 banners 数据渲染:
    • 背景:从 image_url 加载 <Image> 组件
    • 标题/副标题:从 banner.title / banner.subtitle 渲染
    • 跳转:点击 SwiperItem 时根据 link_type 跳转article → navigateTo 文章详情external → Taro 导航)
  5. 加载中显示骨架屏Skeleton加载失败不显示轮播图降级处理

6.2 文章列表区域

文件: apps/miniprogram/src/pages/index/index.tsx

改动:

  1. 删除"核心功能"区域的 3 张硬编码卡片
  2. 新增 useEffect 调用 GET /public/articles?category_id={首页推荐分类ID}&page_size=4
  3. 渲染文章卡片列表:
    • 前 2 篇使用两列网格布局(mp-article-grid),显示封面图 + 标题 + 摘要
    • 后 2 篇使用横向列表布局(左图右文),节省空间
    • 底部"查看更多"链接,跳转到文章列表页(需登录)
  4. 区域标题从"核心功能"改为"健康资讯"
  5. 无数据时显示空状态占位

6.3 小程序 API 服务

文件: apps/miniprogram/src/services/article.ts

新增公开 API 调用(不需要 token

// 获取公开轮播图(无需认证)
export async function getPublicBanners(): Promise<Banner[]> {
  const { data } = await client.get('/public/banners');
  return data;
}

// 获取公开文章列表(无需认证)
export async function getPublicArticles(categoryId: string, pageSize = 4): Promise<ArticleListItem[]> {
  const { data } = await client.get('/public/articles', { params: { category_id: categoryId, page_size: pageSize } });
  return data;
}

需要在 request.ts 中增加对 /public/ 路径的跳过认证逻辑(不附加 Authorization header

7. 缩略图与在线编辑

7.1 缩略图生成

时机: 上传图片后,在 media_service 中自动生成。

规格:

  • 尺寸200×200px居中裁剪cover 模式)
  • 格式保持原格式JPEG→JPEG, PNG→PNG
  • 质量JPEG 85%
  • 命名:thumb_{uuid}.ext,与原文件同目录
  • 库:使用 image crateRust 纯实现,无系统依赖)

流程:

  1. 接收上传文件 → 保存原文件
  2. 读取图片 → 获取原始 width/height
  3. 居中裁剪到正方形 → 缩放到 200×200
  4. 保存缩略图
  5. 数据库记录 storage_path + thumbnail_path + width + height

7.2 在线编辑

范围: 仅支持裁剪 + 缩放,不做旋转、滤镜、文字标注等。

实现方案:

  • 前端:使用 react-image-crop
  • 后端:接收裁剪参数 { x, y, width, height },裁剪原图并保存为新版本
  • 替换策略:裁剪后覆盖原文件(保留原文件名),同时重新生成缩略图
  • 不保留编辑历史(简化实现)

交互:

  1. 媒体库中点击"编辑"按钮
  2. 打开 Modal显示原图 + 裁剪框
  3. 调整裁剪区域 → 确认
  4. 调用 PUT /media/{id}/crop,传裁剪参数
  5. 后端裁剪 → 覆盖原文件 → 重新生成缩略图
  6. 前端刷新列表

7.3 新增 API

方法 路径 说明
POST /media/{id}/crop 裁剪图片body: { x, y, width, height, version }

8. 权限与安全

8.1 权限码注册

erp-health/src/module.rsErpModule::permissions() 中注册:

("health.media.list", "查看媒体库"),
("health.media.manage", "管理媒体资源(上传/编辑/删除)"),
("health.banners.manage", "管理轮播图"),

默认分配给 adminoperator 角色。

8.2 安全考虑

风险 缓解措施
上传恶意文件 复用现有 Content-Type 白名单 + Magic Bytes 验证
路径遍历攻击 文件名使用 UUID v7不使用用户输入的文件名
签名 URL 泄露 1 小时有效期 + 绑定具体文件路径 + HMAC 验证
公开资源被枚举 UUID v7 不可预测,不暴露列表端点给未认证用户
批量删除误操作 前端二次确认 + 软删除可恢复
磁盘空间耗尽 配置 max_file_size 限制单文件大小,监控 uploads 目录
跨租户访问 所有查询强制 tenant_id 过滤,文件路径包含 tenant_id

8.3 文件类型限制

上传端点仅允许图片类型:

类型 MIME Magic Bytes
JPEG image/jpeg FF D8 FF
PNG image/png 89 50 4E 47
GIF image/gif GIF87a / GIF89a
WebP image/webp RIFF....WEBP

现有上传端点已支持文档类型PDF/Word/Excel媒体库上传端点在此基础上进一步限制为仅图片。

9. 关键文件清单

9.1 后端新增

文件 说明
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/handler/media_handler.rs 媒体库 HTTP 处理
crates/erp-health/src/handler/banner_handler.rs 轮播图 HTTP 处理
crates/erp-health/src/service/media_service.rs 媒体库业务逻辑CRUD + 缩略图生成 + 裁剪)
crates/erp-health/src/service/banner_service.rs 轮播图业务逻辑CRUD + 排序 + 定时生效)
crates/erp-health/src/dto/media_dto.rs 媒体库 DTO
crates/erp-health/src/dto/banner_dto.rs 轮播图 DTO
迁移文件3 个) media_folder / media_item / banner 建表

9.2 后端修改

文件 改动
crates/erp-health/src/module.rs 注册路由 + 权限码
crates/erp-health/src/entity/mod.rs 导出新实体
crates/erp-health/src/handler/mod.rs 导出新 handler
crates/erp-health/src/service/mod.rs 导出新 service
crates/erp-health/src/dto/mod.rs 导出新 DTO
crates/erp-server/src/main.rs 注册公开路由组(/public/
crates/erp-server/src/handlers/upload.rs upload_auth_middleware 增加签名 URL 验证
crates/erp-server/src/config.rs StorageConfig 增加 secret_key 字段
crates/erp-server/config/default.toml 增加 secret_key 配置

9.3 前端新增

文件 说明
apps/web/src/pages/health/MediaLibrary.tsx 媒体库页面
apps/web/src/pages/health/BannerManage.tsx 轮播图管理页面
apps/web/src/components/MediaPicker/index.tsx 媒体选择器组件
apps/web/src/api/health/media.ts 媒体库 API 客户端
apps/web/src/api/health/banners.ts 轮播图 API 客户端

9.4 前端修改

文件 改动
apps/web/src/pages/health/ArticleEditor.tsx 封面图和插图改用 MediaPicker
apps/web/src/router/ 添加媒体库和轮播图路由
apps/web/src/api/upload.ts 可选:上传后自动创建 media_item

9.5 小程序修改

文件 改动
apps/miniprogram/src/pages/index/index.tsx 轮播图和文章列表接入 API
apps/miniprogram/src/services/article.ts 新增公开 API 调用
apps/miniprogram/src/services/request.ts /public/ 路径跳过认证

10. 验证清单

10.1 后端验证

  • cargo check 全 workspace 通过
  • cargo test --workspace 全部通过
  • 数据库迁移正反执行成功
  • POST /media/upload 上传图片 → 返回 media_item 含缩略图
  • GET /media 列表筛选(按文件夹/类型/关键词/公开状态)
  • PUT /media/{id} 更新元数据 → 乐观锁生效
  • DELETE /media/{id} 软删除 → 文件从磁盘移除
  • POST /media/{id}/crop 裁剪 → 原文件和缩略图更新
  • POST /banners 创建轮播图 → 关联 media_item
  • PUT /banners/sort 排序 → sort_order 更新
  • GET /public/banners 无认证 → 返回签名 URL
  • 签名 URL 访问文件 → 成功返回图片
  • 过期签名 URL → 返回 401
  • 私有资源不附加 JWT → 返回 401

10.2 管理后台验证

  • 媒体库页面:文件夹树加载/新建/重命名/删除
  • 媒体库页面:上传图片 → 列表显示缩略图
  • 媒体库页面:搜索/筛选/分页正常
  • 媒体库页面:编辑 alt_text / 公开状态 → 保存成功
  • 媒体库页面:裁剪图片 → 结果正确
  • 轮播图管理:新增 → 从 MediaPicker 选图 → 保存
  • 轮播图管理:拖拽排序 → sort_order 更新
  • 轮播图管理:设置定时上下架 → 状态正确切换
  • 文章编辑器:封面图从 MediaPicker 选图
  • 文章编辑器:插图从 MediaPicker 选图或直接上传

10.3 小程序验证

  • 访客首页轮播图:未登录 → 动态显示后台配置的轮播图
  • 访客首页轮播图:点击跳转正确(文章详情 / 外部链接)
  • 访客首页文章列表:显示"健康资讯"分类下已发布文章
  • 访客首页文章列表:点击"查看更多"跳转(需登录引导)
  • 轮播图加载失败 → 不显示轮播区域(降级处理)
  • 文章为空 → 显示空状态占位
  • 关怀模式下轮播图和文章列表显示正常