feat(mini-app): 重构微信小程序前台,完善详情页模块并优化登录流程
- 新增详情页全套UI组件,重构详情页面的代码结构与业务逻辑 - 删除单独的登录页面与对应路由,将登录逻辑集成至个人中心页面 - 统一多页面组件的UI圆角样式,优化部分组件的布局与交互 - 调整玩法页面的跳转逻辑,点击路线卡片直接跳转至详情页 - 优化管家页面的tabBar显示隐藏与页面滚动锁定逻辑 - 更新首页团建项目的文案内容,简化描述文本 - 更新README文档,删除冗余的测试文件与废弃的路由配置 - 修复联系弹窗的事件触发逻辑,新增opened事件支持
This commit is contained in:
11
README.md
11
README.md
@@ -4,11 +4,11 @@
|
||||
|
||||
## 项目组成
|
||||
|
||||
| 子项目 | 定位 | 技术栈 | 默认地址 |
|
||||
| --- | --- | --- | --- |
|
||||
| `WonderQ-MiniAPP` | H5 与微信小程序前台 | uni-app、Vue 3、TypeScript、Tailwind CSS | `http://localhost:5173` |
|
||||
| `WonderQ-Admin-UI` | 运营管理后台前端 | Vite、React、TypeScript、Tailwind CSS 4 | `http://localhost:5602` |
|
||||
| `WonderQ-Admin` | Public API 与 Admin API | Python、FastAPI、SQLAlchemy 2、Alembic、PostgreSQL、JWT、Pydantic | `http://localhost:4000` |
|
||||
| 子项目 | 定位 | 技术栈 | 默认地址 |
|
||||
| ------------------ | ----------------------- | ----------------------------------------------------------------- | ----------------------- |
|
||||
| `WonderQ-MiniAPP` | H5 与微信小程序前台 | uni-app、Vue 3、TypeScript、Tailwind CSS | `http://localhost:5173` |
|
||||
| `WonderQ-Admin-UI` | 运营管理后台前端 | Vite、React、TypeScript、Tailwind CSS 4 | `http://localhost:5602` |
|
||||
| `WonderQ-Admin` | Public API 与 Admin API | Python、FastAPI、SQLAlchemy 2、Alembic、PostgreSQL、JWT、Pydantic | `http://localhost:4000` |
|
||||
|
||||
项目采用页面驱动开发:前台页面定义用户流程,管理后台维护运营内容,后端负责鉴权、接口、数据持久化和发布能力。
|
||||
|
||||
@@ -32,7 +32,6 @@ WonderQ-Project/
|
||||
- `pages/play/index`:玩法
|
||||
- `pages/concierge/index`:管家
|
||||
- `pages/detail/index`:线路详情
|
||||
- `pages/login/index`:登录
|
||||
- `pages/mine/index`:我的
|
||||
|
||||
前台可复用组件位于 `WonderQ-MiniAPP/src/components/`,页面专属组件按页面放在对应的 `components/` 目录中。
|
||||
|
||||
@@ -26,13 +26,6 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "手机号登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
|
||||
@@ -74,6 +74,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
opened: [];
|
||||
}>();
|
||||
|
||||
const popupRef = ref<UniPopupExpose | null>(null);
|
||||
@@ -89,6 +90,7 @@ watch(
|
||||
);
|
||||
|
||||
function onPopupChange(event: { show: boolean }) {
|
||||
if (!event.show) emit("close");
|
||||
if (event.show) emit("opened");
|
||||
else emit("close");
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
<template>
|
||||
<!-- #ifdef MP-WEIXIN || APP-PLUS -->
|
||||
<page-meta :page-style="pageStyle" />
|
||||
<!-- #endif -->
|
||||
<view class="wq-page">
|
||||
<view class="wq-phone">
|
||||
<scroll-view scroll-y class="min-h-screen bg-white text-[#171615] no-scrollbar">
|
||||
<ConciergeHero />
|
||||
<ConciergeAdvisor :advisors="advisors" @select="openAdvisor" />
|
||||
<ConciergePrinciples :principles="principles" />
|
||||
</scroll-view>
|
||||
<scroll-view :scroll-y="!pageScrollLocked" class="min-h-screen bg-white text-[#171615] no-scrollbar">
|
||||
<ConciergeHero />
|
||||
<ConciergeAdvisor :advisors="advisors" @select="openAdvisor" />
|
||||
<ConciergePrinciples :principles="principles" />
|
||||
</scroll-view>
|
||||
|
||||
<ConciergeContactSheet :open="Boolean(selectedAdvisor)" :advisor="selectedAdvisor"
|
||||
@close="selectedAdvisor = null" />
|
||||
<ConciergeContactSheet :open="Boolean(selectedAdvisor)" :advisor="selectedAdvisor" @opened="handlePopupOpened"
|
||||
@close="handlePopupClosed" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed } from "vue";
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
import { onHide, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import ConciergeHero from "./components/ConciergeHero.vue";
|
||||
import ConciergePrinciples from "./components/ConciergePrinciples.vue";
|
||||
import ConciergeAdvisor from "./components/ConciergeAdvisor.vue";
|
||||
@@ -76,8 +81,82 @@ const advisors: ConciergeAdvisorData[] = [
|
||||
];
|
||||
|
||||
const selectedAdvisor = ref<ConciergeAdvisorData | null>(null);
|
||||
const pageScrollLocked = ref(false);
|
||||
const pageStyle = computed(() => `overflow:${pageScrollLocked.value ? "hidden" : "visible"};`);
|
||||
let tabBarTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
watch(selectedAdvisor, (advisor) => {
|
||||
if (!advisor) {
|
||||
showNativeTabBar();
|
||||
}
|
||||
});
|
||||
|
||||
function openAdvisor(advisor: ConciergeAdvisorData) {
|
||||
selectedAdvisor.value = advisor;
|
||||
pageScrollLocked.value = true;
|
||||
}
|
||||
|
||||
function handlePopupOpened() {
|
||||
pageScrollLocked.value = true;
|
||||
hideNativeTabBar();
|
||||
}
|
||||
|
||||
function handlePopupClosed() {
|
||||
pageScrollLocked.value = false;
|
||||
selectedAdvisor.value = null;
|
||||
}
|
||||
|
||||
function hideNativeTabBar() {
|
||||
|
||||
if (tabBarTimer) {
|
||||
clearTimeout(tabBarTimer);
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
uni.hideTabBar({
|
||||
animation: false,
|
||||
fail: (error) => {
|
||||
console.warn("[WonderQ] 微信小程序隐藏 tabBar 失败", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
hide();
|
||||
tabBarTimer = setTimeout(() => {
|
||||
tabBarTimer = undefined;
|
||||
hide();
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function showNativeTabBar() {
|
||||
if (tabBarTimer) {
|
||||
clearTimeout(tabBarTimer);
|
||||
tabBarTimer = undefined;
|
||||
}
|
||||
uni.showTabBar({ animation: false });
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
if (selectedAdvisor.value) {
|
||||
pageScrollLocked.value = true;
|
||||
hideNativeTabBar();
|
||||
} else {
|
||||
pageScrollLocked.value = false;
|
||||
showNativeTabBar();
|
||||
}
|
||||
});
|
||||
onHide(() => {
|
||||
pageScrollLocked.value = false;
|
||||
showNativeTabBar();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageScrollLocked.value = false;
|
||||
showNativeTabBar();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
showNativeTabBar();
|
||||
if (tabBarTimer) {
|
||||
clearTimeout(tabBarTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
29
WonderQ-MiniAPP/src/pages/detail/components/DetailHero.vue
Normal file
29
WonderQ-MiniAPP/src/pages/detail/components/DetailHero.vue
Normal file
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<view class="flex items-start justify-between gap-4 pr-[18px] min-[431px]:pr-[22px]">
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="flex min-h-5 items-center gap-[10px]">
|
||||
<text class="inline-flex min-h-5 items-center rounded-[2px] border border-[#eadfce] bg-[#f8f1e8] px-[6px] text-[10px] leading-[18px] tracking-[0.06em] text-[#98744b]">{{ item.badge }}</text>
|
||||
<text class="inline-flex min-h-5 items-center rounded-[12px] border border-[#eadfce] bg-[#f8f1e8] px-[6px] text-[10px] leading-[18px] tracking-[0.06em] text-[#98744b]">{{ item.badge }}</text>
|
||||
<text class="text-[13px] font-semibold leading-5 text-[#283b4d]">{{ item.category }}</text>
|
||||
</view>
|
||||
<text class="mt-[10px] block whitespace-pre-line font-serif text-[25px] font-medium leading-8 tracking-[0.04em] text-[#18212a]">{{ item.title }}</text>
|
||||
@@ -18,7 +18,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<image class="mt-[17px] block h-[174px] w-full bg-[#eae7e1] min-[431px]:h-[190px]" :src="item.image" mode="aspectFill" />
|
||||
<image class="mt-[17px] block h-[174px] w-full rounded-[12px] bg-[#eae7e1] min-[431px]:h-[190px]" :src="item.image" mode="aspectFill" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<view
|
||||
v-for="vehicle in vehicleOptions"
|
||||
:key="vehicle.title"
|
||||
class="min-w-0 overflow-hidden rounded-[4px] border border-[#e8e1d7] bg-white tap-feedback active:border-[#d3b890] active:bg-[#fffaf2]"
|
||||
class="min-w-0 overflow-hidden rounded-[12px] border border-[#e8e1d7] bg-white tap-feedback active:border-[#d3b890] active:bg-[#fffaf2]"
|
||||
:aria-label="`${vehicle.title},${vehicle.description}`"
|
||||
@click="$emit('select', vehicle)"
|
||||
>
|
||||
|
||||
@@ -9,25 +9,20 @@
|
||||
</view>
|
||||
|
||||
<view class="mx-[8.5%] grid grid-cols-2 gap-3">
|
||||
<view
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="relative h-[220px] overflow-hidden rounded-[4px] bg-[#e8e5df] tap-feedback min-[431px]:h-[234px]"
|
||||
:aria-label="item.title"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
<view v-for="item in items" :key="item.id"
|
||||
class="relative h-[220px] overflow-hidden rounded-[12px] bg-[#e8e5df] tap-feedback min-[431px]:h-[234px]"
|
||||
:aria-label="item.title" @click="$emit('select', item)">
|
||||
<image class="absolute inset-0 block h-full w-full" :src="item.image" mode="aspectFill" />
|
||||
<view class="absolute inset-0 h-full w-full bg-[linear-gradient(180deg,transparent_55%,rgba(10,16,14,0.62)_100%)]" />
|
||||
<text class="absolute bottom-3 left-3 right-3 truncate text-[13px] font-semibold leading-[18px] text-white">{{ item.title }}</text>
|
||||
<view
|
||||
class="absolute inset-0 h-full w-full bg-[linear-gradient(180deg,transparent_55%,rgba(10,16,14,0.62)_100%)]" />
|
||||
<text class="absolute bottom-3 left-3 right-3 truncate text-[13px] font-semibold leading-[18px] text-white">{{
|
||||
item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="mx-[8.5%] mt-[17px] flex h-11 w-[83%] items-center justify-center gap-1 rounded-[4px] border border-[#eeeae4] bg-white p-0 text-[13px] leading-[42px] text-[#7f7770] tap-feedback active:bg-[#fbf5ec] active:text-[#6e6258]"
|
||||
hover-class="bg-[#fbf5ec] text-[#6e6258]"
|
||||
aria-label="查看更多"
|
||||
@click="$emit('more')"
|
||||
>
|
||||
class="mx-[8.5%] mt-[17px] flex h-11 w-[83%] items-center justify-center gap-1 rounded-[50px] border border-[#eeeae4] bg-white p-0 text-[13px] leading-[42px] text-[#7f7770] tap-feedback active:bg-[#fbf5ec] active:text-[#6e6258]"
|
||||
hover-class="bg-[#fbf5ec] text-[#6e6258]" aria-label="查看更多" @click="$emit('more')">
|
||||
<text>查看更多</text>
|
||||
<uni-icons type="right" :size="16" color="#9a9189" />
|
||||
</view>
|
||||
|
||||
@@ -11,24 +11,24 @@ export const homeTeamBuildingMocks: HomeTeamBuilding[] = [
|
||||
{
|
||||
id: "wild-challenge",
|
||||
tag: "户外挑战",
|
||||
title: "山野挑战,共同完成一次极境任务",
|
||||
description: "洞穴、瀑降与协作任务组合,适合 10-30 人团队。",
|
||||
title: "山野挑战,共创极境",
|
||||
description: "洞穴、瀑降与协作,适合 10-30 人。",
|
||||
image: "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient",
|
||||
searchKeyword: "户外团建",
|
||||
},
|
||||
{
|
||||
id: "canyon-teamwork",
|
||||
tag: "团队协作",
|
||||
title: "峡谷溯溪,把默契带进自然",
|
||||
description: "轻户外水线和分组任务,兼顾参与感与安全保障。",
|
||||
title: "峡谷溯溪,默契同行",
|
||||
description: "溯溪与分组协作,兼顾参与感与安全。",
|
||||
image: "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
|
||||
searchKeyword: "峡谷团建",
|
||||
},
|
||||
{
|
||||
id: "village-gathering",
|
||||
tag: "人文聚会",
|
||||
title: "苗寨共聚,换一种方式认识彼此",
|
||||
description: "夜游、长桌宴与在地文化体验,适合企业团建收尾。",
|
||||
title: "苗寨共聚,认识彼此",
|
||||
description: "夜游、长桌宴与文化体验,适合团建收尾。",
|
||||
image: "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
|
||||
searchKeyword: "贵州团建",
|
||||
},
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
<template>
|
||||
<view class="wq-page">
|
||||
<view class="wq-phone">
|
||||
<view class="safe-bottom min-h-screen bg-transparent">
|
||||
<TopHeader title="手机号登录" subtitle="用于查看我的需求" @back="goRoot('/pages/home/index')" />
|
||||
|
||||
<view class="px-4 pt-5">
|
||||
<view class="brand-panel rounded-[28px] p-6 text-white">
|
||||
<view class="relative z-10">
|
||||
<text class="block text-[12px] font-semibold uppercase tracking-[0.12em] text-[#f6dfb7]">WonderQ Member</text>
|
||||
<text class="mt-3 block text-[26px] font-bold leading-8">授权手机号登录</text>
|
||||
<text class="mt-3 block text-[13px] leading-6 text-white/80">用于关联旅行需求、收藏线路和最近浏览记录,手机号仅用于账号识别与服务联系。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mt-4 rounded-[24px] border border-[#245c45]/10 bg-white/90 p-5 shadow-[0_14px_28px_rgba(31,44,37,0.08)]">
|
||||
<view class="mb-5">
|
||||
<text class="block text-[18px] font-bold text-[#1f2c25]">登录后可继续进入我的</text>
|
||||
<text class="mt-2 block text-[13px] leading-5 text-[#718073]">微信会弹出手机号授权面板,同意后完成登录。</text>
|
||||
</view>
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button
|
||||
class="login-phone-action primary-action tap-feedback m-0 w-full text-[15px] font-semibold"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
open-type="getPhoneNumber"
|
||||
@getphonenumber="handleGetPhoneNumber"
|
||||
>
|
||||
手机号授权登录
|
||||
</button>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<button class="login-phone-action primary-action tap-feedback m-0 w-full text-[15px] font-semibold" @click="showMiniappOnly">
|
||||
手机号授权登录
|
||||
</button>
|
||||
<!-- #endif -->
|
||||
|
||||
<text v-if="message" class="mt-4 block text-center text-[12px] leading-5 text-[#80632d]">{{ message }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import TopHeader from "@/components/TopHeader.vue";
|
||||
import { loginWithPhoneCode } from "@/lib/auth";
|
||||
import { goRoot } from "@/lib/navigation";
|
||||
|
||||
type PhoneNumberEvent = {
|
||||
detail: {
|
||||
code?: string;
|
||||
errMsg?: string;
|
||||
errno?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const redirect = ref("/pages/mine/index");
|
||||
const loading = ref(false);
|
||||
const message = ref("");
|
||||
|
||||
function safeRedirect(value: unknown) {
|
||||
if (typeof value !== "string") return "/pages/mine/index";
|
||||
const decoded = decodeURIComponent(value);
|
||||
return decoded.startsWith("/pages/") ? decoded : "/pages/mine/index";
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
redirect.value = safeRedirect(query?.redirect);
|
||||
});
|
||||
|
||||
async function handleGetPhoneNumber(event: PhoneNumberEvent) {
|
||||
const code = event.detail?.code;
|
||||
if (!code) {
|
||||
message.value = "未完成手机号授权,暂时无法进入我的。";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
await loginWithPhoneCode(code);
|
||||
uni.showToast({ title: "登录成功", icon: "none" });
|
||||
uni.redirectTo({ url: redirect.value });
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : "登录失败,请稍后再试。";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showMiniappOnly() {
|
||||
message.value = "请在微信小程序中使用手机号授权登录。";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-phone-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
text-align: center;
|
||||
line-height: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
src="https://images.unsplash.com/photo-1774623703220-b3b5e6d3cedf?auto=format&fit=crop&fm=jpg&q=80&w=1200&h=1800"
|
||||
mode="aspectFill" />
|
||||
<view class="absolute inset-0 bg-[#171513]/45" />
|
||||
<view class="absolute left-6 top-[calc(16px+env(safe-area-inset-top))]">
|
||||
<view class="absolute left-6" :style="brandPositionStyle">
|
||||
<text class="block text-[11px] font-semibold tracking-[0.28em] text-white/90">WONDERQ</text>
|
||||
<text class="mt-1 block text-[10px] tracking-[0.22em] text-white/55">MEMBER SPACE</text>
|
||||
</view>
|
||||
@@ -19,7 +19,8 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="flex min-h-0 flex-1 flex-col items-center px-6 pb-[calc(12px+env(safe-area-inset-bottom))] pt-6 text-center">
|
||||
<view
|
||||
class="flex min-h-0 flex-1 flex-col items-center px-6 pb-[calc(12px+env(safe-area-inset-bottom))] pt-6 text-center">
|
||||
<view class="flex h-12 w-12 items-center justify-center rounded-[14px] border border-[#ded6cd] bg-white/80">
|
||||
<uni-icons type="person" :size="30" color="#6d665f" />
|
||||
</view>
|
||||
@@ -32,18 +33,59 @@
|
||||
<text class="text-[#c8beb5]">·</text>
|
||||
<text>收藏</text>
|
||||
</view>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button
|
||||
class="tap-feedback m-0 mt-5 flex min-h-[48px] w-full max-w-[280px] items-center justify-center gap-2 rounded-[14px] border-0 bg-[#a45a3d] px-6 text-[16px] font-semibold tracking-[0.05em] text-white shadow-none"
|
||||
@click="$emit('login')">
|
||||
open-type="getPhoneNumber"
|
||||
@getphonenumber="handlePhoneLogin"
|
||||
>
|
||||
<uni-icons type="chat" :size="19" color="#fffaf5" />
|
||||
<text>微信授权登录</text>
|
||||
</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<button
|
||||
class="tap-feedback m-0 mt-5 flex min-h-[48px] w-full max-w-[280px] items-center justify-center gap-2 rounded-[14px] border-0 bg-[#a45a3d] px-6 text-[16px] font-semibold tracking-[0.05em] text-white shadow-none"
|
||||
@click="handleFallbackLogin"
|
||||
>
|
||||
<uni-icons type="chat" :size="19" color="#fffaf5" />
|
||||
<text>微信授权登录</text>
|
||||
</button>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits<{
|
||||
login: [];
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
type PhoneLoginEvent = {
|
||||
detail?: {
|
||||
code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const emit = defineEmits<{
|
||||
login: [event: PhoneLoginEvent];
|
||||
}>();
|
||||
|
||||
const menuButtonTop = ref<number | null>(null);
|
||||
const brandPositionStyle = computed(() =>
|
||||
menuButtonTop.value === null ? {} : { top: `${menuButtonTop.value}px` },
|
||||
);
|
||||
|
||||
function syncMenuButtonPosition() {
|
||||
const menuButton = uni.getMenuButtonBoundingClientRect();
|
||||
menuButtonTop.value = menuButton.top;
|
||||
}
|
||||
|
||||
onMounted(syncMenuButtonPosition);
|
||||
|
||||
function handlePhoneLogin(event: PhoneLoginEvent) {
|
||||
emit("login", event);
|
||||
}
|
||||
|
||||
function handleFallbackLogin() {
|
||||
emit("login", { detail: {} });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { getAuthSession } from "@/lib/auth";
|
||||
import { getAuthSession, loginWithPhoneCode } from "@/lib/auth";
|
||||
import { go, goDetail, goRoot, goSearch } from "@/lib/navigation";
|
||||
import { appState, loadAppData, loadPersistedCollections, toggleFavoriteProduct } from "@/lib/store";
|
||||
import type { AuthSession } from "@/lib/types";
|
||||
@@ -27,6 +27,7 @@ type MineTab = "requests" | "favorites" | "history";
|
||||
|
||||
const session = ref<AuthSession | null>(null);
|
||||
const customerLine = ref("可查看当前设备的需求、收藏和浏览记录。");
|
||||
const isLoggingIn = ref(false);
|
||||
const tabs = computed(() => [
|
||||
{ id: "requests" as MineTab, label: "需求", count: appState.latestBooking ? 1 : 0 },
|
||||
{ id: "favorites" as MineTab, label: "收藏", count: appState.favoriteProducts.length },
|
||||
@@ -42,7 +43,37 @@ onShow(() => {
|
||||
void loadAppData();
|
||||
});
|
||||
|
||||
function goLogin() {
|
||||
go("/pages/login/index", { redirect: "/pages/mine/index" });
|
||||
type WechatPhoneNumberEvent = {
|
||||
detail?: {
|
||||
code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function goLogin(event: WechatPhoneNumberEvent) {
|
||||
const code = event.detail?.code?.trim();
|
||||
if (!code || isLoggingIn.value) {
|
||||
if (!code) {
|
||||
uni.showToast({ title: "请完成微信手机号授权", icon: "none" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isLoggingIn.value = true;
|
||||
uni.showLoading({ title: "登录中" });
|
||||
|
||||
try {
|
||||
session.value = await loginWithPhoneCode(code);
|
||||
customerLine.value = `已绑定手机号 ${session.value.customer.phoneMasked}`;
|
||||
loadPersistedCollections();
|
||||
void loadAppData();
|
||||
} catch (error) {
|
||||
uni.showToast({
|
||||
title: error instanceof Error && error.message ? error.message : "微信登录失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
isLoggingIn.value = false;
|
||||
uni.hideLoading();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="no-scrollbar h-full min-h-0 w-[104px] shrink-0 border-r border-[#f0f0f0] bg-white">
|
||||
<view class="pb-[96px]">
|
||||
<button v-for="category in categories" :key="category.id"
|
||||
class="tap-feedback m-0 flex h-[62px] w-full items-center justify-center rounded-none border-0 bg-white px-2 text-center text-[13px] leading-5 text-[#171717] after:border-0"
|
||||
:class="activeId === category.id ? 'border-l-2 border-[#f26424] bg-[#f5f5f5] font-semibold' : 'border-l-2 border-transparent font-medium'"
|
||||
:aria-current="activeId === category.id ? 'page' : undefined" :aria-label="category.label"
|
||||
@click="$emit('select', category.id)">
|
||||
<text>{{ category.label }}</text>
|
||||
</button>
|
||||
<view class="no-scrollbar h-full min-h-0 w-[104px] shrink-0 border-r border-[#f0f0f0] bg-[#f5f5f5]">
|
||||
<view v-for="category in categories" :key="category.id"
|
||||
class="flex h-[62px] w-full items-center justify-center rounded-none border-0 px-2 text-center text-[13px] leading-5 text-[#171717] after:border-0"
|
||||
:class="activeId === category.id ? 'border-r-2 border-[#f26424] bg-white font-semibold' : 'border-r-2 border-transparent font-medium'"
|
||||
:aria-current="activeId === category.id ? 'page' : undefined" :aria-label="category.label"
|
||||
@click="$emit('select', category.id)">
|
||||
<text>{{ category.label }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="tap-feedback m-0 block w-full border-0 bg-transparent p-0 text-center after:border-0"
|
||||
:aria-label="`${route.title},${route.routeCount}条路线`" @click="$emit('select', route)">
|
||||
<view class="relative h-[140px] w-full overflow-hidden rounded-[8px] bg-[#eeeeeb]">
|
||||
<view class="relative h-[140px] w-full overflow-hidden rounded-[12px] bg-[#eeeeeb]">
|
||||
<image class="block h-full w-full" :src="route.image" mode="aspectFill" />
|
||||
<view class="absolute bottom-2 right-2 rounded-[2px] bg-white px-2 py-1">
|
||||
<text class="block text-[10px] leading-3 text-[#151515]">{{ route.routeCount }} 条路线</text>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { goSearch } from "@/lib/navigation";
|
||||
import { goDetail } from "@/lib/navigation";
|
||||
import PlayCategorySidebar from "./components/PlayCategorySidebar.vue";
|
||||
import PlayRouteCard from "./components/PlayRouteCard.vue";
|
||||
import { playCategories, type PlayCategory, type PlayRoute } from "./components/playData";
|
||||
@@ -36,6 +36,6 @@ function selectCategory(id: string) {
|
||||
}
|
||||
|
||||
function selectRoute(route: PlayRoute) {
|
||||
goSearch(route.searchKeyword);
|
||||
goDetail(route as any);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearAuthSession,
|
||||
getAuthSession,
|
||||
isLoggedIn,
|
||||
maskPhone,
|
||||
requireLogin,
|
||||
saveAuthSession,
|
||||
} from "@/lib/auth";
|
||||
|
||||
function installUniStorageMock() {
|
||||
const storage = new Map<string, unknown>();
|
||||
const navigateTo = vi.fn();
|
||||
(globalThis as unknown as { uni: unknown }).uni = {
|
||||
getStorageSync: (key: string) => storage.get(key),
|
||||
setStorageSync: (key: string, value: unknown) => storage.set(key, value),
|
||||
removeStorageSync: (key: string) => storage.delete(key),
|
||||
navigateTo,
|
||||
};
|
||||
return { navigateTo, storage };
|
||||
}
|
||||
|
||||
describe("miniapp auth helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("stores and clears the customer auth session", () => {
|
||||
installUniStorageMock();
|
||||
|
||||
saveAuthSession({
|
||||
token: "customer-token",
|
||||
customer: { id: "customer-test", phoneMasked: "100****0000" },
|
||||
});
|
||||
|
||||
expect(isLoggedIn()).toBe(true);
|
||||
expect(getAuthSession()).toEqual({
|
||||
token: "customer-token",
|
||||
customer: { id: "customer-test", phoneMasked: "100****0000" },
|
||||
});
|
||||
|
||||
clearAuthSession();
|
||||
|
||||
expect(isLoggedIn()).toBe(false);
|
||||
expect(getAuthSession()).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects anonymous users to login with an encoded target", () => {
|
||||
const { navigateTo } = installUniStorageMock();
|
||||
|
||||
const allowed = requireLogin("/pages/mine/index");
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: "/pages/login/index?redirect=%2Fpages%2Fmine%2Findex",
|
||||
});
|
||||
});
|
||||
|
||||
it("masks phone numbers before rendering them on the client", () => {
|
||||
expect(maskPhone("10000000000")).toBe("100****0000");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user