feat(route-detail): 实现路线详情独立管理全链路功能
添加`DetailRecord`数据库模型、迁移脚本及完整的CRUD管理接口,将路线详情从玩法路线表解耦,实现独立存储。 在Admin UI的路线编辑器中集成详情配置面板,支持编辑详情文案、图片等展示内容。 完善MiniAPP路线详情页,新增导航函数、API调用及数据归一化逻辑,并添加对应单元测试。 更新所有相关文档,明确领域边界、联调流程及接口契约规范。 调整首页和玩法页的点击跳转逻辑,优先跳转路线详情页而非原需求页。 新增数值格式化工具函数优化内容展示效果。
This commit is contained in:
1 parent
7ba92f3c5d
commit
c143b67275
32 files changed
+1343
-259
No files matched your search
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user