feat(route-detail): 实现路线详情独立管理全链路功能

添加`DetailRecord`数据库模型、迁移脚本及完整的CRUD管理接口,将路线详情从玩法路线表解耦,实现独立存储。
在Admin UI的路线编辑器中集成详情配置面板,支持编辑详情文案、图片等展示内容。
完善MiniAPP路线详情页,新增导航函数、API调用及数据归一化逻辑,并添加对应单元测试。
更新所有相关文档,明确领域边界、联调流程及接口契约规范。
调整首页和玩法页的点击跳转逻辑,优先跳转路线详情页而非原需求页。
新增数值格式化工具函数优化内容展示效果。
This commit is contained in:
duanshuwen committed 2026-08-19 21:20:51 +08:00
1 parent 7ba92f3c5d
commit c143b67275
32 files changed
+1343 -259

No files matched your search

+5
View File
@@ -3,6 +3,7 @@ import type {
AuthSession,
PhoneLoginPayload,
PublicConciergeResponse,
PublicDetail,
PublicHomeResponse,
PublicHomeTeamBuildingDetail,
PublicHomeWildArchiveDetail,
@@ -98,6 +99,10 @@ export async function fetchPublicTeamBuildingDetail(teamBuildingId: string) {
return request<PublicHomeTeamBuildingDetail>(`/api/public/home/team-buildings/${encodeURIComponent(teamBuildingId)}`);
}
export async function fetchPublicDetail(key: string) {
return request<PublicDetail>(`/api/public/details/${encodeURIComponent(key)}`);
}
export async function createPublicLead(payload: PublicLeadPayload) {
return request<{ id: string; status: string }>("/api/public/leads", {
method: "POST",
+4
View File
@@ -117,3 +117,7 @@ export const fallbackData: AppData = {
export function getSubmitErrorMessage(error: unknown) {
return error instanceof Error && error.message ? error.message : "提交失败,请稍后再试。";
}
export function formatWan(value: number) {
return Number.isFinite(value) ? value.toFixed(2).replace(/\.00$/, "") : "0";
}
+4
View File
@@ -43,6 +43,10 @@ export function goTeamBuildingDetail(teamBuildingId: string) {
go("/pages/team-buildings/detail", { id: teamBuildingId });
}
export function goWanfaRouteDetail(routeId: string) {
go("/pages/detail/index", { routeId });
}
export function goMine() {
if (requireLogin("/pages/mine/index")) {
goRoot("/pages/mine/index");
+14
View File
@@ -108,6 +108,20 @@ export type PublicWanfaResponse = {
categories: PublicWanfaCategory[];
};
export type PublicDetail = {
key: string;
eyebrow: string;
duration: string;
title: string;
subtitle: string;
intro: string;
highlights: string[];
included: string[];
excluded: string[];
notes: string[];
gallery: string[];
};
export type PublicConciergeDetail = {
icon: string;
label: string;
+6
View File
@@ -13,6 +13,12 @@
"navigationBarTitleText": "玩法"
}
},
{
"path": "pages/detail/index",
"style": {
"navigationBarTitleText": "路线详情"
}
},
{
"path": "pages/concierge/index",
"style": {
@@ -1,49 +1,32 @@
<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 class="relative z-10 -mt-5 mx-3 rounded-[14px] bg-white px-4 py-5 shadow-[0_8px_24px_rgba(63,48,37,0.08)]">
<view class="flex flex-wrap items-center gap-2">
<text class="rounded-full bg-[#eaf6f1] px-2.5 py-1 text-[10px] font-semibold text-[#16806d]">
{{ presentation.eyebrow }}
</text>
<text class="rounded-full bg-[#f3f0eb] px-2.5 py-1 text-[10px] font-medium text-[#746c64]">
{{ presentation.duration }}
</text>
</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>
<text class="mt-3 block text-[24px] font-bold leading-8 text-[#24211f]">{{ presentation.title }}</text>
<text class="mt-2 block text-[13px] leading-6 text-[#756d65]">{{ presentation.subtitle }}</text>
<view class="mt-4 grid grid-cols-2 gap-2 border-t border-[#f0ebe5] pt-3">
<view class="rounded-[8px] bg-[#f8f6f2] px-3 py-2.5">
<text class="block text-[10px] text-[#a39a91]">路线状态</text>
<text class="mt-1 block text-[12px] font-medium text-[#3d3833]">可咨询规划</text>
</view>
<view class="rounded-[8px] bg-[#f8f6f2] px-3 py-2.5">
<text class="block text-[10px] text-[#a39a91]">详情图片</text>
<text class="mt-1 block text-[12px] font-medium text-[#3d3833]">{{ presentation.gallery.length }} 张</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 };
defineProps<{
presentation: DetailPresentation;
favorite: boolean;
}>();
defineEmits<{
toggleFavorite: [];
}>();
const quickFacts = [
{ label: "出行方式", value: "小包团" },
{ label: "适合人数", value: "2-8人" },
{ label: "服务方式", value: "管家跟进" },
];
const { product, meta, presentation, favorite } = toRefs(props);
</script>
@@ -1,93 +1,96 @@
import { stripTitle } from "@/lib/data";
import type { Product } from "@/lib/types";
import { playCategories, type PlayRoute } from "../../play/components/playData";
import type { PublicDetail } 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[];
};
export type DetailPresentation = PublicDetail;
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",
const sharedGallery = [
"https://images.unsplash.com/photo-1500534623283-312aade485b7?auto=format&fit=crop&fm=jpg&q=80&w=1200&h=800",
"https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&fm=jpg&q=80&w=1200&h=800",
];
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);
function createRouteFallback(route: PlayRoute): DetailPresentation {
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: [
"往返大交通及个人消费",
"未列明餐食和自选体验",
"因个人原因产生的额外费用",
key: route.id,
eyebrow: "玩法推荐",
duration: "5天4晚",
title: route.title,
subtitle: route.subtitle,
intro: `围绕${route.title}安排一段节奏舒适的贵州探索,把自然景观、在地体验和小团服务串成一条完整路线。`,
highlights: [
"核心景观串联,减少无效往返和重复换乘",
"小团出行,按同行人的节奏灵活调整",
"在地体验与舒适停留合理衔接",
"行程前确认天气、装备和接送细节",
],
included: ["行程内用车与接送服务", "方案中注明的景点门票和体验项目", "行前服务管家确认与途中跟进"],
excluded: ["往返大交通及个人消费", "未列明餐食和自选体验", "因个人原因产生的额外费用"],
notes: [
"贵州多山多雨,请准备防滑鞋、轻便雨具和薄外套。",
"溶洞、漂流、徒步等体验会根据天气和同行人体力适当调整。",
"页面价格为参考起价,最终方案以出行日期、人数和资源确认结果为准。",
"户外体验会根据天气和同行人体力适当调整。",
"最终行程以出行日期、人数和资源确认结果为准。",
],
gallery: Array.from(
new Set([product.image, ...apiImages, ...fallbackGallery]),
).slice(0, 6),
gallery: Array.from(new Set([route.image, ...sharedGallery])).slice(0, 5),
};
}
const routeFallbacks = new Map(
playCategories.flatMap((category) => category.routes).map((route) => [route.id, createRouteFallback(route)]),
);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object";
}
function cleanText(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : "";
}
function cleanList(value: unknown) {
return Array.isArray(value)
? value.map((item) => cleanText(item)).filter(Boolean)
: [];
}
function cleanGallery(value: unknown) {
return Array.from(
new Set(
cleanList(value).filter((item) => /^https?:\/\//i.test(item)),
),
);
}
export function findWanfaRouteDetailFallback(routeId: string) {
return routeFallbacks.get(routeId) ?? null;
}
export function normalizeDetailPresentation(
value: unknown,
fallback: DetailPresentation | null = null,
): DetailPresentation | null {
const source = isRecord(value) ? value : {};
const key = cleanText(source.key) || fallback?.key || "";
const eyebrow = cleanText(source.eyebrow) || fallback?.eyebrow || "";
const duration = cleanText(source.duration) || fallback?.duration || "";
const title = cleanText(source.title) || fallback?.title || "";
const subtitle = cleanText(source.subtitle) || fallback?.subtitle || "";
const intro = cleanText(source.intro) || fallback?.intro || "";
const gallery = cleanGallery(source.gallery);
if (!key || !eyebrow || !duration || !title || !subtitle || !intro) {
return fallback;
}
return {
key,
eyebrow,
duration,
title,
subtitle,
intro,
highlights: cleanList(source.highlights).length ? cleanList(source.highlights) : fallback?.highlights ?? [],
included: cleanList(source.included).length ? cleanList(source.included) : fallback?.included ?? [],
excluded: cleanList(source.excluded).length ? cleanList(source.excluded) : fallback?.excluded ?? [],
notes: cleanList(source.notes).length ? cleanList(source.notes) : fallback?.notes ?? [],
gallery: gallery.length ? gallery : fallback?.gallery ?? [],
};
}
+121 -78
View File
@@ -1,25 +1,48 @@
<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 v-if="loading" class="flex min-h-[520px] items-center justify-center px-8 text-center">
<view>
<view class="mx-auto h-8 w-8 animate-pulse rounded-full bg-[#d8e8e1]" />
<text class="mt-3 block text-[13px] text-[#8c857e]">正在加载路线详情…</text>
</view>
</view>
<DetailMediaGallery :images="presentation.gallery" />
<view v-else-if="!presentation" class="flex min-h-[520px] flex-col items-center justify-center px-8 text-center">
<text class="text-[16px] font-semibold text-[#2c2824]">路线详情暂时不可用</text>
<text class="mt-2 text-[12px] leading-5 text-[#8c857e]">{{ errorMessage || "暂未配置这条路线的详情内容。" }}</text>
<button
type="button"
class="mt-5 rounded-[10px] border border-[#d9d1c7] bg-white px-5 py-2.5 text-[13px] font-medium text-[#3d3731]"
@click="loadDetail"
>
重试
</button>
</view>
<view v-else>
<view v-if="usingFallback" class="bg-[#fff8e8] px-4 py-2 text-[11px] leading-5 text-[#8c6a2c]">
当前显示本地模拟详情,服务恢复后可重试获取最新内容。
</view>
<DetailHero :image="heroImage" :presentation="presentation" @back="goBack()" />
<DetailOverviewCard :presentation="presentation" />
<view class="mt-4">
<DetailInfoCard title="详细介绍" eyebrow="ABOUT THIS ROUTE" :paragraphs="[presentation.intro]" />
<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" />
</view>
</scroll-view>
<DetailActionBar :price="1.68" :favorite="favorite" @call="goCall" @concierge="goConcierge"
@booking="goBooking(product)" />
<DetailActionBar :price="1.68" :favorite="false" @call="goCall" @concierge="goConcierge" />
<ConciergeContactSheet :open="Boolean(selectedAdvisor)" :advisor="selectedAdvisor" @close="handleConciergeClosed" />
</view>
@@ -27,74 +50,29 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad, onShareAppMessage, onShow } from "@dcloudio/uni-app";
import { onLoad, onShareAppMessage } from "@dcloudio/uni-app";
import ConciergeContactSheet from "@/components/ConciergeContactSheet.vue";
import { fetchPublicDetail } from "@/lib/api";
import { goBack } from "@/lib/navigation";
import { SUPPORT_PHONE } from "@/lib/types";
import type { ConciergeAdvisor } from "../concierge/components/conciergeTypes";
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";
import {
findWanfaRouteDetailFallback,
normalizeDetailPresentation,
type DetailPresentation,
} from "./components/detailPresentation";
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 routeId = ref("");
const presentation = ref<DetailPresentation | null>(null);
const loading = ref(true);
const usingFallback = ref(false);
const errorMessage = ref("");
const selectedAdvisor = ref<ConciergeAdvisor | null>(null);
const conciergeAdvisor: ConciergeAdvisor = {
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&fm=jpg&q=80&w=240&h=240",
@@ -107,5 +85,70 @@ const conciergeAdvisor: ConciergeAdvisor = {
],
};
const selectedAdvisor = ref<ConciergeAdvisor | null>(null);
const heroImage = computed(() => presentation.value?.gallery[0] ?? "");
const priceParagraphs = computed(() => ["最终价格会根据出行日期、人数和资源确认结果生成。"]);
function parseRouteId(value: unknown) {
if (typeof value !== "string") return "";
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
async function loadDetail() {
if (!routeId.value) {
presentation.value = null;
errorMessage.value = "缺少路线 ID,无法读取详情。";
loading.value = false;
return;
}
loading.value = true;
errorMessage.value = "";
usingFallback.value = false;
const fallback = findWanfaRouteDetailFallback(routeId.value);
try {
const response = await fetchPublicDetail(routeId.value);
const normalized = normalizeDetailPresentation(response, fallback);
if (normalized) {
presentation.value = normalized;
usingFallback.value = normalized === fallback;
} else {
presentation.value = null;
errorMessage.value = "详情内容为空。";
}
} catch (cause) {
presentation.value = fallback;
usingFallback.value = Boolean(fallback);
errorMessage.value = cause instanceof Error ? cause.message : "路线详情接口暂时不可用。";
} finally {
loading.value = false;
}
}
function goCall() {
uni.makePhoneCall({ phoneNumber: SUPPORT_PHONE });
}
function goConcierge() {
selectedAdvisor.value = conciergeAdvisor;
}
function handleConciergeClosed() {
selectedAdvisor.value = null;
}
onLoad((query) => {
routeId.value = parseRouteId(query?.routeId);
void loadDetail();
});
onShareAppMessage(() => ({
title: presentation.value?.title ?? "路线详情",
path: `/pages/detail/index?routeId=${encodeURIComponent(routeId.value)}`,
imageUrl: heroImage.value,
}));
</script>
+12 -1
View File
@@ -26,7 +26,13 @@ import type { HomeExperience } from "./components/homeExperienceData";
import type { HomeTeamBuilding as HomeTeamBuildingItem } from "./components/homeTeamBuildingData";
import type { HomeWildArchive } from "./components/homeWildArchivesData";
import type { HomeWanfaRecommendation } from "./components/homeWanfaRecommendationData";
import { goDemand, goTeamBuildingDetail, goWildArchiveDetail, goWildArchives } from "@/lib/navigation";
import {
goDemand,
goTeamBuildingDetail,
goWanfaRouteDetail,
goWildArchiveDetail,
goWildArchives,
} from "@/lib/navigation";
import { appState, homeContentState, loadAppData, loadHomeContent } from "@/lib/store";
const vehicleOptions = computed(() => appState.data.vehicleOptions);
@@ -45,6 +51,11 @@ function openExperience(item: HomeExperience) {
}
function openWanfaRecommendation(item: HomeWanfaRecommendation) {
const route = item.routes[0];
if (route) {
goWanfaRouteDetail(route.id);
return;
}
goDemand(item.label);
}
+2 -2
View File
@@ -34,7 +34,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { fetchPublicWanfaCategories } from "@/lib/api";
import { goDemand } from "@/lib/navigation";
import { goWanfaRouteDetail } from "@/lib/navigation";
import PlayCategorySidebar from "./components/PlayCategorySidebar.vue";
import PlayRouteCard from "./components/PlayRouteCard.vue";
import { normalizePlayCategories, type PlayCategory, type PlayRoute } from "./components/playTypes";
@@ -50,7 +50,7 @@ function selectCategory(id: string) {
}
function selectRoute(route: PlayRoute) {
goDemand(route.demandKeyword);
goWanfaRouteDetail(route.id);
}
async function loadCategories() {