# 页面模块配置 Admin API 契约 本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠和底部运营入口。 ## 适用模块 当前一期只纳入结构化页面配置模块: | module | 名称 | 用途 | | -------------- | ------------ | ---------------------------------------------- | | `heroSlides` | 顶部轮播 | 首页首屏轮播图、标题短文案、展示排序和启用状态 | | `destinations` | 目的地 | 首页/目的地页展示、搜索入口和热门标记 | | `map` | 贵州地图 | 首页「探索贵州」区域内的地图图片素材 | | `themes` | 主题甄选 | 首页主题卡片和跳转 | | `campaigns` | 特价优惠 | 首页特价优惠活动元信息 | | `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA | 商品池、活动商品池和线索跟进继续走独立业务接口,不混入本契约。 ## 通用约定 - 所有接口前缀为 `/api/admin`。 - 所有接口需要校验 `Authorization: Bearer `。 - 请求和响应均为 `application/json`。 - 字段使用 camelCase。 - `PATCH` 为部分更新,只修改请求体中出现的字段。 - 删除接口返回 JSON,不能返回空 body,因为当前前端请求封装会读取 JSON。 错误响应保持当前前端兼容格式: ```json { "message": "模块不存在或无权限操作", "code": "MODULE_CONFIG_FORBIDDEN", "details": {} } ``` ## 后端实现重点 WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map` 和 `campaigns` 作为正式模块接入,而不是只在前端展示: - 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`ctaBanners`。 - 权限校验、模块路由、服务层分发和数据模型映射都必须识别 `map` 和 `campaigns`,否则前端会收到 `MODULE_CONFIG_FORBIDDEN` 并以 toast 展示失败原因。 - `GET /api/admin/site-config` 即使没有地图数据,也必须返回 `map: []`,不要省略 `map` 字段。 - `GET /api/admin/site-config` 即使没有特价优惠数据,也必须返回 `campaigns: []`,不要省略 `campaigns` 字段。 - `map` 只维护一张图片,只需要支持查询、创建、更新、删除,不需要排序接口。 - `campaigns` 维护活动元信息,只需要支持查询、创建、更新、删除,不需要排序接口。 - 图片上传仍走 `POST /api/admin/media-assets/upload`,模块保存接口只接收上传结果里的 OSS `url` 字段并写入 `image`。 ## 类型定义 ```ts type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "ctaBanners"; type SiteItemPatch = { title?: string; 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; 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; type MapImage = { id: string; image: string | null; isActive: boolean; createdAt?: string; updatedAt?: string; }; type MapImageCreateInput = { image: string; isActive?: boolean; }; type MapImageUpdateInput = Partial; 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; ``` 各模块字段要求: | 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` | | `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 }`。 ## 接口列表 以下路径由六类页面模块复用;`heroSlides`、`map`、`campaigns` 的请求体和响应体以各自专用契约为准。 ### 获取完整站点配置 ```http GET /api/admin/site-config ``` 响应: ```ts type SiteConfig = { heroSlides: HeroSlide[]; destinations: Destination[]; map: MapImage[]; themes: ThemeCard[]; campaigns: Campaign[]; ctaBanners: CtaBanner[]; }; ``` `map` 字段必须稳定返回数组;无数据时返回空数组 `[]`。 `campaigns` 字段必须稳定返回数组;无数据时返回空数组 `[]`。 ### 新增模块配置项 ```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`,不要创建任何排序数据。 响应状态码 `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 ``` 表单字段: | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `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 ``` `src/components/admin/SingleImageUploader.tsx` 已使用该封装;结构维护页会把当前模块 id 作为 `group` 上传。