diff --git a/.gitignore b/.gitignore index 2b3446a..3d379e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules/ dist/ -docs/ apps/*/dist/ .env .env.local diff --git a/AGENTS.md b/AGENTS.md index 121f5d8..0514fe5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,7 +268,7 @@ yarn dev:mp-weixin - TypeScript 项目使用 strict 模式,新增代码应有明确类型,避免 `any`。 - `WonderQ-MiniAPP` 使用 Vue 3 SFC、` diff --git a/WonderQ-MiniAPP/src/pages/detail/components/DetailHero.vue b/WonderQ-MiniAPP/src/pages/detail/components/DetailHero.vue new file mode 100644 index 0000000..e9df33a --- /dev/null +++ b/WonderQ-MiniAPP/src/pages/detail/components/DetailHero.vue @@ -0,0 +1,30 @@ + + + diff --git a/WonderQ-MiniAPP/src/pages/detail/components/DetailInfoCard.vue b/WonderQ-MiniAPP/src/pages/detail/components/DetailInfoCard.vue new file mode 100644 index 0000000..ebea96e --- /dev/null +++ b/WonderQ-MiniAPP/src/pages/detail/components/DetailInfoCard.vue @@ -0,0 +1,56 @@ + + + diff --git a/WonderQ-MiniAPP/src/pages/detail/components/DetailMediaGallery.vue b/WonderQ-MiniAPP/src/pages/detail/components/DetailMediaGallery.vue new file mode 100644 index 0000000..ca62ce3 --- /dev/null +++ b/WonderQ-MiniAPP/src/pages/detail/components/DetailMediaGallery.vue @@ -0,0 +1,16 @@ + + + diff --git a/WonderQ-MiniAPP/src/pages/detail/components/DetailOverviewCard.vue b/WonderQ-MiniAPP/src/pages/detail/components/DetailOverviewCard.vue new file mode 100644 index 0000000..70b20b5 --- /dev/null +++ b/WonderQ-MiniAPP/src/pages/detail/components/DetailOverviewCard.vue @@ -0,0 +1,49 @@ + + + diff --git a/WonderQ-MiniAPP/src/pages/detail/components/detailPresentation.ts b/WonderQ-MiniAPP/src/pages/detail/components/detailPresentation.ts new file mode 100644 index 0000000..11a3f6f --- /dev/null +++ b/WonderQ-MiniAPP/src/pages/detail/components/detailPresentation.ts @@ -0,0 +1,93 @@ +import { stripTitle } from "@/lib/data"; +import type { Product } from "@/lib/types"; + +export type DetailPresentation = { + eyebrow: string; + duration: string; + title: string; + subtitle: string; + intro: string; + highlights: string[]; + included: string[]; + excluded: string[]; + notes: string[]; + gallery: string[]; +}; + +const fallbackGallery = [ + "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg", + "https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg", + "https://p2.cri.cn/M00/89/21/rBABC2aHnWeACxj5AAAAAAAAAAA159.1000x566.jpg", + "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient", +]; + +function getDuration(title: string) { + return title.match(/\d+天\d+晚/)?.[0] ?? "5天4晚"; +} + +function getTextBlocks(product: Product) { + return (product.detailSections ?? []) + .flatMap((section) => section.blocks) + .filter( + (block): block is { type: "text"; text: string } => block.type === "text", + ) + .map((block) => block.text.trim()) + .filter(Boolean); +} + +export function createDetailPresentation(product: Product): DetailPresentation { + const title = stripTitle(product.title); + const combinedText = `${product.title} ${product.summary ?? ""}`; + const isCaveRoute = + combinedText.includes("洞") || combinedText.includes("探险"); + const detailTexts = getTextBlocks(product); + const apiImages = (product.images ?? []) + .slice() + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((image) => image.url.trim()) + .filter(Boolean); + + return { + eyebrow: product.tags[0] || "玩法推荐", + duration: getDuration(product.title), + title: isCaveRoute ? "地心探险大环线" : title, + subtitle: + product.subtitle || `${product.destinationName || "贵州"}·小包团路线`, + intro: + detailTexts[0] || + product.summary || + "沿着贵州山地的自然纹理深入探索,把核心景观、在地体验和轻户外节奏安排在一条线路里。", + highlights: isCaveRoute + ? [ + "深入喀斯特洞穴与地下河,安排专业向导陪同", + "小团出行,按同行人的体力和兴趣灵活调整", + "山野咖啡与自然景观穿插,留出松弛的停留时间", + "行程前由服务管家确认天气、装备和接送细节", + ] + : [ + "核心景观串联,减少无效往返和重复换乘", + "小团出行,按同行人的节奏灵活调整", + "在地体验与舒适住宿合理衔接", + "行程前由服务管家确认天气、装备和接送细节", + ], + included: [ + "行程内用车与接送服务", + "列明景点门票和体验项目", + "服务管家行前确认与途中跟进", + "行程内住宿及方案中注明的服务", + ], + excluded: [ + "往返大交通及个人消费", + "未列明餐食和自选体验", + "因个人原因产生的额外费用", + ], + notes: [ + "贵州多山多雨,请准备防滑鞋、轻便雨具和薄外套。", + "溶洞、漂流、徒步等体验会根据天气和同行人体力适当调整。", + "页面价格为参考起价,最终方案以出行日期、人数和资源确认结果为准。", + ], + gallery: Array.from( + new Set([product.image, ...apiImages, ...fallbackGallery]), + ).slice(0, 6), + }; +} diff --git a/WonderQ-MiniAPP/src/pages/detail/index.vue b/WonderQ-MiniAPP/src/pages/detail/index.vue new file mode 100644 index 0000000..92279d7 --- /dev/null +++ b/WonderQ-MiniAPP/src/pages/detail/index.vue @@ -0,0 +1,111 @@ + + + diff --git a/docs/README.md b/docs/README.md index cdaabb6..01bab8f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,15 +9,21 @@ 1. `backend-api-service.md` 2. `backend-plan.md` 3. `admin-api-requirements.md` -4. `module-config-api.md` -5. `public-api.md` +4. `wanfa-api.md` +5. `concierge-api.md` +6. `detail-api.md` +7. `module-config-api.md` +8. `public-api.md` 管理前端开发: 1. `integration-workflow.md` 2. `admin-api-requirements.md` -3. `module-config-api.md` -4. `development-status.md` +3. `wanfa-api.md` +4. `concierge-api.md` +5. `detail-api.md` +6. `module-config-api.md` +7. `development-status.md` MiniAPP 前台开发: @@ -36,12 +42,18 @@ MiniAPP 前台开发: | `backend-plan.md` | 当前 FastAPI 后端定位、业务模块、近期优先级和安全部署原则 | 后端 | | `admin-api-requirements.md` | Admin UI 必需的 Admin API 主契约 | 后端、管理前端 | | `module-config-api.md` | 页面模块配置 CRUD 的唯一细节契约 | 后端、管理前端 | +| `wanfa-api.md` | 玩法分类和路线管理 API 的补充契约 | 后端、管理前端 | +| `concierge-api.md` | 管家顾问资料管理 API 的补充契约 | 后端、管理前端 | +| `detail-api.md` | 详情展示内容管理 API 的补充契约 | 后端、管理前端 | | `public-api.md` | MiniAPP 对接后端的 Public API 契约 | 后端、MiniAPP | ## 文档边界 - `public-api.md` 是 MiniAPP 对接后端的唯一 Public API 契约。 - `admin-api-requirements.md` 是 Admin UI 对接后端的主契约。 +- `wanfa-api.md` 是玩法分类和路线管理的 Admin API 补充契约。 +- `concierge-api.md` 是管家顾问资料管理的 Admin API 补充契约。 +- `detail-api.md` 是详情展示内容管理的 Admin API 补充契约。 - `module-config-api.md` 是页面模块 CRUD 细节的唯一权威文档。 - `integration-workflow.md` 只写联调流程,不重复接口字段。 - `development-status.md` 只记录当前状态,不替代测试结果。 @@ -51,4 +63,4 @@ MiniAPP 前台开发: - 文档示例不得写入真实 Token、JWT secret、客服链接、企业 ID、手机号或生产环境变量值。 - `.env`、`.env.local` 和生产配置不进入文档目录。 -- 涉及重置数据、发布、回滚、迁移或生产操作的文档,需要明确风险和验证方式。 \ No newline at end of file +- 涉及重置数据、发布、回滚、迁移或生产操作的文档,需要明确风险和验证方式。 diff --git a/docs/admin-api-requirements.md b/docs/admin-api-requirements.md index e09bb68..be6fc8e 100644 --- a/docs/admin-api-requirements.md +++ b/docs/admin-api-requirements.md @@ -2,6 +2,12 @@ 本文档描述 `WonderQ-Admin-UI` 当前使用的 Admin API。接口负责站点内容维护、素材、发布和需求线索管理。 +玩法分类和路线管理的字段、嵌套路由、排序与删除约束见 [wanfa-api.md](./wanfa-api.md)。该文档是本主契约的玩法领域补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI`。 + +管家顾问资料管理的字段、图片、排序与删除约束见 [concierge-api.md](./concierge-api.md)。该文档是本主契约的管家领域补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI`。 + +详情展示内容的字段、图片、排序与商品领域隔离约束见 [detail-api.md](./detail-api.md)。该文档是本主契约的详情领域补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI`。 + ## 通用约定 - API 前缀:`/api/admin`。 diff --git a/docs/concierge-api.md b/docs/concierge-api.md new file mode 100644 index 0000000..5754089 --- /dev/null +++ b/docs/concierge-api.md @@ -0,0 +1,301 @@ +# 管家管理 Admin API + +> 适用端:`WonderQ-Admin`、`WonderQ-Admin-UI`。 +> +> 状态:待实现契约。本文件参考 `WonderQ-MiniAPP/src/pages/concierge/components/conciergeTypes.ts` 定义管家顾问资料,以及管理端需要的查询、维护和排序接口。它不是 MiniAPP Public API 文档。 + +## 领域边界 + +管家管理只维护管家顾问卡片资料: + +- 头像、姓名和职位。 +- 服务详情条目,包括图标和说明文案。 +- 添加管家时展示的二维码。 +- 顾问启用状态和展示顺序。 + +本接口不负责: + +- 需求线索、客户、客服会话或登录。 +- 订单、预订、商品和商品图片关联。 +- 管家页面 Hero 文案和服务原则内容。 + +当前 `WonderQ-MiniAPP/src/pages/concierge/index.vue` 中的 Hero、原则和顾问数据仍是本地静态内容;本契约落地后,Admin UI 维护顾问数据,MiniAPP 再通过独立的 Public API 消费已发布内容。 + +## 接口清单 + +API 前缀为 `/api/admin`,除登录接口外均需要后台 JWT。 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/admin/concierge/advisors` | 获取全部管家顾问 | +| `POST` | `/api/admin/concierge/advisors` | 新增管家顾问 | +| `PATCH` | `/api/admin/concierge/advisors/{advisorId}` | 编辑管家顾问 | +| `DELETE` | `/api/admin/concierge/advisors/{advisorId}` | 删除管家顾问 | +| `PATCH` | `/api/admin/concierge/advisors/reorder` | 调整管家顾问展示顺序 | + +## 通用约定 + +- 请求和响应使用 JSON,字段使用 camelCase。 +- 所有管理接口需要 `Authorization: Bearer `。 +- `GET` 默认返回启用和停用的全部顾问,按 `sortOrder` 升序返回,供管理端完整维护。 +- 创建、编辑、删除和排序成功后写入审计日志,再提交事务。 +- 变更接口返回最新顾问对象;排序接口返回排序后的 `items`。 +- ID 由后端生成并作为非空字符串返回。 +- 空列表返回 `[]`,不能返回 `null` 或省略字段。 +- 失败响应沿用 Admin API 约定,包含 `message`、`code` 和可选的 `details`。 + +## 数据类型 + +`conciergeTypes.ts` 是 MiniAPP 渲染模型,当前没有 `id`、`isActive` 和 `sortOrder`。为了支持 Admin UI 编辑、删除和排序,Admin API 在渲染字段之外增加管理元数据;MiniAPP Public API 适配时可以移除这些管理字段。 + +```ts +type ConciergeDetail = { + icon: string; + label: string; +}; + +type ConciergeAdvisorContent = { + avatar: string; + name: string; + role: string; + details: ConciergeDetail[]; + qrImage: string; +}; + +type ConciergeAdvisorRecord = ConciergeAdvisorContent & { + id: string; + isActive: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; +}; + +type ConciergeAdvisorCreate = { + avatar: string; + name: string; + role: string; + details: ConciergeDetail[]; + qrImage: string; + isActive?: boolean; + sortOrder?: number; +}; + +type ConciergeAdvisorPatch = Partial; + +type ConciergeAdvisorListResponse = { + advisors: ConciergeAdvisorRecord[]; +}; + +type ConciergeReorderRequest = { + itemIds: string[]; +}; +``` + +## 字段约束 + +| 字段 | 类型 | 必填 | 约束和用途 | +| --- | --- | --- | --- | +| `id` | `string` | 响应必填 | 顾问稳定标识,由后端生成。Admin UI 不使用姓名作为编辑、删除或 React `key`。 | +| `avatar` | `string` | 是 | 顾问头像 URL,前台按圆形头像展示。 | +| `name` | `string` | 是 | 顾问姓名,去除首尾空白后不得为空。 | +| `role` | `string` | 是 | 顾问职位或英文职称,去除首尾空白后不得为空。 | +| `details` | `ConciergeDetail[]` | 是 | 服务详情列表,保留数组顺序;允许为空数组。 | +| `details[].icon` | `string` | 是 | `uni-icons` 使用的图标名称,例如 `calendar`、`navigate`。 | +| `details[].label` | `string` | 是 | 服务详情文案,去除首尾空白后不得为空。 | +| `qrImage` | `string` | 是 | 添加管家时展示的二维码图片 URL。接口只保存图片 URL,不保存二维码原始 payload。 | +| `isActive` | `boolean` | 响应必填 | 是否在已发布前台内容中展示,创建默认 `true`。 | +| `sortOrder` | `number` | 响应必填 | 非负整数,数值越小越靠前;创建时未传则追加到末尾。 | +| `createdAt` | `string` | 响应必填 | ISO 8601 创建时间。 | +| `updatedAt` | `string` | 响应必填 | ISO 8601 最后更新时间。 | + +头像和二维码应使用已上传素材的最终 URL。服务端应校验必填文本、URL 格式、`details` 数组结构和 `sortOrder` 非负整数;具体文本最大长度由后端 schema 统一定义,并同步到 Admin UI 表单校验。 + +## 接口详情 + +### 获取全部顾问 + +```http +GET /api/admin/concierge/advisors +Authorization: Bearer +``` + +成功响应: + +```json +{ + "advisors": [ + { + "id": "advisor-001", + "avatar": "https://example.test/assets/advisor-avatar.jpg", + "name": "示例顾问", + "role": "SENIOR TRAVEL ADVISOR", + "details": [ + { "icon": "calendar", "label": "服务经验:8年" }, + { "icon": "navigate", "label": "擅长领域:自然探索" } + ], + "qrImage": "https://example.test/assets/advisor-qr.png", + "isActive": true, + "sortOrder": 0, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ] +} +``` + +### 新增顾问 + +```http +POST /api/admin/concierge/advisors +Authorization: Bearer +Content-Type: application/json +``` + +请求: + +```json +{ + "avatar": "https://example.test/assets/advisor-avatar.jpg", + "name": "示例顾问", + "role": "SENIOR TRAVEL ADVISOR", + "details": [ + { "icon": "calendar", "label": "服务经验:8年" }, + { "icon": "navigate", "label": "擅长领域:自然探索" } + ], + "qrImage": "https://example.test/assets/advisor-qr.png", + "isActive": true +} +``` + +成功返回 `201` 和新建的 `ConciergeAdvisorRecord`。未传 `sortOrder` 时追加到当前最大顺序之后。 + +### 编辑顾问 + +```http +PATCH /api/admin/concierge/advisors/{advisorId} +Authorization: Bearer +Content-Type: application/json +``` + +请求体为 `ConciergeAdvisorPatch`,只更新提交的字段。例如只更新服务详情: + +```json +{ + "details": [ + { "icon": "calendar", "label": "服务经验:10年" }, + { "icon": "navigate", "label": "擅长领域:亲子与自然探索" } + ] +} +``` + +成功返回更新后的 `ConciergeAdvisorRecord`。不存在的顾问返回 `404 CONCIERGE_ADVISOR_NOT_FOUND`。 + +### 删除顾问 + +```http +DELETE /api/admin/concierge/advisors/{advisorId} +Authorization: Bearer +``` + +删除成功返回: + +```json +{ "id": "advisor-001" } +``` + +删除后应重新规范化剩余顾问的 `sortOrder`,从 `0` 开始连续编号。若业务要求至少保留一名启用顾问,服务端在删除最后一名启用顾问时返回 `409 CONCIERGE_LAST_ACTIVE_ADVISOR`,否则允许删除并由前台处理空状态。 + +### 调整顾问顺序 + +```http +PATCH /api/admin/concierge/advisors/reorder +Authorization: Bearer +Content-Type: application/json +``` + +请求必须完整包含当前全部顾问 ID,不能重复: + +```json +{ "itemIds": ["advisor-002", "advisor-001"] } +``` + +成功响应: + +```json +{ "items": [] } +``` + +其中 `items` 为更新 `sortOrder` 后的 `ConciergeAdvisorRecord[]`。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400 CONCIERGE_REORDER_INVALID`。 + +## 图片与素材 + +Admin UI 使用现有素材上传接口获取图片 URL: + +```http +POST /api/admin/media-assets/upload +``` + +建议管家页面上传时使用 `group=concierge`,头像和二维码分别将返回的 `url` 写入 `avatar` 和 `qrImage`。API 不接受 base64 图片,也不在管家表中复制图片二进制内容。 + +二维码必须作为图片 URL 保存,不能把真实个人微信号、手机号或二维码 payload 写入文档、前端类型或接口日志。 + +## Admin UI 对接要求 + +Admin UI 应按以下方式调用: + +1. 进入管家页面时调用 `GET /api/admin/concierge/advisors`,按 `sortOrder` 渲染全部顾问。 +2. 列表展示头像、姓名、职位、详情数量、启用状态和编辑/删除操作。 +3. 新增和编辑表单维护头像、姓名、职位、详情列表、二维码和启用状态。 +4. `details` 使用可增删的重复字段编辑器,提交时保留用户排列顺序,不把多个详情拼成一个字符串。 +5. 上移或下移顾问时提交完整顾问 ID 列表,不直接修改本地 `sortOrder` 后假设保存成功。 +6. 删除前要求二次确认;处理最后一名启用顾问的 `409` 提示。 +7. 处理 `401`、`404`、`409`、`422` 和 `5xx`,保存或排序请求进行中禁用重复提交。 +8. 图片上传失败时不得提交旧草稿中的空 URL;表单应保留其他已填写字段,允许用户重试上传。 + +建议的 Admin UI API 封装函数: + +```ts +getConciergeAdvisors(); +createConciergeAdvisor(input: ConciergeAdvisorCreate); +updateConciergeAdvisor(advisorId: string, input: ConciergeAdvisorPatch); +deleteConciergeAdvisor(advisorId: string); +reorderConciergeAdvisors(itemIds: string[]); +``` + +## 与当前 MiniAPP 类型的映射 + +当前 `conciergeTypes.ts` 的 `ConciergeAdvisor` 仅包含前台渲染字段: + +```ts +type ConciergeAdvisor = { + avatar: string; + name: string; + role: string; + details: Array<{ icon: string; label: string }>; + qrImage: string; +}; +``` + +Admin API 返回的 `ConciergeAdvisorRecord` 可以通过以下方式映射为前台模型: + +```ts +const advisor: ConciergeAdvisor = { + avatar: record.avatar, + name: record.name, + role: record.role, + details: record.details, + qrImage: record.qrImage, +}; +``` + +只有 `isActive === true` 的记录进入已发布 Public 内容;`id`、`sortOrder`、`createdAt` 和 `updatedAt` 属于管理元数据,不应要求前台组件展示。 + +## 后端落地边界 + +本契约落地时需要由 `WonderQ-Admin` 补充对应 ORM 模型、迁移、schema、路由、序列化和审计日志;由 `WonderQ-Admin-UI` 补充 API 类型、请求封装、管家列表、表单和排序交互。当前后端没有 `/api/admin/concierge/*` 路由,Admin UI 的管家入口仍是空态,MiniAPP 顾问数据仍在 `index.vue` 中静态定义。 + +相关文档: + +- [Admin API 主契约](./admin-api-requirements.md) +- [玩法管理 Admin API](./wanfa-api.md) +- [管家数据类型](../WonderQ-MiniAPP/src/pages/concierge/components/conciergeTypes.ts) diff --git a/docs/detail-api.md b/docs/detail-api.md new file mode 100644 index 0000000..b78edb2 --- /dev/null +++ b/docs/detail-api.md @@ -0,0 +1,294 @@ +# 详情展示管理 Admin API + +> 适用端:`WonderQ-Admin`、`WonderQ-Admin-UI`。 +> +> 状态:待实现契约。本文件参考 `WonderQ-MiniAPP/src/pages/detail/components/detailPresentation.ts` 定义详情页展示数据。当前 `detailPresentation.ts` 仍依赖已移除的 `Product` 类型,后端也没有详情管理接口;本契约采用与商品领域解耦的详情展示模型,不恢复 Product、ProductImage 或订单关联。 + +## 领域边界 + +详情管理只维护详情页可编辑的展示内容: + +- 详情页识别键、标题眉标、出行时长和标题文案。 +- 详情介绍、行程亮点、费用包含、费用不含和注意事项。 +- 详情页图片画廊及图片顺序。 +- 启用状态和详情列表顺序。 + +本接口不负责: + +- 商品、商品价格、商品库存或商品详情表。 +- Product、ProductImage 或任何商品外键。 +- 订单、预订、收藏、评价或线索。 +- 详情页底部的电话、管家联系和预订动作。 + +`key` 是详情展示内容自己的稳定业务键,不得设计为 Product ID 外键。详情页如何从玩法、页面入口或其他前台上下文定位 `key`,由前台导航契约另行约定。 + +## 与 `detailPresentation.ts` 的关系 + +`detailPresentation.ts` 当前是前台展示适配器,不是持久化模型: + +- `eyebrow`、`duration`、`title`、`subtitle`、`intro`、`highlights`、`included`、`excluded`、`notes`、`gallery` 组成最终展示对象。 +- 当前实现从 `Product` 的标题、摘要、标签、详情区块和图片数组推导部分字段。 +- 当前实现对洞穴/探险路线生成另一组固定亮点,并使用固定费用说明和注意事项。 +- 当前实现会将主图、接口图片和 fallback 图片去重后截取前 6 张。 + +新的 Admin API 应直接维护最终展示字段;Admin UI 不应复刻这些推导逻辑,也不应依赖已移除的 Product 字段。 + +## 接口清单 + +API 前缀为 `/api/admin`,除登录接口外均需要后台 JWT。 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/admin/details` | 获取全部详情展示配置 | +| `GET` | `/api/admin/details/{detailId}` | 获取单个详情展示配置 | +| `POST` | `/api/admin/details` | 新增详情展示配置 | +| `PATCH` | `/api/admin/details/{detailId}` | 编辑详情展示配置 | +| `DELETE` | `/api/admin/details/{detailId}` | 删除详情展示配置 | +| `PATCH` | `/api/admin/details/reorder` | 调整详情展示配置顺序 | + +## 通用约定 + +- 请求和响应使用 JSON,字段使用 camelCase。 +- 所有管理接口需要 `Authorization: Bearer `。 +- `GET /details` 返回启用和停用的全部配置,按 `sortOrder` 升序返回。 +- 创建、编辑、删除和排序成功后写入审计日志,再提交事务。 +- 创建和编辑返回最新详情对象;排序接口返回排序后的 `items`。 +- ID 由后端生成并作为非空字符串返回;`key` 由调用方提供并保持稳定。 +- 空列表返回 `[]`,不能返回 `null` 或省略字段。 +- 失败响应沿用 Admin API 约定,包含 `message`、`code` 和可选的 `details`。 + +## 数据类型 + +以下内容字段与 `DetailPresentation` 保持兼容,管理端补充 `id`、`key`、状态和审计时间: + +```ts +type DetailPresentation = { + eyebrow: string; + duration: string; + title: string; + subtitle: string; + intro: string; + highlights: string[]; + included: string[]; + excluded: string[]; + notes: string[]; + gallery: string[]; +}; + +type DetailRecord = DetailPresentation & { + id: string; + key: string; + isActive: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; +}; + +type DetailCreate = DetailPresentation & { + key: string; + isActive?: boolean; + sortOrder?: number; +}; + +type DetailPatch = Partial; + +type DetailListResponse = { + details: DetailRecord[]; +}; + +type DetailReorderRequest = { + itemIds: string[]; +}; +``` + +## 字段约束 + +| 字段 | 类型 | 必填 | 约束和用途 | +| --- | --- | --- | --- | +| `id` | `string` | 响应必填 | 后端生成的记录 ID,仅供管理端识别记录。 | +| `key` | `string` | 是 | 详情展示稳定键;建议使用小写字母、数字和中划线,例如 `classic-panorama`。不得关联 Product 表。 | +| `eyebrow` | `string` | 是 | 详情页顶部眉标,例如“玩法推荐”。 | +| `duration` | `string` | 是 | 展示用时长,例如“5天4晚”;接口保存最终文案,不要求前端从标题正则提取。 | +| `title` | `string` | 是 | 详情页主标题。 | +| `subtitle` | `string` | 是 | 详情页副标题或目的地说明。 | +| `intro` | `string` | 是 | “详细介绍”区域的主介绍文案。 | +| `highlights` | `string[]` | 是 | “行程亮点”列表,保留数组顺序。 | +| `included` | `string[]` | 是 | “费用包含”列表,保留数组顺序。 | +| `excluded` | `string[]` | 是 | “费用不含”列表,保留数组顺序。 | +| `notes` | `string[]` | 是 | “注意事项”列表,保留数组顺序。 | +| `gallery` | `string[]` | 是 | 详情图片 URL 列表,按展示顺序返回;建议最多 6 张以匹配当前前台逻辑。 | +| `isActive` | `boolean` | 响应必填 | 是否进入已发布前台内容,创建默认 `true`。 | +| `sortOrder` | `number` | 响应必填 | 非负整数,数值越小越靠前;创建时未传则追加到末尾。 | +| `createdAt` | `string` | 响应必填 | ISO 8601 创建时间。 | +| `updatedAt` | `string` | 响应必填 | ISO 8601 最后更新时间。 | + +服务端应校验 `key` 唯一、文本字段去除首尾空白后不为空、数组元素为非空字符串、`gallery` 为 URL 列表、`sortOrder` 为非负整数。具体文本最大长度由后端 schema 统一定义,并同步到 Admin UI 表单校验。 + +## 接口详情 + +### 获取详情列表 + +```http +GET /api/admin/details +Authorization: Bearer +``` + +成功响应: + +```json +{ + "details": [ + { + "id": "detail-001", + "key": "classic-panorama", + "eyebrow": "玩法推荐", + "duration": "5天4晚", + "title": "经典贵州全景", + "subtitle": "贵州·瀑布、苗寨、古城与山地风光", + "intro": "沿着贵州山地的自然纹理深入探索。", + "highlights": ["核心景观串联", "小团出行,按同行人节奏调整"], + "included": ["行程内用车与接送服务"], + "excluded": ["往返大交通及个人消费"], + "notes": ["贵州多山多雨,请准备防滑鞋和轻便雨具。"], + "gallery": ["https://example.test/assets/detail-01.jpg"], + "isActive": true, + "sortOrder": 0, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ] +} +``` + +### 获取单个详情 + +```http +GET /api/admin/details/{detailId} +Authorization: Bearer +``` + +成功返回单个 `DetailRecord`。记录不存在返回 `404 DETAIL_NOT_FOUND`。 + +### 新增详情 + +```http +POST /api/admin/details +Authorization: Bearer +Content-Type: application/json +``` + +请求体为 `DetailCreate`。成功返回 `201` 和新建的 `DetailRecord`;未传 `sortOrder` 时追加到当前列表末尾。 + +### 编辑详情 + +```http +PATCH /api/admin/details/{detailId} +Authorization: Bearer +Content-Type: application/json +``` + +请求体为 `DetailPatch`,只更新提交的字段。修改 `key` 时仍须保证全局唯一。成功返回更新后的 `DetailRecord`;记录不存在返回 `404 DETAIL_NOT_FOUND`,`key` 冲突返回 `409 DETAIL_KEY_EXISTS`。 + +### 删除详情 + +```http +DELETE /api/admin/details/{detailId} +Authorization: Bearer +``` + +成功返回: + +```json +{ "id": "detail-001" } +``` + +删除后应重新规范化剩余记录的 `sortOrder`,从 `0` 开始连续编号。详情记录没有 Product、订单或线索外键,因此删除不触发跨领域级联操作。 + +### 调整详情顺序 + +```http +PATCH /api/admin/details/reorder +Authorization: Bearer +Content-Type: application/json +``` + +请求必须完整包含当前全部详情 ID,不能重复: + +```json +{ "itemIds": ["detail-002", "detail-001"] } +``` + +成功返回: + +```json +{ "items": [] } +``` + +其中 `items` 为更新 `sortOrder` 后的 `DetailRecord[]`。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400 DETAIL_REORDER_INVALID`。 + +## 图片与素材 + +Admin UI 使用现有素材上传接口获取图片 URL: + +```http +POST /api/admin/media-assets/upload +``` + +建议详情页图片使用 `group=detail`,将返回的 `url` 按用户排列顺序写入 `gallery`。接口只保存图片 URL,不创建 ProductImage 表或商品图片关联。 + +当前 `detailPresentation.ts` 的 fallback 图片属于前台兜底逻辑。Admin API 正式接入后,Admin UI 不应把 fallback 图片自动写入数据库;应由运营人员明确上传和排序详情图片。 + +## Admin UI 对接要求 + +Admin UI 应按以下方式调用: + +1. 进入详情管理页时调用 `GET /api/admin/details`,按 `sortOrder` 渲染详情列表。 +2. 新增和编辑表单维护 `key`、眉标、时长、标题、副标题、介绍和四组列表文案。 +3. `highlights`、`included`、`excluded`、`notes` 使用可增删的重复字段编辑器,提交时保留数组顺序,不拼接成换行字符串。 +4. 使用图片上传接口维护 `gallery`,支持新增、删除和调整图片顺序。 +5. 上移或下移详情时提交完整详情 ID 列表,不直接修改本地 `sortOrder` 后假设保存成功。 +6. 删除前要求二次确认;删除成功后以接口返回或重新查询的数据更新列表。 +7. 处理 `401`、`404`、`409`、`422` 和 `5xx`,保存、上传或排序进行中禁用重复提交。 +8. 详情编辑器不得出现 Product ID、ProductImage ID、库存、订单或预订字段。 + +建议的 Admin UI API 封装函数: + +```ts +getDetails(); +getDetail(detailId: string); +createDetail(input: DetailCreate); +updateDetail(detailId: string, input: DetailPatch); +deleteDetail(detailId: string); +reorderDetails(itemIds: string[]); +``` + +## 与前台展示模型的映射 + +Admin API 返回的 `DetailRecord` 可以映射为 `DetailPresentation`: + +```ts +const presentation: DetailPresentation = { + eyebrow: record.eyebrow, + duration: record.duration, + title: record.title, + subtitle: record.subtitle, + intro: record.intro, + highlights: record.highlights, + included: record.included, + excluded: record.excluded, + notes: record.notes, + gallery: record.gallery, +}; +``` + +接入时应优先使用接口已保存的最终文案和图片顺序,不再依赖 `stripTitle`、标题时长正则、洞穴路线分支或 fallbackGallery 生成同一字段。 + +## 后端落地边界 + +本契约落地时需要由 `WonderQ-Admin` 补充对应 ORM 模型、迁移、schema、路由、序列化和审计日志;由 `WonderQ-Admin-UI` 补充 API 类型、请求封装、详情列表、编辑器、图片管理和排序交互。当前后端没有 `/api/admin/details` 路由,Admin UI 没有详情管理入口,`detailPresentation.ts` 也不是可直接作为后端契约的完整类型来源。 + +相关文档: + +- [Admin API 主契约](./admin-api-requirements.md) +- [管家管理 Admin API](./concierge-api.md) +- [详情展示适配器](../WonderQ-MiniAPP/src/pages/detail/components/detailPresentation.ts) diff --git a/docs/guanjia-api.md b/docs/guanjia-api.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/home-api.md b/docs/home-api.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/wanfa-api.md b/docs/wanfa-api.md new file mode 100644 index 0000000..1fd56e8 --- /dev/null +++ b/docs/wanfa-api.md @@ -0,0 +1,325 @@ +# 玩法管理 Admin API + +> 适用端:`WonderQ-Admin`、`WonderQ-Admin-UI`。 +> +> 状态:待实现契约。本文件参考 `WonderQ-MiniAPP/src/pages/play/components/playData.ts` 定义玩法分类和路线的数据结构,以及管理端需要的查询、维护和排序接口。它不是 MiniAPP Public API 文档。 + +## 领域边界 + +玩法管理只维护玩法分类和路线卡片内容: + +- 分类名称和展示顺序。 +- 路线标题、副标题、封面、路线数量和需求关键词。 +- 分类与路线的新增、编辑、删除和排序。 + +玩法领域不负责: + +- 商品、商品详情、预订或订单。 +- 目的地实体和目的地维护。 +- 线索创建或线索跟进。 +- Product、ProductImage 或其他已移除商品关联表。 + +`routeCount` 只是前台卡片展示数量,不是 Product 表的外键或实时关联统计。 + +## 接口清单 + +API 前缀为 `/api/admin`,除登录接口外均需要后台 JWT。 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/admin/wanfa/categories` | 获取全部玩法分类及其路线 | +| `POST` | `/api/admin/wanfa/categories` | 新增玩法分类 | +| `PATCH` | `/api/admin/wanfa/categories/{categoryId}` | 编辑玩法分类 | +| `DELETE` | `/api/admin/wanfa/categories/{categoryId}` | 删除玩法分类 | +| `PATCH` | `/api/admin/wanfa/categories/reorder` | 调整玩法分类顺序 | +| `POST` | `/api/admin/wanfa/categories/{categoryId}/routes` | 新增分类路线 | +| `PATCH` | `/api/admin/wanfa/categories/{categoryId}/routes/{routeId}` | 编辑分类路线 | +| `DELETE` | `/api/admin/wanfa/categories/{categoryId}/routes/{routeId}` | 删除分类路线 | +| `PATCH` | `/api/admin/wanfa/categories/{categoryId}/routes/reorder` | 调整分类内路线顺序 | + +## 通用约定 + +- 请求和响应使用 JSON,字段使用 camelCase。 +- 所有管理接口需要 `Authorization: Bearer `。 +- 创建、编辑、删除和排序成功后写入审计日志,再提交事务。 +- 变更接口返回最新变更对象;排序接口返回排序后的 `items`。 +- ID 由后端生成并作为非空字符串返回。初始化迁移时应优先保留 `playData.ts` 中已有的稳定 ID。 +- 分类和路线的数组顺序就是管理端和前台的展示顺序,接口不要求前端依赖 `sortOrder` 字段。 +- 空集合返回 `[]`,不能返回 `null` 或省略字段。 +- 失败响应沿用 Admin API 约定,包含 `message`、`code` 和可选的 `details`。 + +## 数据类型 + +以下类型与 `playData.ts` 保持字段兼容。管理端接口不应向前台模型强制增加商品 ID、预订 ID 或数据库关联字段。 + +```ts +type WanfaConfig = { + categories: WanfaCategory[]; +}; + +type WanfaCategory = { + id: string; + label: string; + routes: WanfaRoute[]; +}; + +type WanfaRoute = { + id: string; + title: string; + subtitle: string; + image: string; + routeCount: number; + demandKeyword: string; +}; + +type WanfaCategoryCreate = { + label: string; +}; + +type WanfaCategoryPatch = { + label?: string; +}; + +type WanfaRouteCreate = { + title: string; + subtitle: string; + image: string; + routeCount: number; + demandKeyword: string; +}; + +type WanfaRoutePatch = Partial; + +type WanfaReorderRequest = { + itemIds: string[]; +}; +``` + +## 字段约束 + +| 字段 | 类型 | 必填 | 约束和用途 | +| --- | --- | --- | --- | +| `category.id` | `string` | 响应必填 | 分类稳定标识,例如 `family-route`。创建时由后端生成。 | +| `category.label` | `string` | 是 | 左侧分类显示名称,去除首尾空白后不得为空。 | +| `category.routes` | `WanfaRoute[]` | 响应必填 | 当前分类下的路线,按展示顺序返回。 | +| `route.id` | `string` | 响应必填 | 路线稳定标识,例如 `family-water`。创建时由后端生成。 | +| `route.title` | `string` | 是 | 路线卡片标题,去除首尾空白后不得为空。 | +| `route.subtitle` | `string` | 是 | 路线卡片副标题或目的地说明。 | +| `route.image` | `string` | 是 | 可直接用于图片组件的封面 URL。 | +| `route.routeCount` | `number` | 是 | 卡片右下角展示的路线数量,服务端应校验为不小于 0 的整数。 | +| `route.demandKeyword` | `string` | 是 | 跳转需求页时可使用的预填关键词,去除首尾空白后不得为空。 | + +字段最大长度应由后端 schema 统一定义,并同步到 Admin UI 表单校验;在未形成统一长度常量前,前端不能通过截断文本代替服务端校验。 + +## 接口详情 + +### 获取分类及路线 + +```http +GET /api/admin/wanfa/categories +Authorization: Bearer +``` + +成功响应: + +```json +{ + "categories": [ + { + "id": "family-route", + "label": "亲子路线", + "routes": [ + { + "id": "family-water", + "title": "亲子玩水", + "subtitle": "贵州·轻松节奏与自然课堂", + "image": "https://example.test/assets/family-water.jpg", + "routeCount": 4, + "demandKeyword": "亲子玩水" + } + ] + } + ] +} +``` + +### 新增分类 + +```http +POST /api/admin/wanfa/categories +Authorization: Bearer +Content-Type: application/json +``` + +请求: + +```json +{ "label": "亲子路线" } +``` + +成功返回 `201` 和新分类对象,初始 `routes` 为 `[]`,并追加到分类列表末尾。 + +### 编辑分类 + +```http +PATCH /api/admin/wanfa/categories/{categoryId} +Authorization: Bearer +Content-Type: application/json +``` + +请求只允许修改分类名称: + +```json +{ "label": "家庭路线" } +``` + +成功返回更新后的 `WanfaCategory`。不存在的分类返回 `404 WANFA_CATEGORY_NOT_FOUND`。 + +### 删除分类 + +```http +DELETE /api/admin/wanfa/categories/{categoryId} +Authorization: Bearer +``` + +为避免误删路线,分类仍包含路线时不得级联删除,应返回 `409 WANFA_CATEGORY_NOT_EMPTY`。删除前由 Admin UI 提示先移除分类内路线。删除成功返回: + +```json +{ "id": "family-route" } +``` + +### 分类排序 + +```http +PATCH /api/admin/wanfa/categories/reorder +Authorization: Bearer +Content-Type: application/json +``` + +请求必须完整包含当前全部分类 ID,不能重复: + +```json +{ "itemIds": ["photo-route", "family-route", "healing-route"] } +``` + +成功返回: + +```json +{ "items": [] } +``` + +其中 `items` 为排序后的 `WanfaCategory[]`。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400 WANFA_CATEGORY_REORDER_INVALID`。 + +### 新增路线 + +```http +POST /api/admin/wanfa/categories/{categoryId}/routes +Authorization: Bearer +Content-Type: application/json +``` + +请求: + +```json +{ + "title": "亲子玩水", + "subtitle": "贵州·轻松节奏与自然课堂", + "image": "https://example.test/assets/family-water.jpg", + "routeCount": 4, + "demandKeyword": "亲子玩水" +} +``` + +成功返回 `201` 和新建的 `WanfaRoute`,并追加到对应分类路线末尾。分类不存在返回 `404 WANFA_CATEGORY_NOT_FOUND`。 + +### 编辑路线 + +```http +PATCH /api/admin/wanfa/categories/{categoryId}/routes/{routeId} +Authorization: Bearer +Content-Type: application/json +``` + +请求体为 `WanfaRoutePatch`,只更新提交的字段。成功返回更新后的 `WanfaRoute`;分类或路线不存在时分别返回 `404 WANFA_CATEGORY_NOT_FOUND` 或 `404 WANFA_ROUTE_NOT_FOUND`。 + +### 删除路线 + +```http +DELETE /api/admin/wanfa/categories/{categoryId}/routes/{routeId} +Authorization: Bearer +``` + +成功返回 `{ "id": "..." }`。删除后,分类内路线保持原有相对顺序。 + +### 分类内路线排序 + +```http +PATCH /api/admin/wanfa/categories/{categoryId}/routes/reorder +Authorization: Bearer +Content-Type: application/json +``` + +请求必须完整包含该分类当前全部路线 ID,不能重复: + +```json +{ "itemIds": ["family-grassland", "family-water", "family-village"] } +``` + +成功返回排序后的路线: + +```json +{ "items": [] } +``` + +分类不存在返回 `404 WANFA_CATEGORY_NOT_FOUND`;路线 ID 不完整、重复或不属于该分类时返回 `400 WANFA_ROUTE_REORDER_INVALID`。 + +## 当前本地数据映射 + +迁移初始数据时,分类和路线应按以下 ID 与顺序导入: + +| 分类 ID | 分类名称 | 路线 ID(当前顺序) | +| --- | --- | --- | +| `family-route` | 亲子路线 | `family-water`、`family-village`、`family-grassland` | +| `photo-route` | 旅拍路线 | `miao-photo`、`peak-photo`、`terrace-photo` | +| `healing-route` | 疗愈路线 | `mountain-healing`、`hot-spring-healing`、`river-healing` | +| `team-building` | 团建 | `team-challenge`、`team-stream`、`team-culture` | +| `guizhou-panorama` | 贵州全景 | `classic-panorama`、`mountain-panorama`、`wild-panorama` | +| `private-custom` | 私人定制 | `private-family`、`private-business`、`private-wild` | + +图片别名的当前解析规则位于 `playData.ts` 的 `routeImageByAsset` 和 `resolveRouteImage`:已配置别名解析为完整远程 URL,完整 `http` URL 直接使用,其他值按 `/assets/guizhou/{image}.jpg` 解析。Admin API 建议保存最终 URL,Admin UI 通过现有媒体上传接口获取 URL 后再提交 `image`。 + +## Admin UI 对接要求 + +Admin UI 应按以下方式调用: + +1. 进入玩法管理页时调用 `GET /api/admin/wanfa/categories`,以返回数组顺序渲染分类和路线。 +2. 添加分类调用 `POST /categories`;编辑和删除分类分别调用对应 `PATCH`、`DELETE`。 +3. 添加、编辑和删除路线使用分类嵌套路由。 +4. 上移或下移分类时提交完整分类 ID 列表;上移或下移路线时提交完整路线 ID 列表。 +5. 每次变更成功后以接口返回数据更新本地状态;必要时重新请求列表,不直接拼接数据库字段。 +6. 删除非空分类前展示阻止性提示,不自动级联删除路线。 +7. 处理 `401`、`404`、`409`、`422` 和 `5xx`,并在保存中禁用重复提交。 + +建议的 Admin UI API 封装函数: + +```ts +getWanfaCategories(); +createWanfaCategory(input: WanfaCategoryCreate); +updateWanfaCategory(categoryId: string, input: WanfaCategoryPatch); +deleteWanfaCategory(categoryId: string); +reorderWanfaCategories(itemIds: string[]); +createWanfaRoute(categoryId: string, input: WanfaRouteCreate); +updateWanfaRoute(categoryId: string, routeId: string, input: WanfaRoutePatch); +deleteWanfaRoute(categoryId: string, routeId: string); +reorderWanfaRoutes(categoryId: string, itemIds: string[]); +``` + +## 后端落地边界 + +本契约落地时需要由 `WonderQ-Admin` 补充对应 ORM 模型、迁移、schema、路由、序列化和审计日志;由 `WonderQ-Admin-UI` 补充 API 类型、请求封装、表单和排序交互。接口实现前不要把 `playData.ts` 的静态数据误认为已存在的数据库或 API 数据。 + +相关文档: + +- [Admin API 主契约](./admin-api-requirements.md) +- [页面模块配置契约](./module-config-api.md) +- [玩法本地数据](../WonderQ-MiniAPP/src/pages/play/components/playData.ts)