feat: 新增客片案例与团队共创模块,重构玩法与配置
- 新增客片案例全流程功能,包含前台页面、管理端配置、后端API、数据库迁移与文档 - 新增团队共创详情页面与对应接口 - 重构玩法模块:将静态playData替换为动态API获取,拆分类型定义到playTypes.ts - 优化vite构建配置与环境变量处理,调整详情页操作栏文案 - 更新全量相关文档与配图,新增客片案例、团队共创API文档 - 新增测试用例,完善数据归一化逻辑 - 调整路由配置与环境变量示例文件
This commit is contained in:
1 parent
cda8069630
commit
7ba92f3c5d
76 files changed
+7970
-137
No files matched your search
@@ -8,3 +8,38 @@ export type ConciergeAdvisor = {
|
||||
}>;
|
||||
qrImage: string;
|
||||
};
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is RecordValue {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function normalizeConciergeAdvisors(value: unknown): ConciergeAdvisor[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.advisors)) return [];
|
||||
|
||||
return value.advisors.flatMap((item) => {
|
||||
if (!isRecord(item)) return [];
|
||||
|
||||
const avatar = normalizeText(item.avatar);
|
||||
const name = normalizeText(item.name);
|
||||
const role = normalizeText(item.role);
|
||||
const qrImage = normalizeText(item.qrImage);
|
||||
if (!avatar || !name || !role || !qrImage) return [];
|
||||
|
||||
const details = Array.isArray(item.details)
|
||||
? item.details.flatMap((detail) => {
|
||||
if (!isRecord(detail)) return [];
|
||||
const icon = normalizeText(detail.icon);
|
||||
const label = normalizeText(detail.label);
|
||||
return icon && label ? [{ icon, label }] : [];
|
||||
})
|
||||
: [];
|
||||
|
||||
return [{ avatar, name, role, details, qrImage }];
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,20 @@
|
||||
<view class="wq-phone">
|
||||
<scroll-view :scroll-y="!pageScrollLocked" class="min-h-screen bg-white text-[#171615] no-scrollbar">
|
||||
<ConciergeHero />
|
||||
<ConciergeAdvisor :advisors="advisors" @select="openAdvisor" />
|
||||
<view v-if="loading" class="mx-5 my-6 rounded-xl border border-slate-200 bg-slate-50 px-4 py-6 text-center">
|
||||
<text class="text-sm text-slate-500">正在加载管家信息…</text>
|
||||
</view>
|
||||
<view v-else-if="error" class="mx-5 my-6 rounded-xl border border-rose-200 bg-rose-50 px-4 py-6 text-center">
|
||||
<text class="block text-sm text-rose-600">管家信息加载失败</text>
|
||||
<text class="mt-1 block text-xs text-rose-500">{{ error }}</text>
|
||||
<button class="mt-4 rounded-full bg-emerald-700 px-5 py-2 text-xs text-white" @click="loadAdvisors">
|
||||
重新加载
|
||||
</button>
|
||||
</view>
|
||||
<view v-else-if="!advisors.length" class="mx-5 my-6 rounded-xl border border-slate-200 bg-slate-50 px-4 py-6 text-center">
|
||||
<text class="text-sm text-slate-500">暂无可用管家</text>
|
||||
</view>
|
||||
<ConciergeAdvisor v-else :advisors="advisors" @select="openAdvisor" />
|
||||
<ConciergePrinciples :principles="principles" />
|
||||
</scroll-view>
|
||||
|
||||
@@ -17,14 +30,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, onMounted } from "vue";
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
import { onHide, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { fetchPublicConciergeAdvisors } from "@/lib/api";
|
||||
import ConciergeHero from "./components/ConciergeHero.vue";
|
||||
import ConciergePrinciples from "./components/ConciergePrinciples.vue";
|
||||
import ConciergeAdvisor from "./components/ConciergeAdvisor.vue";
|
||||
import ConciergeContactSheet from "@/components/ConciergeContactSheet.vue";
|
||||
import type { ConciergeAdvisor as ConciergeAdvisorData } from "./components/conciergeTypes";
|
||||
import { normalizeConciergeAdvisors, type ConciergeAdvisor as ConciergeAdvisorData } from "./components/conciergeTypes";
|
||||
|
||||
const principles = [
|
||||
{
|
||||
@@ -44,47 +58,33 @@ const principles = [
|
||||
},
|
||||
];
|
||||
|
||||
const advisors: ConciergeAdvisorData[] = [
|
||||
{
|
||||
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: "擅长领域:瀑降 / 探洞" },
|
||||
],
|
||||
},
|
||||
{
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1531123897727-8f129e1688ce?auto=format&fit=crop&fm=jpg&q=80&w=240&h=240",
|
||||
name: "Mia",
|
||||
role: "LIFESTYLE TRAVEL ADVISOR",
|
||||
qrImage: "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=WonderQ%20Concierge",
|
||||
details: [
|
||||
{ icon: "calendar", label: "服务经验:6年" },
|
||||
{ icon: "navigate", label: "擅长领域:溯溪 / 人文探索" },
|
||||
],
|
||||
},
|
||||
{
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&fm=jpg&q=80&w=240&h=240",
|
||||
name: "Leo",
|
||||
role: "WILD TRAVEL ADVISOR",
|
||||
qrImage: "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=WonderQ%20Concierge",
|
||||
details: [
|
||||
{ icon: "calendar", label: "服务经验:9年" },
|
||||
{ icon: "navigate", label: "擅长领域:洞穴探险 / 山地徒步" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const advisors = ref<ConciergeAdvisorData[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
|
||||
async function loadAdvisors() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const response = await fetchPublicConciergeAdvisors();
|
||||
advisors.value = normalizeConciergeAdvisors(response);
|
||||
} catch (loadError) {
|
||||
advisors.value = [];
|
||||
error.value = loadError instanceof Error ? loadError.message : "请稍后重试。";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAdvisor = ref<ConciergeAdvisorData | null>(null);
|
||||
const pageScrollLocked = ref(false);
|
||||
const pageStyle = computed(() => `overflow:${pageScrollLocked.value ? "hidden" : "visible"};`);
|
||||
let tabBarTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
void loadAdvisors();
|
||||
});
|
||||
|
||||
watch(selectedAdvisor, (advisor) => {
|
||||
if (!advisor) {
|
||||
showNativeTabBar();
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<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]"
|
||||
<!-- <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> -->
|
||||
|
||||
<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" />
|
||||
@@ -29,7 +29,7 @@
|
||||
</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>
|
||||
@click="$emit('concierge')">联系管家</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { PlayCategory } from "./playData";
|
||||
import type { PlayCategory } from "./playTypes";
|
||||
|
||||
const props = defineProps<{
|
||||
categories: PlayCategory[];
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PlayRoute } from "./playData";
|
||||
import type { PlayRoute } from "./playTypes";
|
||||
|
||||
defineProps<{
|
||||
route: PlayRoute;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PublicWanfaResponse } from "@/lib/types";
|
||||
|
||||
export type PlayRoute = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
image: string;
|
||||
routeCount: number;
|
||||
demandKeyword: string;
|
||||
};
|
||||
|
||||
export type PlayCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
routes: PlayRoute[];
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object";
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeRoute(value: unknown): PlayRoute | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const id = nonEmptyString(value.id);
|
||||
const title = nonEmptyString(value.title);
|
||||
const subtitle = nonEmptyString(value.subtitle);
|
||||
const image = nonEmptyString(value.image);
|
||||
const demandKeyword = nonEmptyString(value.demandKeyword);
|
||||
const routeCount = typeof value.routeCount === "number" && Number.isFinite(value.routeCount) ? value.routeCount : null;
|
||||
|
||||
if (!id || !title || !subtitle || !image || !demandKeyword || routeCount === null || routeCount < 0) return null;
|
||||
|
||||
return { id, title, subtitle, image, routeCount, demandKeyword };
|
||||
}
|
||||
|
||||
function normalizeCategory(value: unknown): PlayCategory | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.routes)) return null;
|
||||
|
||||
const id = nonEmptyString(value.id);
|
||||
const label = nonEmptyString(value.label);
|
||||
const routes = value.routes.map(normalizeRoute).filter((route): route is PlayRoute => route !== null);
|
||||
|
||||
if (!id || !label || routes.length === 0) return null;
|
||||
|
||||
return { id, label, routes };
|
||||
}
|
||||
|
||||
export function normalizePlayCategories(value: unknown): PlayCategory[] {
|
||||
const categories = isRecord(value) && Array.isArray((value as PublicWanfaResponse).categories)
|
||||
? (value as PublicWanfaResponse).categories
|
||||
: [];
|
||||
|
||||
return categories.map(normalizeCategory).filter((category): category is PlayCategory => category !== null);
|
||||
}
|
||||
@@ -7,7 +7,18 @@
|
||||
|
||||
<scroll-view scroll-y class="no-scrollbar h-full min-h-0 min-w-0 flex-1 bg-white">
|
||||
<view class="min-h-full bg-white pb-[calc(96px+env(safe-area-inset-bottom))] px-[20px] pt-[16px]">
|
||||
<view class="pb-10">
|
||||
<view v-if="loading" class="flex min-h-[360px] items-center justify-center text-[13px] text-[#aaa3a0]" aria-live="polite">
|
||||
<text>正在加载玩法</text>
|
||||
</view>
|
||||
<view v-else-if="error" class="flex min-h-[360px] flex-col items-center justify-center px-4 text-center" role="alert">
|
||||
<text class="text-[14px] font-medium text-[#151515]">玩法暂时加载失败</text>
|
||||
<text class="mt-2 text-[12px] leading-5 text-[#aaa3a0]">{{ error }}</text>
|
||||
<button class="mt-4 rounded-[8px] bg-[#008577] px-4 py-2 text-[13px] text-white" @click="loadCategories">重新加载</button>
|
||||
</view>
|
||||
<view v-else-if="!playCategories.length" class="flex min-h-[360px] items-center justify-center text-[13px] text-[#aaa3a0]">
|
||||
<text>暂无可用玩法</text>
|
||||
</view>
|
||||
<view v-else-if="activeCategory" class="pb-10">
|
||||
<view v-for="route in activeCategory.routes" :key="route.id" class="pb-[40px] last:pb-0">
|
||||
<PlayRouteCard :route="route" @select="selectRoute" />
|
||||
</view>
|
||||
@@ -21,21 +32,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { fetchPublicWanfaCategories } from "@/lib/api";
|
||||
import { goDemand } from "@/lib/navigation";
|
||||
import PlayCategorySidebar from "./components/PlayCategorySidebar.vue";
|
||||
import PlayRouteCard from "./components/PlayRouteCard.vue";
|
||||
import { playCategories, type PlayCategory, type PlayRoute } from "./components/playData";
|
||||
import { normalizePlayCategories, type PlayCategory, type PlayRoute } from "./components/playTypes";
|
||||
|
||||
const defaultCategory = playCategories[0]!;
|
||||
const activeCategoryId = ref(defaultCategory.id);
|
||||
const activeCategory = computed<PlayCategory>(() => playCategories.find((category) => category.id === activeCategoryId.value) ?? defaultCategory);
|
||||
const playCategories = ref<PlayCategory[]>([]);
|
||||
const activeCategoryId = ref("");
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const activeCategory = computed<PlayCategory | null>(() => playCategories.value.find((category) => category.id === activeCategoryId.value) ?? null);
|
||||
|
||||
function selectCategory(id: string) {
|
||||
activeCategoryId.value = id;
|
||||
}
|
||||
|
||||
function selectRoute(route: PlayRoute) {
|
||||
goDemand(route.title);
|
||||
goDemand(route.demandKeyword);
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const response = await fetchPublicWanfaCategories();
|
||||
playCategories.value = normalizePlayCategories(response);
|
||||
activeCategoryId.value = playCategories.value[0]?.id ?? "";
|
||||
} catch (cause) {
|
||||
playCategories.value = [];
|
||||
activeCategoryId.value = "";
|
||||
error.value = cause instanceof Error && cause.message.trim() ? cause.message : "请稍后重试";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCategories);
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<view class="wq-page">
|
||||
<view class="wq-phone">
|
||||
<scroll-view scroll-y class="h-screen bg-white no-scrollbar" :show-scrollbar="false">
|
||||
<view v-if="loading" class="flex min-h-[360px] items-center justify-center text-[13px] text-[#aaa3a0]">
|
||||
<text>正在加载团队共创详情</text>
|
||||
</view>
|
||||
<view v-else-if="!detail" class="flex min-h-[360px] flex-col items-center justify-center px-5 text-center">
|
||||
<text class="text-[14px] font-medium text-[#151515]">暂时找不到这个团队共创</text>
|
||||
<button class="mt-4 rounded-[8px] bg-[#008577] px-4 py-2 text-[13px] text-white"
|
||||
@click="goBack()">返回首页</button>
|
||||
</view>
|
||||
<view v-else class="pb-10">
|
||||
<view class="relative h-[280px] overflow-hidden bg-[#1f302d]">
|
||||
<image class="h-full w-full" :src="detail.image" mode="aspectFill" />
|
||||
<view
|
||||
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/35 to-transparent px-5 pb-6 pt-16 text-white">
|
||||
<text class="block text-[10px] uppercase tracking-[0.3em] text-white/75">{{ detail.tag }}</text>
|
||||
<text class="mt-2 block text-[30px] font-semibold leading-[1.15] tracking-[0.04em]">{{ detail.title
|
||||
}}</text>
|
||||
<text class="mt-2 block text-[14px] leading-5 text-white/85">{{ detail.detailSubtitle }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="fallbackNotice"
|
||||
class="mx-5 mt-5 rounded-[8px] bg-[#fff8ed] px-3 py-2 text-[12px] leading-5 text-[#a67842]">
|
||||
{{ fallbackNotice }}
|
||||
</view>
|
||||
<view class="space-y-6 px-5 py-8">
|
||||
<text v-for="(paragraph, index) in detail.detailParagraphs" :key="`${detail.id}-${index}`"
|
||||
class="block text-[15px] leading-8 tracking-[0.03em] text-[#34302d]">
|
||||
{{ paragraph }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { fetchPublicTeamBuildingDetail } from "@/lib/api";
|
||||
import { goBack } from "@/lib/navigation";
|
||||
import {
|
||||
homeTeamBuildingFallback,
|
||||
normalizeHomeTeamBuildingDetail,
|
||||
type HomeTeamBuilding,
|
||||
} from "@/pages/home/components/homeTeamBuildingData";
|
||||
|
||||
const detail = ref<HomeTeamBuilding | null>(null);
|
||||
const loading = ref(true);
|
||||
const fallbackNotice = ref("");
|
||||
|
||||
async function loadDetail(teamBuildingId: string) {
|
||||
loading.value = true;
|
||||
fallbackNotice.value = "";
|
||||
try {
|
||||
const response = await fetchPublicTeamBuildingDetail(teamBuildingId);
|
||||
detail.value = normalizeHomeTeamBuildingDetail(response);
|
||||
} catch {
|
||||
const fallback = homeTeamBuildingFallback.find((item) => item.id === teamBuildingId);
|
||||
detail.value = fallback ? { ...fallback, detailParagraphs: [...fallback.detailParagraphs] } : null;
|
||||
if (detail.value) fallbackNotice.value = "接口暂不可用,当前展示模拟数据";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
const teamBuildingId = typeof query?.id === "string" ? query.id : "";
|
||||
if (!teamBuildingId) {
|
||||
detail.value = null;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
void loadDetail(teamBuildingId);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<button
|
||||
class="m-0 overflow-hidden rounded-[12px] bg-white p-0 text-left shadow-[0_3px_14px_rgba(35,27,19,0.06)] tap-feedback"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
<image class="block h-[178px] w-full bg-[#f2f0ed]" :src="item.image" mode="aspectFill" />
|
||||
<view class="px-3 pb-3 pt-3">
|
||||
<text class="block truncate text-[14px] font-semibold leading-5 text-[#171717]">{{ item.title }}</text>
|
||||
<text class="mt-1 block text-[12px] leading-4 text-[#a7a09a]">· {{ item.photoCount }}张</text>
|
||||
</view>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HomeWildArchive } from "@/pages/home/components/homeWildArchivesData";
|
||||
|
||||
defineProps<{ item: HomeWildArchive }>();
|
||||
defineEmits<{ select: [item: HomeWildArchive] }>();
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<view class="flex h-14 items-center justify-between border-b border-[#f0eeea] bg-white px-4">
|
||||
<button class="m-0 flex h-9 w-9 items-center justify-start p-0" aria-label="返回" @click="$emit('back')">
|
||||
<uni-icons type="left" :size="22" color="#151515" />
|
||||
</button>
|
||||
<text class="max-w-[70%] truncate text-[14px] font-medium text-[#111111]">{{ title }}</text>
|
||||
<view class="h-9 w-9" aria-hidden="true" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string }>();
|
||||
defineEmits<{ back: [] }>();
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<view class="wq-page">
|
||||
<view class="wq-phone">
|
||||
<scroll-view scroll-y class="h-screen bg-white no-scrollbar">
|
||||
<view v-if="loading" class="flex min-h-[360px] items-center justify-center text-[13px] text-[#aaa3a0]">
|
||||
<text>正在加载案例详情</text>
|
||||
</view>
|
||||
<view v-else-if="!detail" class="flex min-h-[360px] flex-col items-center justify-center px-5 text-center">
|
||||
<text class="text-[14px] font-medium text-[#151515]">暂时找不到这个客片案例</text>
|
||||
<button class="mt-4 rounded-[8px] bg-[#008577] px-4 py-2 text-[13px] text-white"
|
||||
@click="goBack()">返回案例列表</button>
|
||||
</view>
|
||||
<view v-else class="pb-8">
|
||||
<view class="px-5 pb-5 pt-7">
|
||||
<text class="block text-[22px] font-semibold leading-8 tracking-[0.01em] text-[#111111]">{{ detail.title
|
||||
}}</text>
|
||||
<text class="mt-1 block text-[14px] leading-5 text-[#a29a93]">{{ detail.images.length }}张照片</text>
|
||||
<text v-if="fallbackNotice" class="mt-2 block text-[12px] leading-5 text-[#b18a6d]">{{ fallbackNotice
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="space-y-3 px-4">
|
||||
<image v-for="image in detail.images" :key="image" class="block w-full rounded-[7px] bg-[#f2f0ed]"
|
||||
:src="image" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { fetchPublicWildArchiveDetail } from "@/lib/api";
|
||||
import { goBack } from "@/lib/navigation";
|
||||
import { homeWildArchiveFallback, normalizeHomeWildArchiveDetail, type HomeWildArchive } from "@/pages/home/components/homeWildArchivesData";
|
||||
|
||||
const detail = ref<HomeWildArchive | null>(null);
|
||||
const loading = ref(true);
|
||||
const fallbackNotice = ref("");
|
||||
|
||||
async function loadDetail(archiveId: string) {
|
||||
loading.value = true;
|
||||
fallbackNotice.value = "";
|
||||
try {
|
||||
const response = await fetchPublicWildArchiveDetail(archiveId);
|
||||
detail.value = normalizeHomeWildArchiveDetail(response);
|
||||
} catch {
|
||||
const fallback = homeWildArchiveFallback.find((item) => item.id === archiveId);
|
||||
detail.value = fallback ? { ...fallback, images: [...fallback.images] } : null;
|
||||
if (detail.value) fallbackNotice.value = "接口暂不可用,当前展示模拟数据";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
const archiveId = typeof query?.id === "string" ? query.id : "";
|
||||
if (!archiveId) {
|
||||
detail.value = null;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
void loadDetail(archiveId);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<view class="wq-page">
|
||||
<view class="wq-phone">
|
||||
<scroll-view scroll-y class="h-screen bg-[#faf9f7] no-scrollbar">
|
||||
<view class="bg-white px-5 pb-6 pt-7">
|
||||
<text class="block text-[22px] font-semibold leading-8 tracking-[0.02em] text-[#111111]">客片案例</text>
|
||||
<text class="mt-1 block text-[13px] leading-5 text-[#a29a93]">珍藏每一段旅途的美好瞬间</text>
|
||||
<view v-if="fallbackNotice" class="mt-2 flex items-center gap-2">
|
||||
<text class="text-[11px] leading-4 text-[#b18a6d]">{{ fallbackNotice }}</text>
|
||||
<button class="m-0 p-0 text-[11px] leading-4 text-[#008577]" @click="loadArchives">重试</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="flex min-h-[360px] items-center justify-center text-[13px] text-[#aaa3a0]">
|
||||
<text>正在加载客片案例</text>
|
||||
</view>
|
||||
<view v-else-if="error" class="flex min-h-[360px] flex-col items-center justify-center px-5 text-center">
|
||||
<text class="text-[14px] font-medium text-[#151515]">客片案例加载失败</text>
|
||||
<text class="mt-2 text-[12px] leading-5 text-[#aaa3a0]">{{ error }}</text>
|
||||
<button class="mt-4 rounded-[8px] bg-[#008577] px-4 py-2 text-[13px] text-white"
|
||||
@click="loadArchives">重新加载</button>
|
||||
</view>
|
||||
<view v-else-if="!archives.length"
|
||||
class="flex min-h-[360px] items-center justify-center text-[13px] text-[#aaa3a0]">
|
||||
<text>暂无客片案例</text>
|
||||
</view>
|
||||
<view v-else class="grid grid-cols-2 gap-3 px-3 py-5">
|
||||
<WildArchiveCard v-for="item in archives" :key="item.id" :item="item" @select="openDetail" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { fetchPublicWildArchives } from "@/lib/api";
|
||||
import { goWildArchiveDetail } from "@/lib/navigation";
|
||||
import WildArchiveCard from "./components/WildArchiveCard.vue";
|
||||
import { homeWildArchiveFallback, normalizeHomeWildArchives, type HomeWildArchive } from "@/pages/home/components/homeWildArchivesData";
|
||||
|
||||
const archives = ref<HomeWildArchive[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const fallbackNotice = ref("");
|
||||
|
||||
async function loadArchives() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
fallbackNotice.value = "";
|
||||
try {
|
||||
const response = await fetchPublicWildArchives();
|
||||
archives.value = normalizeHomeWildArchives(response.items);
|
||||
} catch (cause) {
|
||||
archives.value = homeWildArchiveFallback.map((item) => ({ ...item, images: [...item.images] }));
|
||||
fallbackNotice.value = cause instanceof Error && cause.message.trim() ? "接口暂不可用,当前展示模拟数据" : "当前展示模拟数据";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(item: HomeWildArchive) {
|
||||
goWildArchiveDetail(item.id);
|
||||
}
|
||||
|
||||
onMounted(loadArchives);
|
||||
</script>
|
||||
Reference in new issue
Block a user