Files
WonderQ-Admin-UI/docs/admin-module-config-api.md
duanshuwen 9fd91c84e5 feat(admin): add route sections admin management module
This commit adds the complete admin workflow for managing homepage curated route subgroups:
- Add `RouteSection` type and extend `SiteConfig`/`SiteModule` to support the new module
- Create all required admin components: panel, editors, product picker, and utility functions
- Update admin utilities and API types to handle routeSections CRUD operations
- Update documentation to include the new routeSections API contract
- Add supporting CSS styles for the new UI elements
2026-07-02 23:05:45 +08:00

683 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 页面模块配置 Admin API 契约
本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠、精选线路分组和底部运营入口。
## 适用模块
当前一期只纳入结构化页面配置模块:
| module | 名称 | 用途 |
| -------------- | ------------ | ---------------------------------------------- |
| `heroSlides` | 顶部轮播 | 首页首屏轮播图、标题短文案、展示排序和启用状态 |
| `destinations` | 目的地 | 首页/目的地页展示、搜索入口和热门标记 |
| `map` | 贵州地图 | 首页「探索贵州」区域内的地图图片素材 |
| `themes` | 主题甄选 | 首页主题卡片和跳转 |
| `campaigns` | 特价优惠 | 首页特价优惠活动元信息 |
| `routeSections` | 精选线路子分组 | 首页“精选线路”按运营任务新增分组,维护标题、副文案、启用状态和关联商品 |
| `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA |
商品本体、活动商品池和线索跟进继续走独立业务接口,不混入本契约。`routeSections` 只维护精选线路子分组和商品 ID 关联,不重复编辑商品详情。
## 通用约定
- 所有接口前缀为 `/api/admin`
- 所有接口需要校验 `Authorization: Bearer <token>`
- 请求和响应均为 `application/json`
- 字段使用 camelCase。
- `PATCH` 为部分更新,只修改请求体中出现的字段。
- 删除接口返回 JSON不能返回空 body因为当前前端请求封装会读取 JSON。
错误响应保持当前前端兼容格式:
```json
{
"message": "模块不存在或无权限操作",
"code": "MODULE_CONFIG_FORBIDDEN",
"details": {}
}
```
## 后端实现重点
WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map``campaigns` 作为正式模块接入,而不是只在前端展示:
- 模块白名单必须包含 `heroSlides``destinations``map``themes``campaigns``routeSections``ctaBanners`
- 权限校验、模块路由、服务层分发和数据模型映射都必须识别 `map``campaigns`,否则前端会收到 `MODULE_CONFIG_FORBIDDEN` 并以 toast 展示失败原因。
- `GET /api/admin/site-config` 即使没有地图数据,也必须返回 `map: []`,不要省略 `map` 字段。
- `GET /api/admin/site-config` 即使没有特价优惠数据,也必须返回 `campaigns: []`,不要省略 `campaigns` 字段。
- `GET /api/admin/site-config` 必须返回 `routeSections`,包含未启用分组和后台已配置的全部商品 ID。
- `map` 只维护一张图片,只需要支持查询、创建、更新、删除,不需要排序接口。
- `campaigns` 维护活动元信息,只需要支持查询、创建、更新、删除,不需要排序接口。
- `routeSections` 允许按运营任务新增多个子分组;已创建分组支持更新字段、商品关联、删除和分组顺序。
- 同一商品不能同时出现在多个 `routeSections` 子分组;更新 `productIds` 时后端需要校验互斥。
- 图片上传仍走 `POST /api/admin/media-assets/upload`,模块保存接口只接收上传结果里的 OSS `url` 字段并写入 `image`
## 类型定义
```ts
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners";
type SiteItemPatch = {
title?: string;
subtitle?: string | null;
kicker?: string;
name?: string;
slug?: string;
region?: string | null;
label?: string;
alt?: string;
image?: string | null;
description?: string | null;
coverImage?: string | null;
targetType?: string | null;
targetValue?: string | null;
isHot?: boolean;
isActive?: boolean;
sortOrder?: number;
productIds?: string[];
status?: "draft" | "published";
startsAt?: string | null;
endsAt?: string | null;
};
type HeroSlide = {
id: string;
title: string;
kicker: string | null;
image: string | null;
isActive: boolean;
sortOrder: number;
createdAt?: string;
updatedAt?: string;
};
type HeroSlideCreateInput = {
title: string;
kicker?: string | null;
image?: string | null;
isActive?: boolean;
sortOrder?: number;
};
type HeroSlideUpdateInput = Partial<HeroSlideCreateInput>;
type MapImage = {
id: string;
image: string | null;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
};
type MapImageCreateInput = {
image: string;
isActive?: boolean;
};
type MapImageUpdateInput = Partial<MapImageCreateInput>;
type Campaign = {
id: string;
slug: string;
title: string;
description: string | null;
coverImage: string | null;
priceAmount: number | null;
priceUnit: string | null;
tags: string[];
status: "draft" | "published";
startsAt: string | null;
endsAt: string | null;
createdAt?: string;
updatedAt?: string;
};
type CampaignCreateInput = {
title: string;
slug: string;
description?: string | null;
coverImage?: string | null;
priceAmount?: number | null;
priceUnit?: string | null;
tags?: string[];
status?: "draft" | "published";
startsAt?: string | null;
endsAt?: string | null;
};
type CampaignUpdateInput = Partial<CampaignCreateInput>;
type RouteSection = {
id: string;
title: string;
subtitle: string | null;
productIds: string[];
isActive: boolean;
sortOrder: number;
createdAt?: string;
updatedAt?: string;
};
```
各模块字段要求:
| module | 创建必填 | 可选字段 |
| -------------- | -------- | ------------------------------------------------------------- |
| `heroSlides` | `title` | `kicker``image``isActive``sortOrder` |
| `destinations` | `name` | `slug``region``image``isHot``isActive``sortOrder` |
| `map` | `image` | `isActive` |
| `themes` | `label` | `image``targetType``targetValue``isActive``sortOrder` |
| `campaigns` | `title``slug` | `description``coverImage``priceAmount``priceUnit``tags``status``startsAt``endsAt` |
| `routeSections` | `title` | `subtitle``productIds``isActive``sortOrder` |
| `ctaBanners` | `alt` | `image``targetType``targetValue``isActive``sortOrder` |
后端可以在创建时补全 `id`、默认 `isActive=true`、默认 `sortOrder=当前模块最后一位`
`campaigns` 创建时默认 `status="draft"`,不会进入 Public `site-config.campaigns`;只有 `status="published"` 的活动会进入 H5 Public API。
Admin UI 面向运营只展示活动标题、活动描述、封面图和前台启用状态;`slug` 是接口必填技术标识,前端可在新建时自动生成,编辑时复用原值。
## 顶部轮播 `heroSlides` 专用契约
顶部轮播对应当前管理端抽屉中的 3 个区域:
- 展示内容:`title``kicker`
- 资源图片:`image`
- 排序与状态:`sortOrder``isActive`
字段语义:
| 字段 | 类型 | 创建 | 更新 | 说明 |
| ----------- | ---------------- | -------- | ---------- | ---------------------------------------------------------------------- |
| `id` | `string` | 后端生成 | 不允许修改 | 顶部轮播配置项唯一 id |
| `title` | `string` | 必填 | 可选 | 前台轮播主标题,提交时需要去除首尾空格,不能为空 |
| `kicker` | `string \| null` | 可选 | 可选 | 副标题/短文案,空字符串可归一化为 `null``""`,前后端需保持响应一致 |
| `image` | `string \| null` | 可选 | 可选 | 单张轮播图地址或素材 URL未上传时为 `null` |
| `sortOrder` | `number` | 可选 | 可选 | 展示顺序,整数;未传时追加到当前模块末尾 |
| `isActive` | `boolean` | 可选 | 可选 | 前台是否展示;未传时默认 `true` |
| `createdAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 |
| `updatedAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 |
顶部轮播不定义跳转能力。`heroSlides` 的创建、更新、查询响应中不要返回 `targetType``targetValue`;兼容期如果请求体携带这两个字段,后端可以忽略,但不要写入顶部轮播业务数据。
### 顶部轮播 CRUD
新增顶部轮播:
```http
POST /api/admin/site-config/heroSlides
```
请求体:
```json
{
"title": "新轮播",
"kicker": "贵州小包团首选",
"image": "/assets/source/hero.jpg",
"isActive": true,
"sortOrder": 0
}
```
响应状态码 `201`
```json
{
"id": "slide_001",
"title": "新轮播",
"kicker": "贵州小包团首选",
"image": "/assets/source/hero.jpg",
"isActive": true,
"sortOrder": 0,
"createdAt": "2026-07-01T08:00:00.000Z",
"updatedAt": "2026-07-01T08:00:00.000Z"
}
```
更新顶部轮播:
```http
PATCH /api/admin/site-config/heroSlides/:id
```
请求体为 `HeroSlideUpdateInput`,只提交需要修改的字段:
```json
{
"title": "夏日贵州小包团",
"image": null,
"isActive": false
}
```
响应状态码 `200`,响应体返回更新后的完整 `HeroSlide`
删除顶部轮播:
```http
DELETE /api/admin/site-config/heroSlides/:id
```
响应状态码 `200`
```json
{
"id": "slide_001"
}
```
删除后后端需要重新整理 `heroSlides` 内剩余项的 `sortOrder`
调整顶部轮播顺序:
```http
PATCH /api/admin/site-config/heroSlides/reorder
```
请求体:
```json
{
"itemIds": ["slide_002", "slide_001", "slide_003"]
}
```
响应状态码 `200`
```json
{
"items": [
{
"id": "slide_002",
"title": "第二张",
"kicker": null,
"image": null,
"sortOrder": 0,
"isActive": true
},
{
"id": "slide_001",
"title": "第一张",
"kicker": null,
"image": null,
"sortOrder": 1,
"isActive": true
}
]
}
```
## 贵州地图 `map` 专用契约
贵州地图模块对应当前管理端抽屉中的 2 个区域:
- 地图图片:`image`
- 显示状态:`isActive`
字段语义:
| 字段 | 类型 | 创建 | 更新 | 说明 |
| ----------- | ---------------- | -------- | ---------- | ----------------------------------------- |
| `id` | `string` | 后端生成 | 不允许修改 | 地图图片配置项唯一 id |
| `image` | `string \| null` | 必填 | 可选 | 单张地图图片地址或素材 URL创建时不能为空 |
| `isActive` | `boolean` | 可选 | 可选 | 前台是否展示;未传时默认 `true` |
| `createdAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 |
| `updatedAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 |
贵州地图只维护一张图片,不提供排序能力。`map` 的查询响应返回数组是为了复用现有站点配置结构,但最多返回 1 项。创建第二张地图图片时,后端应返回 409 或改为更新当前唯一图片,具体以后端实现保持一致。贵州地图不定义标题、文案和跳转能力。`map` 的创建、更新、查询响应中不要返回 `title``name``label``alt``targetType``targetValue``sortOrder`
后端推荐策略:`POST /api/admin/site-config/map` 在不存在地图图片时创建;已存在时返回 `409 MAP_IMAGE_ALREADY_EXISTS`,或直接更新当前唯一图片。无论选择哪种策略,都要保证 `PATCH /api/admin/site-config/map/:id` 可以按 id 更新当前图片。
### 贵州地图 CRUD
新增贵州地图图片:
```http
POST /api/admin/site-config/map
```
请求体:
```json
{
"image": "https://bucket.oss-cn-example.aliyuncs.com/admin/map/2026/07/01/guizhou-map.webp",
"isActive": false
}
```
响应状态码 `201`
```json
{
"id": "map_001",
"image": "https://bucket.oss-cn-example.aliyuncs.com/admin/map/2026/07/01/guizhou-map.webp",
"isActive": false,
"createdAt": "2026-07-01T08:00:00.000Z",
"updatedAt": "2026-07-01T08:00:00.000Z"
}
```
更新贵州地图图片:
```http
PATCH /api/admin/site-config/map/:id
```
请求体为 `MapImageUpdateInput`,只提交需要修改的字段。
删除贵州地图图片:
```http
DELETE /api/admin/site-config/map/:id
```
## 特价优惠 `campaigns` 专用契约
特价优惠模块对应 H5 Public API 的 `site-config.campaigns`,只维护活动元信息,不维护活动商品关联列表。
字段语义:
| 字段 | 类型 | 创建 | 更新 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | `string` | 后端生成 | 不允许修改 | 活动唯一 id |
| `slug` | `string` | 必填 | 可选 | 活动标识,需要全局唯一 |
| `title` | `string` | 必填 | 可选 | 活动标题,后台列表主标题 |
| `description` | `string \| null` | 可选 | 可选 | 活动描述,后台列表副文案 |
| `coverImage` | `string \| null` | 可选 | 可选 | 活动封面图 OSS URL |
| `priceAmount` | `number \| null` | 可选 | 可选 | 参考起价,单位按 `priceUnit` 展示 |
| `priceUnit` | `string \| null` | 可选 | 可选 | 价格单位文案,默认 `起/人` |
| `tags` | `string[]` | 可选 | 可选 | 活动卡片标签,最多 3 个 |
| `status` | `"draft" \| "published"` | 可选 | 可选 | 新建默认 `draft`Public API 只返回 `published` |
| `startsAt` | `string \| null` | 可选 | 可选 | 活动开始时间 |
| `endsAt` | `string \| null` | 可选 | 可选 | 活动结束时间 |
| `createdAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 |
| `updatedAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 |
### 特价优惠 CRUD
新增特价优惠:
```http
POST /api/admin/site-config/campaigns
```
请求体:
```json
{
"slug": "classic-deal",
"title": "经典打卡特惠",
"description": "经典首游活动",
"coverImage": "https://bucket.oss-cn-example.aliyuncs.com/admin/campaigns/2026/07/02/classic.webp",
"priceAmount": 162500,
"priceUnit": "起/人",
"tags": ["臻藏旅位", "赛事庆典"],
"status": "draft",
"startsAt": null,
"endsAt": null
}
```
更新特价优惠:
```http
PATCH /api/admin/site-config/campaigns/:id
```
删除特价优惠:
```http
DELETE /api/admin/site-config/campaigns/:id
```
特价优惠不定义 `sortOrder``isActive``targetType``targetValue`;兼容期如果请求体携带这些字段,后端可以忽略,但不要写入 `Campaign` 业务数据。
`tags` 保存前需要 trim 并过滤空字符串;有效标签超过 3 个时返回 `422 MODULE_CONFIG_VALIDATION_ERROR``details``{ "field": "tags", "max": 3 }`
## 精选线路子分组 `routeSections` 专用契约
`routeSections` 对应首页“精选线路”的运营分组。运营可按任务新增分组,并维护分组标题、副文案、启用状态、分组顺序、关联线路商品和商品顺序。
字段语义:
| 字段 | 类型 | 更新 | 说明 |
| --- | --- | --- | --- |
| `id` | `string` | 不允许修改 | 系统生成分组 ID |
| `title` | `string` | 可选 | 分组标题,不能为空 |
| `subtitle` | `string \| null` | 可选 | 分组副文案 |
| `productIds` | `string[]` | 可选 | 关联商品 ID按数组顺序展示 |
| `isActive` | `boolean` | 可选 | 用户侧是否展示该分组 |
| `sortOrder` | `number` | 可选 | 分组展示顺序 |
更新分组:
```http
POST /api/admin/site-config/routeSections
```
请求体示例:
```json
{
"title": "经典人文打卡线路",
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联"
}
```
响应状态码 `201`,返回创建后的子分组。`id` 由后端生成;精选线路分组按运营任务动态新增,不再限制为固定三组,也不再使用 `routes` / `routes-outdoor` / `routes-mix` 作为固定槽位。
```http
PATCH /api/admin/site-config/routeSections/:id
```
请求体示例:
```json
{
"title": "经典人文打卡线路",
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
"isActive": true,
"productIds": ["product-uuid-1", "product-uuid-2"]
}
```
调整分组顺序:
```http
PATCH /api/admin/site-config/routeSections/reorder
```
约束:
- `productIds` 中的商品必须存在。
- 同一商品不能出现在其他精选线路子分组;冲突时返回 `409 ROUTE_SECTION_PRODUCT_CONFLICT`
- `DELETE /api/admin/site-config/routeSections/:id` 删除分组配置并返回被删除 ID只移除首页分组不删除关联商品本体。
## 接口列表
以下路径由七类页面模块复用;`heroSlides``map``campaigns``routeSections` 的请求体和响应体以各自专用契约为准。
### 获取完整站点配置
```http
GET /api/admin/site-config
```
响应:
```ts
type SiteConfig = {
heroSlides: HeroSlide[];
destinations: Destination[];
map: MapImage[];
themes: ThemeCard[];
campaigns: Campaign[];
routeSections: RouteSection[];
ctaBanners: CtaBanner[];
};
```
`map` 字段必须稳定返回数组;无数据时返回空数组 `[]`
`campaigns` 字段必须稳定返回数组;无数据时返回空数组 `[]`
`routeSections` 字段必须稳定返回数组;无数据时返回 `[]`,由管理端通过“新增”逐个创建子分组。
### 新增模块配置项
```http
POST /api/admin/site-config/:module
```
请求体为 `SiteItemPatch`。响应状态码 `201`,响应体返回创建后的完整配置项:
```json
{
"id": "slide_001",
"title": "新轮播",
"kicker": "",
"image": null,
"isActive": true,
"sortOrder": 5
}
```
### 更新模块配置项
```http
PATCH /api/admin/site-config/:module/:id
```
请求体为 `SiteItemPatch`。响应状态码 `200`,响应体返回更新后的完整配置项。
### 删除模块配置项
```http
DELETE /api/admin/site-config/:module/:id
```
响应状态码 `200`,响应体:
```json
{
"id": "slide_001"
}
```
删除后后端需要重新整理同模块内剩余项的 `sortOrder`
### 调整模块配置顺序
```http
PATCH /api/admin/site-config/:module/reorder
```
请求体:
```json
{
"itemIds": ["slide_002", "slide_001", "slide_003"]
}
```
约束:
- `itemIds` 必须包含该模块当前全部配置项 id。
- 不允许重复 id。
- 不允许混入其他模块 id。
- 后端按数组顺序写入 `sortOrder`,从 0 开始。
`map``campaigns` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder``PATCH /api/admin/site-config/campaigns/reorder`,后端应返回 `400``405`,不要创建任何排序数据。`routeSections` 支持排序,但 `itemIds` 必须刚好包含当前已保存的子分组 ID。
响应状态码 `200`
```json
{
"items": [
{ "id": "slide_002", "title": "第二张", "sortOrder": 0, "isActive": true },
{ "id": "slide_001", "title": "第一张", "sortOrder": 1, "isActive": true }
]
}
```
如果后端使用 Express/Fastify 等路由,`/:module/reorder` 需要注册在 `/:module/:id` 之前,避免 `reorder` 被当成 id。
## 状态码
| 状态码 | 场景 |
| ------ | ------------------------------------------------ |
| `200` | 查询、更新、删除、排序成功 |
| `201` | 创建成功 |
| `400` | module 非法、请求体格式错误、排序 id 不完整 |
| `401` | 未登录或 token 无效 |
| `403` | 无权限维护页面配置 |
| `404` | 配置项不存在 |
| `409` | 删除被发布版本、商品或活动引用的配置项时发生冲突 |
| `422` | 字段校验失败,例如必填标题为空 |
| `500` | 服务端异常 |
## 前端联调入口
WonderQ-Admin-UI 当前调用函数位于 `src/api.ts`
- `getSiteConfig()`
- `createSiteConfigItem(module, input)`
- `updateSiteConfigItem(module, id, input)`
- `deleteSiteConfigItem(module, id)`
- `reorderSiteConfigItems(module, itemIds)`
维护地图 UI 位于 `src/pages/structure/StructurePage.tsx`
## 图片素材上传
用于 WonderQ-Admin-UI 在维护页面模块图片时上传本地图片,并把返回的 OSS URL 写入对应模块图片字段,例如 `image``coverImage`
```http
POST /api/admin/media-assets/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
```
表单字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `file` | `File` | 是 | 图片文件,仅支持 JPG、PNG、WebP、GIF |
| `group` | `string` | 否 | 素材分组,只能包含字母、数字、下划线和中划线;默认 `general` |
限制:
- 单个文件最大 5MB。
- 后端会校验 `Content-Type` 和文件头魔数,拒绝 SVG、非图片文件和伪装类型。
- 后端将文件上传到 OSS返回可用于前台展示的 URL并写入 `MediaAsset` 素材记录。
响应状态码 `201`
```json
{
"id": "asset_001",
"url": "https://bucket.oss-cn-example.aliyuncs.com/admin/heroSlides/2026/07/01/example.png",
"name": "hero.png",
"mimeType": "image/png",
"sizeBytes": 102400,
"group": "heroSlides",
"createdAt": "2026-07-01T08:00:00",
"updatedAt": "2026-07-01T08:00:00"
}
```
常见错误:
| 状态码 | `code` | 场景 |
| --- | --- | --- |
| `400` | `MEDIA_UPLOAD_INVALID_TYPE` | 文件不是允许的图片类型 |
| `400` | `MEDIA_UPLOAD_TYPE_MISMATCH` | `Content-Type` 与文件头不一致 |
| `400` | `MEDIA_UPLOAD_INVALID_GROUP` | `group` 格式非法 |
| `413` | `MEDIA_UPLOAD_TOO_LARGE` | 文件超过 5MB |
| `503` | `MEDIA_STORAGE_NOT_CONFIGURED` | OSS 环境配置不完整 |
| `502` | `MEDIA_STORAGE_UPLOAD_FAILED` | OSS 上传失败 |
前端封装位于 `src/api.ts`
```ts
uploadMediaAsset(file: File, group?: string): Promise<MediaAsset>
```
`src/components/admin/SingleImageUploader.tsx` 已使用该封装;结构维护页会把当前模块 id 作为 `group` 上传。