feat: 新增客片案例与团队共创模块,重构玩法与配置

- 新增客片案例全流程功能,包含前台页面、管理端配置、后端API、数据库迁移与文档
- 新增团队共创详情页面与对应接口
- 重构玩法模块:将静态playData替换为动态API获取,拆分类型定义到playTypes.ts
- 优化vite构建配置与环境变量处理,调整详情页操作栏文案
- 更新全量相关文档与配图,新增客片案例、团队共创API文档
- 新增测试用例,完善数据归一化逻辑
- 调整路由配置与环境变量示例文件
This commit is contained in:
duanshuwen committed 2026-08-19 20:23:04 +08:00
1 parent cda8069630
commit 7ba92f3c5d
76 files changed
+7970 -137

No files matched your search

@@ -0,0 +1,42 @@
<template>
<view v-if="items.length" class="bg-[#fffdf9] px-[8.5%] pb-8 pt-7">
<view class="mb-[14px] flex items-end justify-between gap-4">
<view>
<text class="block font-serif text-[21px] font-semibold leading-7 tracking-[0.04em] text-[#18212a]">玩法推荐</text>
<text class="mt-1 block text-[12px] leading-[18px] text-[#7a7d78]">按主题挑选适合你的旅行方式</text>
</view>
<text class="shrink-0 font-sans text-[9px] leading-4 tracking-[0.18em] text-[#a3988e]">WAYS TO GO</text>
</view>
<view class="grid grid-cols-2 gap-3">
<view
v-for="item in items"
:key="item.id"
class="min-w-0 overflow-hidden rounded-[12px] border border-[#e8e1d7] bg-white tap-feedback active:border-[#b97b5f] active:bg-[#fffaf4]"
:aria-label="`${item.label},${item.routes.length} 条路线`"
@click="$emit('select', item)"
>
<image class="block h-[112px] w-full bg-[#ebe8e2] min-[431px]:h-[126px]" :src="item.routes[0].image" mode="aspectFill" />
<view class="p-[11px_10px_13px]">
<view class="flex min-h-[22px] items-center justify-between gap-1.5">
<text class="min-w-0 flex-1 font-serif text-[15px] font-semibold leading-[22px] text-[#1c252d]">{{ item.label }}</text>
<uni-icons type="right" :size="16" color="#b5aaa0" />
</view>
<text class="mt-1.5 block text-[11px] leading-[17px] text-[#7a7d78]">{{ item.routes.length }} 条路线可选</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { HomeWanfaRecommendation } from "./homeWanfaRecommendationData";
defineProps<{
items: HomeWanfaRecommendation[];
}>();
defineEmits<{
select: [item: HomeWanfaRecommendation];
}>();
</script>
@@ -5,8 +5,25 @@ export type HomeTeamBuilding = {
description: string;
image: string;
demandKeyword: string;
detailSubtitle: string;
detailParagraphs: string[];
};
export type HomeTeamBuildingApiItem = {
id?: string | null;
tag?: string | null;
title?: string | null;
description?: string | null;
image?: string | null;
demandKeyword?: string | null;
detailSubtitle?: string | null;
detailParagraphs?: string[] | null;
isActive?: boolean | null;
sortOrder?: number | null;
};
export type HomeTeamBuildings = HomeTeamBuildingApiItem[];
export const homeTeamBuildingMocks: HomeTeamBuilding[] = [
{
id: "wild-challenge",
@@ -15,6 +32,16 @@ export const homeTeamBuildingMocks: HomeTeamBuilding[] = [
description: "洞穴、瀑降与协作,适合 10-30 人。",
image: "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient",
demandKeyword: "户外团建",
detailSubtitle: "越过山丘,向来处去",
detailParagraphs: [
"真正的贵州,从未被写进流水线的攻略里。洞穴、瀑降、峡谷和村寨,都是团队一起重新认识彼此的方式。",
"当所有人都在熟悉的点里寻找同一个机位,我们换一条路,去看真正的贵州。",
"我们是一群生于斯、长于斯的地坪探索者,也是对体验和美学有高偏好的本地旅行人。",
"十年的时间,我们用影像和脚步寻找贵州的小众特色目的地,把贵州在社交媒体分享给全世界。",
"我们深知这片土地的魅力,绝不仅是走马观花的打卡。它藏在高山峡谷的边缘感中,也藏在溶洞和地心暗河的回音里。",
"去喀斯特洞河里释放野性,去无名山野间寻找宁静,把未知的风险与沿途的惊艳交给最强悍的团队兜底。",
"越过山丘,向来处去,有深度,有记录,有趣。如果想解锁贵州这段独特的旅行,欢迎与野呀团队一起,解锁贵州。",
],
},
{
id: "canyon-teamwork",
@@ -23,6 +50,12 @@ export const homeTeamBuildingMocks: HomeTeamBuilding[] = [
description: "溯溪与分组协作,兼顾参与感与安全。",
image: "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
demandKeyword: "峡谷团建",
detailSubtitle: "在峡谷里重新认识彼此",
detailParagraphs: [
"把会议室换成溪流和峡谷,让团队在共同完成一段路的过程中自然建立默契。",
"从分组协作到沿途探索,每个人都能找到自己的节奏,也能看见同伴身上不一样的力量。",
"这是一场有挑战、有风景,也留得下故事的团队共创。",
],
},
{
id: "village-gathering",
@@ -31,5 +64,66 @@ export const homeTeamBuildingMocks: HomeTeamBuilding[] = [
description: "夜游、长桌宴与文化体验,适合团建收尾。",
image: "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
demandKeyword: "贵州团建",
detailSubtitle: "在山野与人文之间共创一席",
detailParagraphs: [
"走进村寨,和当地的人打个照面,再围坐在长桌旁分享一天的见闻。团队关系,往往在这些没有安排的时刻里变得更近。",
"夜游、长桌宴与文化体验,为一次团建留下温度,也为下一次共同出发埋下期待。",
],
},
];
export const homeTeamBuildingFallback = homeTeamBuildingMocks;
export function normalizeHomeTeamBuildings(
items: HomeTeamBuildings | null | undefined,
): HomeTeamBuilding[] {
const fallback = homeTeamBuildingFallback.map((item) => ({ ...item }));
const activeItems = (items ?? [])
.map((item, index) => ({ item, index }))
.filter(({ item }) => item.isActive !== false && Boolean(normalizeText(item.title)))
.sort(
(left, right) =>
(left.item.sortOrder ?? left.index) - (right.item.sortOrder ?? right.index),
);
if (!activeItems.length) return fallback;
return activeItems.map(({ item }, index) => {
const fallbackItem = homeTeamBuildingFallback.find((candidate) => candidate.id === item.id)
?? homeTeamBuildingFallback[index]
?? homeTeamBuildingFallback[0];
const title = normalizeText(item.title) || fallbackItem.title;
return {
id: normalizeText(item.id) || fallbackItem.id,
tag: normalizeText(item.tag) || fallbackItem.tag,
title,
description: normalizeText(item.description) || fallbackItem.description,
image: normalizeText(item.image) || fallbackItem.image,
demandKeyword: normalizeText(item.demandKeyword) || title,
detailSubtitle: normalizeText(item.detailSubtitle) || fallbackItem.detailSubtitle || fallbackItem.description,
detailParagraphs: normalizeDetailParagraphs(item.detailParagraphs, fallbackItem.detailParagraphs, fallbackItem.description),
};
});
}
function normalizeDetailParagraphs(
paragraphs: string[] | null | undefined,
fallbackParagraphs: string[],
description: string,
) {
const normalized = (paragraphs ?? []).map((paragraph) => normalizeText(paragraph)).filter(Boolean);
return normalized.length ? normalized : fallbackParagraphs.length ? [...fallbackParagraphs] : [description];
}
function normalizeText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
export function normalizeHomeTeamBuildingDetail(
item: HomeTeamBuildingApiItem | null | undefined,
): HomeTeamBuilding {
const fallback = homeTeamBuildingFallback.find((candidate) => candidate.id === item?.id)
?? homeTeamBuildingFallback[0];
const normalized = normalizeHomeTeamBuildings(item ? [item] : [fallback])[0];
return normalized;
}
@@ -0,0 +1,44 @@
import type { PublicHomeWanfaRecommendation, PublicWanfaRoute } from "@/lib/types";
export type HomeWanfaRecommendation = PublicHomeWanfaRecommendation;
export type HomeWanfaRecommendationApiItem = Partial<PublicHomeWanfaRecommendation> & {
routes?: Array<Partial<PublicWanfaRoute>>;
};
export type HomeWanfaRecommendations = HomeWanfaRecommendationApiItem[];
function nonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized ? normalized : null;
}
function normalizeRoute(value: unknown): PublicWanfaRoute | null {
if (!value || typeof value !== "object") return null;
const item = value as Partial<PublicWanfaRoute>;
const id = nonEmptyString(item.id);
const title = nonEmptyString(item.title);
const subtitle = nonEmptyString(item.subtitle);
const image = nonEmptyString(item.image);
const demandKeyword = nonEmptyString(item.demandKeyword);
const routeCount = typeof item.routeCount === "number" && Number.isFinite(item.routeCount) ? item.routeCount : null;
if (!id || !title || !subtitle || !image || !demandKeyword || routeCount === null || routeCount < 0) return null;
return { id, title, subtitle, image, routeCount, demandKeyword };
}
function normalizeRecommendation(value: HomeWanfaRecommendationApiItem): HomeWanfaRecommendation | null {
const id = nonEmptyString(value.id);
const categoryId = nonEmptyString(value.categoryId);
const label = nonEmptyString(value.label);
const routes = Array.isArray(value.routes)
? value.routes.map(normalizeRoute).filter((route): route is PublicWanfaRoute => route !== null)
: [];
if (!id || !categoryId || !label || !routes.length) return null;
return { id, categoryId, label, routes };
}
export function normalizeHomeWanfaRecommendations(
items: HomeWanfaRecommendations | null | undefined,
): HomeWanfaRecommendation[] {
if (!Array.isArray(items)) return [];
return items.map(normalizeRecommendation).filter((item): item is HomeWanfaRecommendation => item !== null);
}
@@ -2,32 +2,114 @@ export type HomeWildArchive = {
id: string;
title: string;
image: string;
images: string[];
photoCount: number;
demandKeyword: string;
};
export type HomeWildArchiveApiItem = {
id?: string | null;
title?: string | null;
image?: string | null;
images?: string[] | null;
photoCount?: number | null;
demandKeyword?: string | null;
isActive?: boolean | null;
sortOrder?: number | null;
};
export type HomeWildArchives = HomeWildArchiveApiItem[];
export const homeWildArchiveMocks: HomeWildArchive[] = [
{
id: "hundred-meter-descent",
title: "百米自降",
image: "https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg",
images: [
"https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg",
"https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg",
],
photoCount: 2,
demandKeyword: "悬崖瀑降",
},
{
id: "shilong-cave",
title: "石龙洞",
title: "石龙洞——客片案例",
image: "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg",
images: [
"https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg",
"https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg",
"https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
"https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient",
"https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
],
photoCount: 5,
demandKeyword: "地心探险",
},
{
id: "cliff-current",
title: "绝壁迎流",
image: "https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg",
images: [
"https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg",
"https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
],
photoCount: 2,
demandKeyword: "峡谷探险",
},
{
id: "canyon-streaming",
title: "峡谷溯溪",
image: "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
images: [
"https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
"https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
],
photoCount: 2,
demandKeyword: "峡谷溯溪",
},
];
export const homeWildArchiveFallback = homeWildArchiveMocks;
export function normalizeHomeWildArchives(
items: HomeWildArchives | null | undefined,
): HomeWildArchive[] {
const fallback = homeWildArchiveFallback.map((item) => ({ ...item }));
const activeItems = (items ?? [])
.map((item, index) => ({ item, index }))
.filter(({ item }) => item.isActive !== false && Boolean(item.title?.trim()))
.sort(
(left, right) =>
(left.item.sortOrder ?? left.index) - (right.item.sortOrder ?? right.index),
);
if (!activeItems.length) return fallback;
return activeItems.map(({ item }, index) => {
const fallbackItem = homeWildArchiveFallback.find((candidate) => candidate.id === item.id)
?? homeWildArchiveFallback[index]
?? homeWildArchiveFallback[0];
const title = item.title?.trim() || fallbackItem.title;
const image = item.image?.trim() || fallbackItem.image;
const images = (item.images ?? []).map((imageUrl) => imageUrl.trim()).filter(Boolean);
const normalizedImages = images.length ? images : [image];
return {
id: item.id?.trim() || fallbackItem.id,
title,
image,
images: normalizedImages,
photoCount: item.photoCount && item.photoCount > 0 ? item.photoCount : normalizedImages.length,
demandKeyword: item.demandKeyword?.trim() || title,
};
});
}
export function normalizeHomeWildArchiveDetail(
item: HomeWildArchiveApiItem | null | undefined,
): HomeWildArchive {
const fallback = homeWildArchiveFallback.find((candidate) => candidate.id === item?.id)
?? homeWildArchiveFallback[0];
const normalized = normalizeHomeWildArchives(item ? [item] : [fallback])[0];
return normalized;
}
+19 -11
View File
@@ -3,10 +3,11 @@
<view class="wq-phone">
<scroll-view scroll-y class="h-screen no-scrollbar" :show-scrollbar="false">
<HomeHeroCarousel :slides="appState.data.heroSlides" @select="openHero" />
<HomeExperienceList :items="homeExperienceMocks" @select="openExperience" />
<HomeWanfaRecommendations :items="homeContentState.playRecommendations" @select="openWanfaRecommendation" />
<HomeExperienceList :items="homeContentState.experiences" @select="openExperience" />
<HomeVehicleGrid :vehicle-options="vehicleOptions" @select="goDemand('贵州')" />
<HomeTeamBuilding :items="homeTeamBuildingMocks" @select="openTeamBuilding" />
<HomeWildArchives :items="homeWildArchiveMocks" @select="openWildArchive" @more="openWildArchives" />
<HomeTeamBuilding :items="homeContentState.teamBuildings" @select="openTeamBuilding" />
<HomeWildArchives :items="homeContentState.wildArchives" @select="openWildArchive" @more="openWildArchives" />
</scroll-view>
</view>
</view>
@@ -20,16 +21,19 @@ import HomeExperienceList from "./components/HomeExperienceList.vue";
import HomeTeamBuilding from "./components/HomeTeamBuilding.vue";
import HomeWildArchives from "./components/HomeWildArchives.vue";
import HomeVehicleGrid from "./components/HomeVehicleGrid.vue";
import { homeExperienceMocks, type HomeExperience } from "./components/homeExperienceData";
import { homeTeamBuildingMocks, type HomeTeamBuilding as HomeTeamBuildingItem } from "./components/homeTeamBuildingData";
import { homeWildArchiveMocks, type HomeWildArchive } from "./components/homeWildArchivesData";
import { goDemand } from "@/lib/navigation";
import { appState, loadAppData } from "@/lib/store";
import HomeWanfaRecommendations from "./components/HomeWanfaRecommendations.vue";
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 { appState, homeContentState, loadAppData, loadHomeContent } from "@/lib/store";
const vehicleOptions = computed(() => appState.data.vehicleOptions);
onShow(() => {
void loadAppData();
void loadHomeContent();
});
function openHero(title: string) {
@@ -40,15 +44,19 @@ function openExperience(item: HomeExperience) {
goDemand(item.demandKeyword);
}
function openWanfaRecommendation(item: HomeWanfaRecommendation) {
goDemand(item.label);
}
function openWildArchive(item: HomeWildArchive) {
goDemand(item.demandKeyword);
goWildArchiveDetail(item.id);
}
function openWildArchives() {
goDemand("极境视界");
goWildArchives();
}
function openTeamBuilding(item: HomeTeamBuildingItem) {
goDemand(item.demandKeyword);
goTeamBuildingDetail(item.id);
}
</script>