详细变更如下: - 更新 .gitignore 文件,添加 pnpm-store 忽略规则 - 新增数据库迁移脚本,为 DetailRecord 添加可空的 conciergeAdvisorId 字段用于关联管家顾问 - 完善 Admin 后台玩法详情编辑器,支持选择关联的管家顾问并校验合法性 - 公共 API 支持返回已启用的管家顾问完整数据,不在详情表中冗余存储管家资料 - 小程序端新增详情页联系管家入口、个人页最近浏览历史功能 - 更新所有相关文档与测试用例,修复下拉选择框的 z-index 样式问题
14 KiB
详情展示管理 Admin API
适用端:
WonderQ-Admin、WonderQ-Admin-UI。状态:已实现契约。本文件定义独立路线详情展示模型,供
WonderQ-Admin、WonderQ-Admin-UI和WonderQ-MiniAPP三端联调使用。详情不恢复 Product、ProductImage 或订单关联。
领域边界
详情管理只维护详情页可编辑的展示内容:
- 详情页识别键、标题眉标、出行时长和标题文案。
- 详情介绍、行程亮点、费用包含、费用不含和注意事项。
- 详情页图片画廊及图片顺序。
- 详情页可选的联系管家顾问 ID;顾问资料仍由管家领域维护。
- 启用状态和详情列表顺序。
本接口不负责:
- 商品、商品价格、商品库存或商品详情表。
- Product、ProductImage 或任何商品外键。
- 订单、预订、收藏、评价或线索。
- 管家顾问的头像、二维码、服务详情和管家 CRUD;详情只保存顾问 ID。
key 固定使用玩法路线 ID(即服务端生成的 WanfaRoute.id UUID),但 DetailRecord 不建立数据库外键。详情页通过 /pages/detail/index?routeId={key} 定位内容;详情记录删除不会跨领域级联删除路线或其他数据。
所有 JSON 响应遵循 三端统一 API 响应契约,成功业务对象位于 data,失败时 data 为 null。
与 detailPresentation.ts 的关系
detailPresentation.ts 是前台展示适配器,不是持久化模型:
eyebrow、duration、title、subtitle、intro、highlights、included、excluded、notes、gallery组成最终展示对象。- 当前实现优先消费 Public API 返回的最终展示字段。
- 接口失败、字段不完整或详情未配置时,MiniAPP 按路线 ID 使用本地网络图片和模拟文案兜底。
新的 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 <admin-jwt>。 GET /details返回启用和停用的全部配置,按sortOrder升序返回。- 创建、编辑、删除和排序成功后写入审计日志,再提交事务。
- 创建和编辑返回最新详情对象;排序接口返回排序后的
items。 - ID 由后端生成并作为非空字符串返回;
key由调用方提供并保持稳定。 - 空列表返回
[],不能返回null或省略字段。 - 成功结果放入
data;失败返回数字code、msg、data: null,可选errorCode和details。
数据类型
以下内容字段与 DetailPresentation 保持兼容,管理端补充 id、key、状态和审计时间:
type DetailPresentation = {
key: string;
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;
conciergeAdvisorId: string | null;
isActive: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
};
type PublicDetail = Omit<DetailPresentation, "key"> & {
key: string;
conciergeAdvisor: PublicConciergeAdvisor | null;
};
type PublicConciergeAdvisor = {
avatar: string;
name: string;
role: string;
details: Array<{ icon: string; label: string }>;
qrImage: string;
};
type DetailCreate = DetailPresentation & {
key: string;
conciergeAdvisorId?: string | null;
isActive?: boolean;
sortOrder?: number;
};
type DetailPatch = Partial<DetailCreate>;
type DetailListResponse = {
details: DetailRecord[];
};
type DetailReorderRequest = {
itemIds: string[];
};
字段约束
| 字段 | 类型 | 必填 | 约束和用途 |
|---|---|---|---|
id |
string |
响应必填 | 后端生成的记录 ID,仅供管理端识别记录。 |
key |
string |
是 | 玩法路线 ID,例如 family-water;必须唯一,不建立数据库外键。 |
eyebrow |
string |
是 | 详情页顶部眉标,例如“玩法推荐”。 |
duration |
string |
是 | 展示用时长,例如“5天4晚”;接口保存最终文案,不要求前端从标题正则提取。 |
title |
string |
是 | 详情页主标题。 |
subtitle |
string |
是 | 详情页副标题或目的地说明。 |
intro |
string |
是 | “详细介绍”区域的主介绍文案。 |
highlights |
string[] |
是 | “行程亮点”列表,保留数组顺序。 |
included |
string[] |
是 | “费用包含”列表,保留数组顺序。 |
excluded |
string[] |
是 | “费用不含”列表,保留数组顺序。 |
notes |
string[] |
是 | “注意事项”列表,保留数组顺序。 |
gallery |
string[] |
是 | 详情图片 URL 列表,按展示顺序返回;建议最多 6 张以匹配当前前台逻辑。 |
conciergeAdvisorId |
string | null |
否 | 关联的管家顾问 ID;不建立数据库外键,空字符串保存为 null。 |
isActive |
boolean |
响应必填 | 是否进入已发布前台内容,创建默认 true。 |
sortOrder |
number |
响应必填 | 非负整数,数值越小越靠前;创建时未传则追加到末尾。 |
createdAt |
string |
响应必填 | ISO 8601 创建时间。 |
updatedAt |
string |
响应必填 | ISO 8601 最后更新时间。 |
服务端应校验 key 唯一、文本字段去除首尾空白后不为空、数组元素为非空字符串、gallery 为 URL 列表、sortOrder 为非负整数。具体文本最大长度由后端 schema 统一定义,并同步到 Admin UI 表单校验。
接口详情
获取详情列表
GET /api/admin/details
Authorization: Bearer <admin-jwt>
成功响应:
{
"code": 200,
"msg": "success",
"data": {
"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"
}
]
}
}
获取单个详情
GET /api/admin/details/{detailId}
Authorization: Bearer <admin-jwt>
成功返回 data 内的单个 DetailRecord。记录不存在返回 404,业务码为 DETAIL_NOT_FOUND。
新增详情
POST /api/admin/details
Authorization: Bearer <admin-jwt>
Content-Type: application/json
请求体为 DetailCreate。成功返回 201 和 data 内新建的 DetailRecord;未传 sortOrder 时追加到当前列表末尾。conciergeAdvisorId 传空字符串时归一化为 null,传入不存在的顾问 ID 返回 422 DETAIL_CONCIERGE_ADVISOR_INVALID。
编辑详情
PATCH /api/admin/details/{detailId}
Authorization: Bearer <admin-jwt>
Content-Type: application/json
请求体为 DetailPatch,只更新提交的字段。修改 key 时仍须保证全局唯一。成功返回 data 内更新后的 DetailRecord;记录不存在返回 404,业务码为 DETAIL_NOT_FOUND,key 冲突返回 409,业务码为 DETAIL_KEY_EXISTS。
删除详情
DELETE /api/admin/details/{detailId}
Authorization: Bearer <admin-jwt>
成功返回:
{
"code": 200,
"msg": "success",
"data": { "id": "detail-001" }
}
删除后应重新规范化剩余记录的 sortOrder,从 0 开始连续编号。详情记录没有 Product、订单或线索外键,因此删除不触发跨领域级联操作。
调整详情顺序
PATCH /api/admin/details/reorder
Authorization: Bearer <admin-jwt>
Content-Type: application/json
请求必须完整包含当前全部详情 ID,不能重复:
{ "itemIds": ["detail-002", "detail-001"] }
成功返回:
{
"code": 200,
"msg": "success",
"data": { "items": [] }
}
其中 items 为更新 sortOrder 后的 DetailRecord[]。缺少 ID、包含未知 ID 或出现重复 ID 时返回 400 DETAIL_REORDER_INVALID。
MiniAPP Public API
获取路线详情
GET /api/public/details/{key}
无需鉴权。key 为 WanfaRoute.id,例如 family-water。接口只返回启用且已配置的展示字段,不返回管理端 ID、状态、排序和审计时间:
type PublicDetail = {
key: string;
eyebrow: string;
duration: string;
title: string;
subtitle: string;
intro: string;
highlights: string[];
included: string[];
excluded: string[];
notes: string[];
gallery: string[];
conciergeAdvisor: PublicConciergeAdvisor | null;
};
不存在、停用或未配置详情时返回 404。详情未配置顾问、顾问不存在或顾问已停用时,详情仍正常返回,但 conciergeAdvisor 为 null。MiniAPP 请求失败或字段不完整时,按 key 查找本地 fallback;fallback 不伪造管家数据,找不到 fallback 时展示未找到和重试状态。详情页仅在返回有效顾问时展示“联系管家”入口。
图片与素材
Admin UI 使用现有素材上传接口获取图片 URL:
POST /api/admin/media-assets/upload
建议详情页图片使用 group=detail,将返回的 url 按用户排列顺序写入 gallery。接口只保存图片 URL,不创建 ProductImage 表或商品图片关联。
当前 detailPresentation.ts 的 fallback 图片属于前台兜底逻辑。Admin API 正式接入后,Admin UI 不应把 fallback 图片自动写入数据库;应由运营人员明确上传和排序详情图片。
Admin UI 对接要求
Admin UI 应按以下方式调用:
- 玩法页加载时同时调用玩法分类、
GET /api/admin/details和GET /api/admin/concierge/advisors;详情编辑器嵌入现有路线编辑抽屉,不新增侧边菜单。 - 新增和编辑表单维护眉标、时长、标题、副标题、介绍和四组列表文案;保存路线时以已保存的路线 ID 作为详情
key。 highlights、included、excluded、notes使用可增删的重复字段编辑器,提交时保留数组顺序,不拼接成换行字符串。- 使用图片上传接口维护
gallery,支持新增、删除和调整图片顺序。 - 上移或下移详情时提交完整详情 ID 列表,不直接修改本地
sortOrder后假设保存成功。 - 删除前要求二次确认;删除成功后以接口返回或重新查询的数据更新列表。
- 处理
401、404、409、422和5xx,保存、上传或排序进行中禁用重复提交。 - 详情编辑器不得出现 Product ID、ProductImage ID、库存、订单、价格或预订字段;联系管家使用顾问 ID 选择器;详情保存失败时明确提示路线摘要已保存、详情需要重试。
建议的 Admin UI API 封装函数:
getDetails();
getDetail(detailId: string);
createDetail(input: DetailCreate);
updateDetail(detailId: string, input: DetailPatch);
deleteDetail(detailId: string);
reorderDetails(itemIds: string[]);
与前台展示模型的映射
Public API 返回的 PublicDetail 可以直接映射为 MiniAPP 的 DetailPresentation;Admin API 返回的 DetailRecord 只包含 conciergeAdvisorId,不复制管家资料:
const presentation: DetailPresentation = {
key: record.key,
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,
conciergeAdvisor: publicDetail.conciergeAdvisor ?? null,
};
接入时应优先使用接口已保存的最终文案和图片顺序;本地 fallback 只用于接口失败、字段不完整或详情未配置的前台容错。
后端落地边界
当前实现由 WonderQ-Admin 的 DetailRecord 模型、0021_detail_records、0022_opaque_ids 和 0023_detail_concierge_advisor 迁移、Admin/Public 路由和审计日志提供能力;由 WonderQ-Admin-UI 在玩法路线编辑抽屉中维护详情及顾问 ID;由 WonderQ-MiniAPP 调用 Public 详情接口并按路线 ID fallback。迁移文件只负责生成和初始化详情记录,不自动执行数据库升级。顾问资料始终从管家领域实时读取,不复制到详情表。
相关文档: