diff --git a/docs/design/media-library-ui-options.html b/docs/design/media-library-ui-options.html new file mode 100644 index 0000000..c6f54d2 --- /dev/null +++ b/docs/design/media-library-ui-options.html @@ -0,0 +1,1218 @@ + + + + + +HMS 媒体库 + 轮播图 + 文章列表 — UI 方案选择 + + + + + + + + +
+
媒体库管理页面 — 布局方案
+
两种布局风格,选择一种作为实施方案。左侧树+网格适合图片量大的场景,全宽网格适合图片量少的场景。
+ +
+ +
+
+

方案 A:左树 + 右网格

+ 推荐 +
+
+
+ +
+

文件夹

+
+ 📁 全部资源 +
+
+ 📂 轮播图 +
+
+ 📂 文章插图 +
+
+ 📂 活动图片 +
+
+ 📂 产品图片 +
+
+ +
+
+ +
+
+ + + + + +
+
+ +
+
🌐 公开
+
+ + + +
+
🏞
+
+
血透中心环境.jpg
+
1920×1080 · 2.4MB
+
+
+
+
🌐 公开
+
+ + + +
+
🌿
+
+
健康宣教封面.jpg
+
1200×800 · 1.8MB
+
+
+
+
+ + + +
+
🏥
+
+
医生团队合照.png
+
2400×1600 · 3.1MB
+
+
+
+
+ + + +
+
📊
+
+
健康数据分析.png
+
800×600 · 0.5MB
+
+
+
+
🌐 公开
+
+ + + +
+
❤️
+
+
心脏健康插图.jpg
+
1600×1200 · 1.2MB
+
+
+
+
+ + + +
+
🩺
+
+
体检报告模板.png
+
1000×700 · 0.8MB
+
+
+
+
🌐 公开
+
+ + + +
+
🏠
+
+
机构外景.jpg
+
3000×2000 · 4.2MB
+
+
+
+
+ + + +
+
💊
+
+
药品展示.jpg
+
1200×900 · 1.5MB
+
+
+
+ +
+
+
+ 特点:文件夹导航清晰,图片分类一目了然。适合图片数量多、需要分类管理的场景。与主流 CMS(WordPress、Strapi)体验一致。 +
+
+
+ + +
+
+

方案 B:全宽网格 + 筛选条

+ 备选 +
+
+
+
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
🌐 公开
+
🏞
+
+
血透中心环境.jpg
+
1920×1080 · 2.4MB
+
+
+
+
🌐 公开
+
🌿
+
+
健康宣教封面.jpg
+
1200×800 · 1.8MB
+
+
+
+
🏥
+
+
医生团队合照.png
+
2400×1600 · 3.1MB
+
+
+
+
📊
+
+
健康数据分析.png
+
800×600 · 0.5MB
+
+
+
+
🌐 公开
+
❤️
+
+
心脏健康插图.jpg
+
1600×1200 · 1.2MB
+
+
+
+
🩺
+
+
体检报告模板.png
+
1000×700 · 0.8MB
+
+
+
+
🌐 公开
+
🏠
+
+
机构外景.jpg
+
3000×2000 · 4.2MB
+
+
+
+
💊
+
+
药品展示.jpg
+
1200×900 · 1.5MB
+
+
+
+
🌐 公开
+
📋
+
+
健康指南封面.jpg
+
1400×1000 · 2.0MB
+
+
+
+
🧬
+
+
基因检测报告.png
+
900×600 · 0.6MB
+
+
+
+ +
+
+ 特点:空间利用率高,展示更多图片。文件夹通过下拉筛选切换,操作路径更短。适合图片数量少、不需要频繁切换分类的场景。 +
+
+
+
+ +
+
选择方案 A(左树+网格)
+
选择方案 B(全宽网格)
+
+
+ + +
+
轮播图管理页面
+
管理后台的轮播图 CRUD + 小程序预览。点击"新增轮播图"查看表单 Drawer。
+ +
+
+
+

轮播图管理

+

