feat(mini-app, docs): 新增小程序详情页,更新项目文档与规范

新增全套关联组件:DetailHero、DetailOverviewCard、DetailInfoCard、DetailMediaGallery、DetailActionBar及ConciergeContactSheet。
新增详情页数据处理工具类detailPresentation.ts,处理商品展示数据的格式化与默认值兼容。
更新项目文档:补充三个领域API补充文档引用,优化AGENTS.md中的前端代码规范与docs文档列表。
This commit is contained in:
duanshuwen
2026-08-17 23:28:07 +08:00
parent 548f91c37f
commit ca6f9397e0
17 changed files with 1351 additions and 7 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,5 @@
node_modules/
dist/
docs/
apps/*/dist/
.env
.env.local

View File

@@ -268,7 +268,7 @@ yarn dev:mp-weixin
- TypeScript 项目使用 strict 模式,新增代码应有明确类型,避免 `any`
- `WonderQ-MiniAPP` 使用 Vue 3 SFC、`<script setup lang="ts">` 和 uni-app 组件,不引入 React。
- `WonderQ-Admin-UI` 使用 React 函数组件和 Hooks不引入 class component。
- 样式优先使用项目已集成的 TailwindCSS管理端同时遵守 `src/styles.css` 的设计变量和已有类名体系
- `WonderQ-Admin-UI` 优先使用 TailwindCSS 工具类完成布局、间距、颜色、字体、边框、响应式和交互状态;重复样式优先使用 `@apply` 或可复用基础组件封装。`src/styles.css` 作为 Tailwind 入口和全局样式层,仅保留设计变量、基础重置、复杂选择器、伪元素以及 Tailwind 无法清晰表达的兼容规则;新增自定义 CSS 前应先确认 Tailwind 无法合理表达,并在代码附近说明原因
- `WonderQ-MiniAPP` 页面和组件默认只能使用 TailwindCSS 工具类完成布局、间距、颜色、字体、边框、响应式及交互状态;禁止新增自定义 `<style>``<style scoped>`。仅在 uni-app 平台兼容性、伪元素或 Tailwind 无法表达的特殊规则下允许例外,并在代码附近说明原因。
- `WonderQ-MiniAPP` 的响应式样式优先使用 TailwindCSS 断点和任意值变体,避免在单文件组件中新增独立媒体查询;重复的视觉规则应提取为全局工具类或可复用组件。
- `WonderQ-MiniAPP` 移动端采用简约、内容优先的视觉风格:以白色和低饱和中性色为基础,使用绿色作为主操作色,避免大面积渐变、金色渐变按钮、装饰性光晕和重阴影。
@@ -369,6 +369,7 @@ yarn dev:mp-weixin
- 是否误读或输出敏感信息。
- 是否改动了未授权文件或锁定文件。
- 新增或修改 Admin UI 样式是否优先使用 TailwindCSS是否避免无必要的页面级自定义 CSS。
- 是否保持 Public API、Admin API、页面模块契约一致。
- 是否遗漏鉴权、参数校验、错误响应、审计或删除冲突处理。
- 是否处理 loading、empty、error、retry 和移动端适配。

View File

@@ -0,0 +1,51 @@
<template>
<view
class="flex w-full shrink-0 items-center gap-2 border-t border-[#e8e2da] bg-white/95 px-3 pb-[calc(10px+env(safe-area-inset-bottom))] pt-2">
<view class="flex w-full shrink-0 items-center gap-1">
<view class="tap-feedback m-0 flex h-12 w-12 flex-col items-center justify-center p-0 text-[#81776f]"
@click="$emit('call')">
<uni-icons type="phone" :size="18" color="#81776f" />
<text class="mt-0.5 text-[9px]">电话</text>
</view>
<view class="tap-feedback m-0 flex h-12 w-12 flex-col items-center justify-center p-0 text-[#81776f]"
@click="$emit('concierge')">
<uni-icons type="headphones" :size="18" color="#81776f" />
<text class="mt-0.5 text-[9px]">管家</text>
</view>
<view class="tap-feedback relative m-0 flex h-12 w-12 flex-col items-center justify-center p-0 text-[#81776f]">
<uni-icons type="redo" :size="18" color="#81776f" />
<text class="mt-0.5 text-[9px]">分享</text>
<button open-type="share" plain hover-class="none" aria-label="分享"
class="absolute inset-0 z-10 m-0 h-full w-full border-0 bg-transparent p-0 opacity-0" />
</view>
<view class="ml-auto flex min-w-0 items-center gap-2">
<view class="min-w-0">
<text class="block text-[10px] text-[#9b9188]">参考起价</text>
<text class="mt-0.5 block whitespace-nowrap text-[18px] font-bold leading-none text-[#e96635]">¥{{
price }}</text>
</view>
<view
class="tap-feedback flex items-center justify-center m-0 h-11 rounded-full bg-[#e96635] px-4 text-[13px] font-semibold text-white shadow-[0_6px_16px_rgba(233,102,53,0.22)]"
@click="$emit('booking')">立即预订</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { formatWan } from "@/lib/data";
defineProps<{
price: number;
favorite: boolean;
}>();
defineEmits<{
call: [];
concierge: [];
booking: [];
}>();
</script>

View File

@@ -0,0 +1,30 @@
<template>
<view class="relative h-[286px] overflow-hidden bg-[#d8d5cf]">
<image class="absolute inset-0 h-full w-full" :src="image" mode="aspectFill" />
<view class="absolute bottom-10 left-4 right-4 z-10 text-white">
<view class="flex items-center gap-2">
<text class="flex items-center justify-center rounded-[20px] bg-[#e96635] px-2 py-1 text-[10px] font-semibold">
{{ presentation.eyebrow }}
</text>
<text
class="flex items-center justify-center rounded-[20px] text-[10px] bg-[#333333] text-white px-2 py-1 font-semibold">
{{ presentation.duration }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { DetailPresentation } from "./detailPresentation";
defineProps<{
image: string;
presentation: DetailPresentation;
}>();
defineEmits<{
back: [];
}>();
</script>

View File

@@ -0,0 +1,56 @@
<template>
<view
class="mx-3 mb-3 rounded-[12px] border border-[#e8e2da] bg-white px-4 py-4 shadow-[0_4px_16px_rgba(63,48,37,0.04)]">
<view class="flex items-center gap-2">
<view class="h-4 w-1 rounded-full bg-[#e96635]" />
<text class="text-[16px] font-bold text-[#282421]">{{ title }}</text>
<text v-if="eyebrow" class="text-[10px] tracking-[0.12em] text-[#aaa097]">{{ eyebrow }}</text>
</view>
<view v-if="paragraphs?.length" class="mt-3 space-y-2">
<text v-for="paragraph in paragraphs" :key="paragraph" class="block text-[12px] leading-5 text-[#6d655e]">{{
paragraph }}</text>
</view>
<view v-if="bullets?.length" class="mt-3 space-y-2">
<view v-for="item in bullets" :key="item" class="flex items-start gap-2">
<uni-icons type="checkbox" :size="20" color="#e96635" />
<text class="text-[12px] leading-5 text-[#5f5852]">{{ item }}</text>
</view>
</view>
<view v-if="included?.length || excluded?.length" class="mt-3 grid grid-cols-2 gap-2">
<view class="rounded-[8px] bg-[#fff8f3] p-3">
<text class="block text-[11px] font-semibold text-[#e96635]">费用包含</text>
<view class="mt-2 space-y-1.5">
<text v-for="item in included" :key="item" class="block text-[11px] leading-4 text-[#6d655e]"> {{ item
}}</text>
</view>
</view>
<view class="rounded-[8px] bg-[#f7f6f3] p-3">
<text class="block text-[11px] font-semibold text-[#7b7168]">费用不含</text>
<view class="mt-2 space-y-1.5">
<text v-for="item in excluded" :key="item" class="block text-[11px] leading-4 text-[#6d655e]">× {{ item
}}</text>
</view>
</view>
</view>
<view v-if="empty" class="mt-3 rounded-[8px] bg-[#faf8f5] px-3 py-4 text-center">
<text class="block text-[13px] font-semibold text-[#413a34]">暂无评价</text>
<text class="mt-1 block text-[11px] text-[#a09890]">完成一次旅行后欢迎留下你的体验</text>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
title: string;
eyebrow?: string;
paragraphs?: string[];
bullets?: string[];
included?: string[];
excluded?: string[];
empty?: boolean;
}>();
</script>

View File

@@ -0,0 +1,16 @@
<template>
<view class="px-3 pb-4">
<view class="overflow-hidden rounded-xl bg-white shadow-[0_4px_16px_rgba(63,48,37,0.04)]">
<view class="space-y-2">
<image v-for="(image, index) in images" :key="`${image}-${index}`" class="block w-full bg-[#eeeae4]"
:src="image" mode="widthFix" lazy-load />
</view>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
images: string[];
}>();
</script>

View File

@@ -0,0 +1,49 @@
<template>
<view
class="relative z-10 -mt-4 mx-3 rounded-[12px] border border-[#e8e2da] bg-white p-4 shadow-[0_8px_24px_rgba(63,48,37,0.08)]">
<text class="block text-[22px] font-bold leading-7 text-[#24211f]">{{ presentation.title }}</text>
<view class="mt-4 flex items-end justify-between gap-3 border-t border-[#f0ebe5] pt-3">
<view>
<text class="block text-[11px] text-[#a69d95]">参考起价</text>
<view class="mt-1 flex items-baseline gap-1">
<text class="text-[25px] font-bold leading-none text-[#e96635]">¥{{ formatWan(product.price) }}</text>
<text class="text-[11px] text-[#8f857d]">万起/</text>
</view>
</view>
</view>
<view class="mt-4 grid grid-cols-3 gap-2">
<view v-for="item in quickFacts" :key="item.label" class="rounded-[8px] bg-[#f8f6f2] px-2 py-2.5">
<text class="block text-[10px] text-[#a39a91]">{{ item.label }}</text>
<text class="mt-1 block truncate text-[12px] font-medium text-[#3d3833]">{{ item.value }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { toRefs } from "vue";
import { formatWan } from "@/lib/data";
import type { Product } from "@/lib/types";
import type { DetailPresentation } from "./detailPresentation";
const props = defineProps<{
product: Product;
meta: { location: string; summary: string };
presentation: DetailPresentation;
favorite: boolean;
}>();
defineEmits<{
toggleFavorite: [];
}>();
const quickFacts = [
{ label: "出行方式", value: "小包团" },
{ label: "适合人数", value: "2-8人" },
{ label: "服务方式", value: "管家跟进" },
];
const { product, meta, presentation, favorite } = toRefs(props);
</script>

View File

@@ -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),
};
}

View File

@@ -0,0 +1,111 @@
<template>
<view class="wq-phone flex h-screen flex-col bg-[#f5f3ef]">
<scroll-view scroll-y class="min-h-0 flex-1">
<DetailHero :image="product.image" :presentation="presentation" @back="goBack()" />
<DetailOverviewCard :product="product" :meta="meta" :presentation="presentation" :favorite="favorite"
@toggle-favorite="toggleFavorite" />
<view class="mt-4">
<DetailInfoCard title="详细介绍" eyebrow="ABOUT THIS ROUTE" :paragraphs="introParagraphs" />
<DetailInfoCard title="行程亮点" eyebrow="HIGHLIGHTS" :bullets="presentation.highlights" />
<DetailInfoCard title="费用明细" eyebrow="PRICE DETAILS" :paragraphs="priceParagraphs"
:included="presentation.included" :excluded="presentation.excluded" />
<DetailInfoCard title="注意事项" eyebrow="NOTES" :paragraphs="presentation.notes" />
<DetailInfoCard title="用户评价" eyebrow="REVIEWS" empty />
</view>
<DetailMediaGallery :images="presentation.gallery" />
</scroll-view>
<DetailActionBar :price="1.68" :favorite="favorite" @call="goCall" @concierge="goConcierge"
@booking="goBooking(product)" />
<ConciergeContactSheet :open="Boolean(selectedAdvisor)" :advisor="selectedAdvisor" @close="handleConciergeClosed" />
</view>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad, onShareAppMessage, onShow } from "@dcloudio/uni-app";
import DetailActionBar from "./components/DetailActionBar.vue";
import DetailHero from "./components/DetailHero.vue";
import DetailInfoCard from "./components/DetailInfoCard.vue";
import DetailMediaGallery from "./components/DetailMediaGallery.vue";
import DetailOverviewCard from "./components/DetailOverviewCard.vue";
import ConciergeContactSheet from "@/components/ConciergeContactSheet.vue";
import type { ConciergeAdvisor } from "../concierge/components/conciergeTypes";
import { createDetailPresentation } from "./components/detailPresentation";
import { getDetailMeta, getProductDetailSections, productKey } from "@/lib/data";
import { goBack, goBooking } from "@/lib/navigation";
import { findProduct, isProductFavorite, loadAppData, rememberProduct, toggleFavoriteProduct } from "@/lib/store";
import type { Product } from "@/lib/types";
const product = ref<Product>(findProduct());
onLoad((query) => {
product.value = findProduct(typeof query?.product === "string" ? decodeURIComponent(query.product) : undefined);
rememberProduct(product.value);
});
onShow(() => {
void loadAppData();
});
onShareAppMessage(() => ({
title: presentation.value.title,
path: `/pages/detail/index?product=${encodeURIComponent(productKey(product.value))}`,
imageUrl: product.value.image,
}));
const meta = computed(() => getDetailMeta(product.value));
const presentation = computed(() => createDetailPresentation(product.value));
const detailSections = computed(() => getProductDetailSections(product.value, meta.value));
const favorite = computed(() => isProductFavorite(product.value));
const introParagraphs = computed(() => getSectionTexts(["overview", "itinerary"], presentation.value.intro));
const priceParagraphs = computed(() => getSectionTexts(["service", "price"], "最终价格会根据出行日期、人数、酒店和体验资源确认后生成。"));
function getSectionTexts(keys: string[], fallback: string) {
const paragraphs = detailSections.value
.filter((section) => keys.includes(section.key))
.flatMap((section) => section.blocks)
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text.trim())
.filter(Boolean);
return paragraphs.length ? paragraphs : [fallback];
}
function toggleFavorite() {
const wasFavorite = favorite.value;
toggleFavoriteProduct(product.value);
uni.showToast({ title: wasFavorite ? "已取消收藏" : "已收藏", icon: "none" });
}
function goCall() {
uni.makePhoneCall({ phoneNumber: '13800000000' });
}
function goConcierge() {
selectedAdvisor.value = conciergeAdvisor;
}
function handleConciergeClosed() {
selectedAdvisor.value = null;
}
const conciergeAdvisor: ConciergeAdvisor = {
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&fm=jpg&q=80&w=240&h=240",
name: "Amanda",
role: "SENIOR TRAVEL ADVISOR",
qrImage: "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=WonderQ%20Concierge",
details: [
{ icon: "calendar", label: "服务经验8年" },
{ icon: "navigate", label: "擅长领域:瀑降 / 探洞" },
],
};
const selectedAdvisor = ref<ConciergeAdvisor | null>(null);
</script>

View File

@@ -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` 和生产配置不进入文档目录。
- 涉及重置数据、发布、回滚、迁移或生产操作的文档,需要明确风险和验证方式。
- 涉及重置数据、发布、回滚、迁移或生产操作的文档,需要明确风险和验证方式。

View File

@@ -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`

301
docs/concierge-api.md Normal file
View File

@@ -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 <admin-jwt>`
- `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<ConciergeAdvisorCreate>;
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 <admin-jwt>
```
成功响应:
```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 <admin-jwt>
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 <admin-jwt>
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 <admin-jwt>
```
删除成功返回:
```json
{ "id": "advisor-001" }
```
删除后应重新规范化剩余顾问的 `sortOrder`,从 `0` 开始连续编号。若业务要求至少保留一名启用顾问,服务端在删除最后一名启用顾问时返回 `409 CONCIERGE_LAST_ACTIVE_ADVISOR`,否则允许删除并由前台处理空状态。
### 调整顾问顺序
```http
PATCH /api/admin/concierge/advisors/reorder
Authorization: Bearer <admin-jwt>
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)

294
docs/detail-api.md Normal file
View File

@@ -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 <admin-jwt>`
- `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<DetailCreate>;
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 <admin-jwt>
```
成功响应:
```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 <admin-jwt>
```
成功返回单个 `DetailRecord`。记录不存在返回 `404 DETAIL_NOT_FOUND`
### 新增详情
```http
POST /api/admin/details
Authorization: Bearer <admin-jwt>
Content-Type: application/json
```
请求体为 `DetailCreate`。成功返回 `201` 和新建的 `DetailRecord`;未传 `sortOrder` 时追加到当前列表末尾。
### 编辑详情
```http
PATCH /api/admin/details/{detailId}
Authorization: Bearer <admin-jwt>
Content-Type: application/json
```
请求体为 `DetailPatch`,只更新提交的字段。修改 `key` 时仍须保证全局唯一。成功返回更新后的 `DetailRecord`;记录不存在返回 `404 DETAIL_NOT_FOUND``key` 冲突返回 `409 DETAIL_KEY_EXISTS`
### 删除详情
```http
DELETE /api/admin/details/{detailId}
Authorization: Bearer <admin-jwt>
```
成功返回:
```json
{ "id": "detail-001" }
```
删除后应重新规范化剩余记录的 `sortOrder`,从 `0` 开始连续编号。详情记录没有 Product、订单或线索外键因此删除不触发跨领域级联操作。
### 调整详情顺序
```http
PATCH /api/admin/details/reorder
Authorization: Bearer <admin-jwt>
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)

0
docs/guanjia-api.md Normal file
View File

0
docs/home-api.md Normal file
View File

325
docs/wanfa-api.md Normal file
View File

@@ -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 <admin-jwt>`
- 创建、编辑、删除和排序成功后写入审计日志,再提交事务。
- 变更接口返回最新变更对象;排序接口返回排序后的 `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<WanfaRouteCreate>;
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 <admin-jwt>
```
成功响应:
```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 <admin-jwt>
Content-Type: application/json
```
请求:
```json
{ "label": "亲子路线" }
```
成功返回 `201` 和新分类对象,初始 `routes``[]`,并追加到分类列表末尾。
### 编辑分类
```http
PATCH /api/admin/wanfa/categories/{categoryId}
Authorization: Bearer <admin-jwt>
Content-Type: application/json
```
请求只允许修改分类名称:
```json
{ "label": "家庭路线" }
```
成功返回更新后的 `WanfaCategory`。不存在的分类返回 `404 WANFA_CATEGORY_NOT_FOUND`
### 删除分类
```http
DELETE /api/admin/wanfa/categories/{categoryId}
Authorization: Bearer <admin-jwt>
```
为避免误删路线,分类仍包含路线时不得级联删除,应返回 `409 WANFA_CATEGORY_NOT_EMPTY`。删除前由 Admin UI 提示先移除分类内路线。删除成功返回:
```json
{ "id": "family-route" }
```
### 分类排序
```http
PATCH /api/admin/wanfa/categories/reorder
Authorization: Bearer <admin-jwt>
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 <admin-jwt>
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 <admin-jwt>
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 <admin-jwt>
```
成功返回 `{ "id": "..." }`。删除后,分类内路线保持原有相对顺序。
### 分类内路线排序
```http
PATCH /api/admin/wanfa/categories/{categoryId}/routes/reorder
Authorization: Bearer <admin-jwt>
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 建议保存最终 URLAdmin 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)