feat(mini-app): 重构微信小程序前台,完善详情页模块并优化登录流程

- 新增详情页全套UI组件,重构详情页面的代码结构与业务逻辑
- 删除单独的登录页面与对应路由,将登录逻辑集成至个人中心页面
- 统一多页面组件的UI圆角样式,优化部分组件的布局与交互
- 调整玩法页面的跳转逻辑,点击路线卡片直接跳转至详情页
- 优化管家页面的tabBar显示隐藏与页面滚动锁定逻辑
- 更新首页团建项目的文案内容,简化描述文本
- 更新README文档,删除冗余的测试文件与废弃的路由配置
- 修复联系弹窗的事件触发逻辑,新增opened事件支持
This commit is contained in:
duanshuwen committed 2026-08-13 21:56:23 +08:00
1 parent 988f7c3f4c
commit d5a4f7549b
22 files changed
+527 -332

No files matched your search

@@ -0,0 +1,45 @@
<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('home')">
<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="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<{
home: [];
toggleFavorite: [];
concierge: [];
booking: [];
}>();
</script>
@@ -0,0 +1,29 @@
<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>
@@ -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>
@@ -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>
@@ -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>
@@ -0,0 +1,78 @@
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),
};
}
+49 -94
View File
@@ -1,99 +1,43 @@
<template>
<view class="wq-page">
<view class="wq-phone">
<view class="min-h-screen bg-transparent">
<scroll-view scroll-y class="h-screen pb-[118px]">
<view class="relative h-[430px] text-white">
<image class="absolute inset-0 h-full w-full" :src="product.image" mode="aspectFill" />
<view class="hero-scrim absolute inset-0" />
<view class="top-safe absolute left-0 right-0 top-0 z-10 flex items-center justify-between px-4 pt-2">
<button
class="tap-feedback m-0 h-11 w-11 rounded-full bg-black/30 p-0 text-[28px] leading-none text-white backdrop-blur"
@click="goBack()">‹</button>
<view class="flex gap-2">
<button
class="tap-feedback m-0 h-11 min-w-[48px] rounded-full bg-black/30 px-3 text-[12px] font-semibold text-white backdrop-blur"
@click="toggleFavorite">{{ favorite ? "已藏" : "收藏" }}</button>
</view>
</view>
<view class="absolute bottom-8 left-0 right-0 px-5">
<text class="block text-[12px] font-semibold opacity-80">{{ meta.location }}</text>
<text class="mt-2 block text-[28px] font-bold leading-9">{{ primaryTitle }}</text>
<text class="mt-3 text-clamp-2 text-[13px] leading-5 opacity-85">{{ meta.summary }}</text>
</view>
</view>
<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()" />
<view
class="sticky top-0 z-20 border-b border-[#245c45]/10 bg-[#fffaf2]/95 px-3 py-3 shadow-[0_8px_20px_rgba(31,44,37,0.05)] backdrop-blur">
<scroll-view scroll-x class="no-scrollbar whitespace-nowrap">
<button v-for="section in detailSections" :key="section.key"
class="tap-feedback m-0 mr-2 min-h-[40px] rounded-full border px-4 py-2 text-[13px]"
:class="activeTab === section.key ? 'border-[#245c45] bg-[#245c45] text-white shadow-[0_8px_16px_rgba(36,92,69,0.16)]' : 'border-[#245c45]/10 bg-white/90 text-[#526256]'"
@click="activeTab = section.key">
{{ section.label }}
</button>
</scroll-view>
</view>
<DetailOverviewCard :product="product" :meta="meta" :presentation="presentation" :favorite="favorite"
@toggle-favorite="toggleFavorite" />
<view class="px-4 py-4">
<view class="elevated-card rounded-[24px] p-4">
<view class="flex flex-wrap gap-2">
<text v-for="tag in product.tags" :key="tag"
class="rounded-full border border-[#245c45]/10 bg-[#edf4ee] px-3 py-1 text-[11px] font-semibold text-[#245c45]">{{
tag }}</text>
</view>
<view class="mt-4 flex items-end justify-between">
<text class="text-[12px] text-[#718073]">参考起价</text>
<text class="text-[24px] font-bold text-[#c36f21]">¥{{ formatWan(product.price) }}万/人起</text>
</view>
</view>
</view>
<view class="px-4">
<view v-for="section in visibleSections" :key="section.key" class="elevated-card mb-4 rounded-[24px] p-4">
<text class="block text-[18px] font-bold text-[#1f2c25]">{{ section.title || section.label }}</text>
<view class="mt-3 space-y-3">
<template v-for="(block, index) in section.blocks" :key="`${section.key}-${index}`">
<text v-if="block.type === 'text'" class="block text-[13px] leading-6 text-[#526256]">{{ block.text
}}</text>
<view v-else class="overflow-hidden rounded-[20px] border border-[#245c45]/10">
<image class="h-[210px] w-full" :src="block.url" mode="aspectFill" />
<text v-if="block.alt" class="block bg-[#f6f7f2] px-3 py-2 text-[11px] text-[#718073]">{{ block.alt
}}</text>
</view>
</template>
</view>
</view>
</view>
<view class="py-4">
<SectionHeading title="你可能还想看" subtitle="按当前线路为你推荐" />
<ProductList :products="recommended" @select="openProduct" />
</view>
</scroll-view>
<view
class="fixed bottom-0 left-1/2 z-40 flex w-full max-w-[430px] -translate-x-1/2 items-center justify-end gap-3 border-t border-[#245c45]/10 bg-[#fffaf2]/95 px-4 pb-[calc(12px+env(safe-area-inset-bottom))] pt-3 shadow-[0_-14px_32px_rgba(31,44,37,0.1)] backdrop-blur">
<button class="primary-action tap-feedback m-0 w-full text-[15px] font-bold"
@click="goBooking(product)">预订咨询</button>
<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>
</view>
</view>
<DetailMediaGallery :images="presentation.gallery" />
</scroll-view>
<DetailActionBar :price="1.68" :favorite="favorite" @home="goHome" @toggle-favorite="toggleFavorite"
@concierge="goConcierge" @booking="goBooking(product)" />
</view>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import ProductList from "@/components/ProductList.vue";
import SectionHeading from "@/components/SectionHeading.vue";
import { formatWan, getDetailMeta, getProductDetailSections, productKey, stripTitle } from "@/lib/data";
import { goBack, goBooking, goDetail } from "@/lib/navigation";
import { appState, findProduct, isProductFavorite, loadAppData, rememberProduct, toggleFavoriteProduct } from "@/lib/store";
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 { createDetailPresentation } from "./components/detailPresentation";
import { getDetailMeta, getProductDetailSections } from "@/lib/data";
import { goBack, goBooking, goRoot } from "@/lib/navigation";
import { findProduct, isProductFavorite, loadAppData, rememberProduct, toggleFavoriteProduct } from "@/lib/store";
import type { Product } from "@/lib/types";
const product = ref<Product>(findProduct());
const activeTab = ref("overview");
onLoad((query) => {
product.value = findProduct(typeof query?.product === "string" ? decodeURIComponent(query.product) : undefined);
@@ -105,14 +49,22 @@ onShow(() => {
});
const meta = computed(() => getDetailMeta(product.value));
const primaryTitle = computed(() => stripTitle(product.value.title));
const presentation = computed(() => createDetailPresentation(product.value));
const detailSections = computed(() => getProductDetailSections(product.value, meta.value));
const visibleSections = computed(() => detailSections.value.filter((section) => activeTab.value === section.key || activeTab.value === "overview"));
const favorite = computed(() => isProductFavorite(product.value));
const recommended = computed(() => {
const start = Math.max(0, product.value.id % Math.max(1, appState.data.products.length - 4));
return appState.data.products.filter((item) => productKey(item) !== productKey(product.value)).slice(start, start + 4);
});
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;
@@ -120,10 +72,13 @@ function toggleFavorite() {
uni.showToast({ title: wasFavorite ? "已取消收藏" : "已收藏", icon: "none" });
}
function openProduct(next: Product) {
product.value = next;
activeTab.value = "overview";
rememberProduct(next);
goDetail(next);
function goHome() {
goRoot("/pages/home/index");
}
function goConcierge() {
goRoot("/pages/concierge/index");
}
</script>