管理小程序访客首页轮播图,拖拽行调整展示顺序

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
排序图片标题副标题跳转状态生效时间操作
1
🏞
专业血透中心三甲级医护团队全程守护无跳转生效中长期 + + +
2
🌿
智慧健康管理AI 驱动个性化健康方案文章生效中长期 + + +
3
🏥
温馨就医环境舒适安全的治疗体验无跳转生效中长期 + + +
4
🎉
世界肾脏日活动3月14日线下健康讲座外部链接待生效2026-03-10 ~ 2026-03-14 + + +
+
+
+
+ + +
+
小程序访客首页 — 效果预览
+
轮播图从后台动态获取,文章列表按分类筛选展示。"核心功能"区域改为文章卡片列表。
+ +
+ +
+
当前效果(硬编码)
+
+
9:41
+
+ +
+
+
核心功能
+
+
+
+
健康数据管理
+
记录并追踪您的体征数据
+
+
+
智能预约排班
+
在线预约透析和治疗
+
+
+
AI 健康分析
+
个性化健康趋势解读
+
+
+
+
+
+
+ + +
+
优化后效果(动态数据)
+
+
9:41
+
+ + + + +
+
+
健康资讯
+ 查看更多 › +
+
+
+
+ 🌿 +
+
+

高血压患者的日常管理指南

+

科学控制血压,从日常生活细节做起

+
+
+
+
+ 🩺 +
+
+

透析患者饮食注意事项

+

合理膳食是透析治疗的重要补充

+
+
+
+
+
+
+ 💊 +
+
+

常见降压药物的服用须知

+

正确服药提高疗效,减少副作用

+
+
+
+
+
+
+ 🧬 +
+
+

肾功能指标解读

+

教你读懂化验单上的关键数据

+
+
+
+
+ + +
+
+
登录后解锁更多健康服务
+ +
+
+
+
+
+
+
+ + +
+
+
+

新增轮播图

+ +
+
+
+ +
+
+ 🏞 +
+ +
+
建议尺寸 750×360px,支持 JPG/PNG,不超过 5MB
+
+ +
+ + +
为空则使用图片的替代文本
+
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + ~ + +
+
留空表示永久生效
+
+ +
+ + +
+ +
+ +
+
+
+
+ 启用 +
+
+
+ +
+
+ + + + + diff --git a/docs/superpowers/specs/2026-05-10-media-library-banner-design.md b/docs/superpowers/specs/2026-05-10-media-library-banner-design.md new file mode 100644 index 0000000..89752d0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-media-library-banner-design.md @@ -0,0 +1,601 @@ +# 媒体库与轮播图管理 — 设计规格 + +> 日期: 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_id` 下 `name` 唯一(未删除的) +- 删除文件夹时,必须先清空其下所有子文件夹和媒体资源(或级联移动到父级) + +### 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/upload`,multipart 包含 `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` | + +**文件夹树响应格式:** +```json +{ + "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:** +```json +{ + "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 时附带短时效签名 token(1 小时有效) +5. 返回格式: +```json +{ + "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 配置新增 + +```toml +[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,选中后插入 `` 标签 + - 插入的图片 URL 附加 `?token=xxx` 用于认证 + +## 6. 小程序改动 + +### 6.1 访客首页轮播图 + +**文件:** `apps/miniprogram/src/pages/index/index.tsx` + +改动: +1. 删除 `CAROUSEL_SLIDES` 硬编码常量 +2. 新增 `useEffect` 在组件挂载时调用 `GET /public/banners` +3. 数据存入组件 state(`banners` 数组) +4. `` 从 banners 数据渲染: + - 背景:从 `image_url` 加载 `` 组件 + - 标题/副标题:从 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): + +```typescript +// 获取公开轮播图(无需认证) +export async function getPublicBanners(): Promise { + const { data } = await client.get('/public/banners'); + return data; +} + +// 获取公开文章列表(无需认证) +export async function getPublicArticles(categoryId: string, pageSize = 4): Promise { + 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` crate(Rust 纯实现,无系统依赖) + +**流程:** +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.rs` 的 `ErpModule::permissions()` 中注册: + +```rust +("health.media.list", "查看媒体库"), +("health.media.manage", "管理媒体资源(上传/编辑/删除)"), +("health.banners.manage", "管理轮播图"), +``` + +默认分配给 `admin` 和 `operator` 角色。 + +### 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 小程序验证 + +- [ ] 访客首页轮播图:未登录 → 动态显示后台配置的轮播图 +- [ ] 访客首页轮播图:点击跳转正确(文章详情 / 外部链接) +- [ ] 访客首页文章列表:显示"健康资讯"分类下已发布文章 +- [ ] 访客首页文章列表:点击"查看更多"跳转(需登录引导) +- [ ] 轮播图加载失败 → 不显示轮播区域(降级处理) +- [ ] 文章为空 → 显示空状态占位 +- [ ] 关怀模式下轮播图和文章列表显示正常