feat(mini-app): 清理冗余组件,新增详情页分享与管家交互功能
- 移除 ProductList、ProductCard、SectionHeading、InlineLeadForm、SearchFilterBar 等无用业务组件 - 重构线路详情页操作栏,替换电话按钮逻辑,新增原生分享按钮与页面分享配置 - 新增 ConciergeContactSheet 弹窗组件,实现专属管家咨询交互 - 调整 PlayCategorySidebar 选中态样式,优化侧边栏交互逻辑 - 统一组件导入路径为绝对路径,修复路径错误问题 - 移除详情页自定义导航栏配置,优化页面显示效果 - 优化 DetailHero 组件模板排版,提升代码可读性
This commit is contained in:
@@ -1,17 +1,8 @@
|
||||
<template>
|
||||
<uni-popup
|
||||
ref="popupRef"
|
||||
type="bottom"
|
||||
background-color="transparent"
|
||||
mask-background-color="rgba(0,0,0,0.45)"
|
||||
:is-mask-click="true"
|
||||
:safe-area="false"
|
||||
@change="onPopupChange"
|
||||
>
|
||||
<view
|
||||
v-if="advisor"
|
||||
class="mx-auto max-h-[88vh] w-full max-w-[430px] overflow-y-auto rounded-t-[24px] bg-white px-5 pb-[calc(20px+env(safe-area-inset-bottom))] pt-3"
|
||||
>
|
||||
<uni-popup ref="popupRef" type="bottom" background-color="transparent" mask-background-color="rgba(0,0,0,0.45)"
|
||||
:is-mask-click="true" :safe-area="false" @change="onPopupChange">
|
||||
<view v-if="advisor"
|
||||
class="mx-auto max-h-[88vh] w-full max-w-[430px] overflow-y-auto rounded-t-[24px] bg-white px-5 pb-[calc(20px+env(safe-area-inset-bottom))] pt-3">
|
||||
<view class="mx-auto h-1 w-10 rounded-full bg-[#d9d3cd]" />
|
||||
|
||||
<view class="mt-5 flex items-center justify-between">
|
||||
@@ -19,17 +10,15 @@
|
||||
<text class="block text-[11px] font-semibold tracking-[0.28em] text-[#ee5b2a]">CONTACT ADVISOR</text>
|
||||
<text class="mt-2 block text-[22px] font-semibold text-[#171615]">添加专属管家</text>
|
||||
</view>
|
||||
<view
|
||||
class="tap-feedback flex h-10 w-10 items-center justify-center text-[#4d4945]"
|
||||
aria-label="关闭"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<view class="tap-feedback flex h-10 w-10 items-center justify-center text-[#4d4945]" aria-label="关闭"
|
||||
@click="$emit('close')">
|
||||
<uni-icons type="closeempty" :size="22" color="#4d4945" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mt-5 flex items-center gap-3 rounded-[12px] bg-[#f7f4f0] p-3">
|
||||
<image class="h-12 w-12 shrink-0 rounded-full bg-[#b9a99b] object-cover" :src="advisor.avatar" mode="aspectFill" />
|
||||
<image class="h-12 w-12 shrink-0 rounded-full bg-[#b9a99b] object-cover" :src="advisor.avatar"
|
||||
mode="aspectFill" />
|
||||
<view class="min-w-0">
|
||||
<text class="block text-[15px] font-semibold text-[#171615]">{{ advisor.name }}</text>
|
||||
<text class="mt-1 block text-[10px] font-semibold tracking-[0.12em] text-[#ee5b2a]">{{ advisor.role }}</text>
|
||||
@@ -37,19 +26,15 @@
|
||||
</view>
|
||||
|
||||
<view class="mt-5 flex flex-col items-center rounded-[16px] border border-[#ece7e1] bg-[#fffdfb] px-5 py-5">
|
||||
<image
|
||||
class="h-[220px] w-[220px]"
|
||||
:src="advisor.qrImage"
|
||||
mode="aspectFit"
|
||||
show-menu-by-longpress
|
||||
alt="管家微信二维码"
|
||||
/>
|
||||
<image class="h-[220px] w-[220px]" :src="advisor.qrImage" mode="aspectFit" show-menu-by-longpress
|
||||
alt="管家微信二维码" />
|
||||
<text class="mt-4 text-center text-[13px] font-semibold text-[#252321]">长按二维码识别,添加管家好友</text>
|
||||
<text class="mt-2 text-center text-[11px] leading-5 text-[#77716b]">添加后可沟通路线、预算、酒店和用车安排</text>
|
||||
</view>
|
||||
|
||||
<view class="mt-4 grid grid-cols-2 gap-2">
|
||||
<view v-for="detail in advisor.details" :key="detail.label" class="flex items-center gap-2 rounded-[10px] bg-[#f7f4f0] px-3 py-3">
|
||||
<view v-for="detail in advisor.details" :key="detail.label"
|
||||
class="flex items-center gap-2 rounded-[10px] bg-[#f7f4f0] px-3 py-3">
|
||||
<uni-icons :type="detail.icon" :size="15" color="#4d4945" />
|
||||
<text class="text-[11px] text-[#4d4945]">{{ detail.label }}</text>
|
||||
</view>
|
||||
@@ -60,7 +45,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import type { ConciergeAdvisor } from "./conciergeTypes";
|
||||
import type { ConciergeAdvisor } from "../pages/concierge/components/conciergeTypes";
|
||||
|
||||
type UniPopupExpose = {
|
||||
open: (direction?: string) => void;
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<view class="mx-4 rounded-[26px] border border-[#1f6b57]/15 bg-white px-4 py-4">
|
||||
<view class="flex items-center justify-center gap-2">
|
||||
<view class="h-px w-8 bg-[#1f6b57]/20" />
|
||||
<text class="block text-[10px] font-semibold uppercase tracking-[0.14em] text-[#1f6b57]">Private tailor</text>
|
||||
<view class="h-px w-8 bg-[#1f6b57]/20" />
|
||||
</view>
|
||||
<text class="mt-2 block text-center text-[20px] font-bold leading-7 text-[#1f2c25]">不知道怎么选?让管家帮你定制</text>
|
||||
<view class="lead-process mt-4 flex items-start px-1">
|
||||
<view v-for="(step, index) in steps" :key="step.label" class="lead-process-step relative flex flex-1 flex-col items-center">
|
||||
<view class="lead-process-icon relative z-10 flex h-5 w-5 items-center justify-center bg-white">
|
||||
<uni-icons :type="step.icon" :size="14" color="#5f7480" />
|
||||
</view>
|
||||
<view v-if="index < steps.length - 1" class="lead-process-line" />
|
||||
<text class="mt-1.5 block text-center text-[12px] font-semibold leading-4 text-[#34464f]">{{ step.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mt-4 rounded-[16px] border border-[#245c45]/10 bg-white px-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]">
|
||||
<input v-model="contact" class="h-11 text-[14px] text-[#1f2c25]" type="text" placeholder="手机号 / 微信号" placeholder-class="text-[#9aa69d]" />
|
||||
</view>
|
||||
<button class="inline-lead-button primary-action tap-feedback m-0 mt-2.5 w-full text-center text-[15px] font-bold text-white" :class="submitting ? 'opacity-70' : ''" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? "提交中..." : "帮我定制" }}
|
||||
</button>
|
||||
<text v-if="message" class="mt-2 block text-center text-[12px] leading-5" :class="message === '已提交' ? 'text-[#245c45]' : 'text-[#c36f21]'">{{ message }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { normalizeContactInput } from "@/lib/data";
|
||||
import { submitLead } from "@/lib/store";
|
||||
|
||||
const props = defineProps<{
|
||||
sourcePage: string;
|
||||
destination?: string;
|
||||
}>();
|
||||
|
||||
const contact = ref("");
|
||||
const message = ref("");
|
||||
const submitting = ref(false);
|
||||
const steps = [
|
||||
{ label: "提交需求", icon: "compose" },
|
||||
{ label: "获取方案", icon: "chat" },
|
||||
{ label: "确认方案", icon: "checkmarkempty" },
|
||||
{ label: "开启旅程", icon: "calendar" },
|
||||
];
|
||||
|
||||
async function submit() {
|
||||
const phone = normalizeContactInput(contact.value);
|
||||
if (!phone) {
|
||||
message.value = "请留下手机号或微信号,方便服务管家联系你。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
const result = await submitLead(
|
||||
{
|
||||
phone,
|
||||
destination: props.destination,
|
||||
sourcePage: props.sourcePage,
|
||||
note: "来自页面内快速定制入口",
|
||||
},
|
||||
false,
|
||||
);
|
||||
submitting.value = false;
|
||||
message.value = result.message ?? (result.ok ? "已提交" : "提交失败");
|
||||
if (result.ok) contact.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inline-lead-button {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
height: 44px !important;
|
||||
min-height: 44px !important;
|
||||
border-radius: 10px !important;
|
||||
padding: 0 !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.lead-process-line {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: calc(50% + 18px);
|
||||
right: calc(-50% + 18px);
|
||||
height: 1px;
|
||||
background: rgba(31, 107, 87, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -1,39 +0,0 @@
|
||||
<template>
|
||||
<view class="tap-feedback m-0 overflow-hidden rounded-[22px] p-0 text-left soft-card"
|
||||
@click="$emit('select', product)">
|
||||
<view class="relative h-[142px] overflow-hidden bg-[#e7efe7]">
|
||||
<image class="block h-full w-full" :src="product.image" mode="aspectFill" lazy-load />
|
||||
</view>
|
||||
<view class="p-3.5">
|
||||
<text class="text-clamp-2 min-h-[42px] text-[15px] font-semibold leading-[21px] text-[#1f2c25] text-justify">{{
|
||||
product.title
|
||||
}}</text>
|
||||
<view class="mt-2 flex flex-wrap gap-1">
|
||||
<text v-for="tag in product.tags.slice(0, 3)" :key="tag"
|
||||
class="rounded-[7px] border border-[#6fb2a7]/70 bg-[#eef8f5] px-1.5 py-0.5 text-[10px] leading-4 text-[#227464]">
|
||||
{{ tag }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="mt-3 flex min-h-[24px] items-end justify-between gap-2">
|
||||
<text class="min-w-0 flex-1 truncate text-[11px] leading-none text-[#718073]">{{ product.subtitle ||
|
||||
product.destinationName || "贵州定制" }}</text>
|
||||
<text class="shrink-0 whitespace-nowrap text-[14px] font-bold leading-none text-[#a86539]">
|
||||
{{ formatProductCardPrice(product.price) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatProductCardPrice } from "@/lib/data";
|
||||
import type { Product } from "@/lib/types";
|
||||
|
||||
defineProps<{
|
||||
product: Product;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
select: [product: Product];
|
||||
}>();
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<view class="grid grid-cols-2 gap-3 px-4">
|
||||
<ProductCard v-for="product in products" :key="product.apiId || product.id" :product="product" @select="$emit('select', product)" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Product } from "@/lib/types";
|
||||
import ProductCard from "./ProductCard.vue";
|
||||
|
||||
defineProps<{
|
||||
products: Product[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
select: [product: Product];
|
||||
}>();
|
||||
</script>
|
||||
@@ -1,321 +0,0 @@
|
||||
<template>
|
||||
<view class="relative z-40 border-b border-[#245c45]/10 bg-[#fffaf2]/95">
|
||||
<view class="search-filter-tabs grid h-11 grid-cols-3 border-b border-[#245c45]/10 bg-white/55">
|
||||
<view v-for="tab in tabs" :key="tab.key" :data-testid="`search-filter-tab-${tab.key}`"
|
||||
class="tap-feedback flex min-w-0 items-center justify-center gap-1 px-2"
|
||||
:class="isPanelMarked(tab.key) ? 'text-[#17877f]' : 'text-[#1f2c25]'" @click="togglePanel(tab.key)">
|
||||
<text class="truncate text-[13px] font-semibold leading-none">{{ tab.label }}</text>
|
||||
<uni-icons :type="activePanel === tab.key ? 'up' : 'down'" :size="11"
|
||||
:color="isPanelMarked(tab.key) ? '#17877f' : '#1f2c25'" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<uni-popup ref="filterPopupRef" class="search-filter-popup" type="top" background-color="transparent"
|
||||
mask-background-color="rgba(0,0,0,0)" :is-mask-click="true" :animation="false" :safe-area="false"
|
||||
@change="onPopupChange">
|
||||
<view class="search-filter-popup-shell" :style="{ marginTop: popupTop }">
|
||||
<view v-if="activePanel === 'sort'" class="search-filter-panel">
|
||||
<view v-for="option in sortOptions" :key="option.key"
|
||||
class="tap-feedback flex min-h-[44px] items-center justify-between border-b border-[#245c45]/10 px-4 last:border-b-0"
|
||||
@click="selectSort(option.key)">
|
||||
<text class="text-[14px] leading-5"
|
||||
:class="modelValue.sortKey === option.key ? 'font-semibold text-[#17877f]' : 'text-[#1f2c25]'">
|
||||
{{ option.label }}
|
||||
</text>
|
||||
<uni-icons v-if="modelValue.sortKey === option.key" type="checkmarkempty" :size="18" color="#17877f" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activePanel === 'price'" class="search-filter-panel">
|
||||
<view class="px-4 pt-3">
|
||||
<text class="block text-[14px] font-semibold leading-5 text-[#1f2c25]">价格</text>
|
||||
<view class="mt-3 flex items-center gap-3">
|
||||
<view class="h-9 flex-1 rounded-[6px] border border-[#245c45]/10 bg-white">
|
||||
<input :value="draftMin" data-testid="search-filter-price-min"
|
||||
class="h-9 px-3 text-center text-[13px] text-[#1f2c25]" type="number" placeholder="最低价"
|
||||
placeholder-class="text-[#b0b8b2]" @input="onDraftMinInput" />
|
||||
</view>
|
||||
<text class="text-[13px] font-semibold text-[#1f2c25]">--</text>
|
||||
<view class="h-9 flex-1 rounded-[6px] border border-[#245c45]/10 bg-white">
|
||||
<input :value="draftMax" data-testid="search-filter-price-max"
|
||||
class="h-9 px-3 text-center text-[13px] text-[#1f2c25]" type="number" placeholder="最高价"
|
||||
placeholder-class="text-[#b0b8b2]" @input="onDraftMaxInput" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mt-6 flex gap-2 border-t border-[#245c45]/10 px-3 py-3">
|
||||
<view class="filter-action tap-feedback flex-1 bg-[#eeeeee] text-[#7b837d]"
|
||||
data-testid="search-filter-price-clear" @click="clearPrice">清空</view>
|
||||
<view class="filter-action tap-feedback flex-[1.55] bg-[#17877f] text-white"
|
||||
data-testid="search-filter-price-apply" @click="applyPrice">
|
||||
查看{{ previewCount }}条线路
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activePanel === 'duration'" class="search-filter-panel">
|
||||
<view class="px-4 pt-3">
|
||||
<text class="block text-[14px] font-semibold leading-5 text-[#1f2c25]">行程天数</text>
|
||||
<view class="mt-2 grid grid-cols-3 gap-2">
|
||||
<view v-for="option in durationOptions" :key="String(option.value)"
|
||||
:data-testid="`search-filter-duration-${option.value}`"
|
||||
class="tap-feedback flex h-9 items-center justify-center rounded-[8px] text-center"
|
||||
:class="draftDuration === option.value ? 'bg-[#17877f] text-white' : 'bg-[#f3f5f4] text-[#1f2c25]'"
|
||||
@click="draftDuration = option.value">
|
||||
<text class="text-[13px] leading-none">{{ option.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mt-6 flex gap-2 border-t border-[#245c45]/10 px-3 py-3">
|
||||
<view class="filter-action tap-feedback flex-1 bg-[#eeeeee] text-[#7b837d]"
|
||||
data-testid="search-filter-duration-clear" @click="clearDuration">清空</view>
|
||||
<view class="filter-action tap-feedback flex-[1.55] bg-[#17877f] text-white"
|
||||
data-testid="search-filter-duration-apply" @click="applyDuration">
|
||||
查看{{ previewCount }}条线路
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, nextTick, ref } from "vue";
|
||||
import type { Product } from "@/lib/types";
|
||||
import {
|
||||
filterAndSortSearchProducts,
|
||||
normalizePriceInput,
|
||||
type SearchDurationValue,
|
||||
type SearchFilterState,
|
||||
type SearchSortKey,
|
||||
} from "@/lib/searchFilters";
|
||||
|
||||
type FilterPanel = "sort" | "price" | "duration";
|
||||
type UniPopupExpose = {
|
||||
open: (direction?: string) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: SearchFilterState;
|
||||
products: Product[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: SearchFilterState];
|
||||
}>();
|
||||
|
||||
const tabs: Array<{ key: FilterPanel; label: string }> = [
|
||||
{ key: "sort", label: "推荐排序" },
|
||||
{ key: "price", label: "参考报价" },
|
||||
{ key: "duration", label: "筛选" },
|
||||
];
|
||||
|
||||
const sortOptions: Array<{ key: SearchSortKey; label: string }> = [
|
||||
{ key: "recommended", label: "推荐排序" },
|
||||
{ key: "salesDesc", label: "近期销量 高 → 低" },
|
||||
{ key: "priceAsc", label: "价格 低 → 高" },
|
||||
{ key: "priceDesc", label: "价格 高 → 低" },
|
||||
];
|
||||
|
||||
const durationOptions: Array<{ label: string; value: SearchDurationValue }> = [
|
||||
{ label: "1日", value: 1 },
|
||||
{ label: "4日", value: 4 },
|
||||
{ label: "5日", value: 5 },
|
||||
{ label: "6日", value: 6 },
|
||||
{ label: "7日", value: 7 },
|
||||
{ label: "8日", value: 8 },
|
||||
{ label: "9日", value: 9 },
|
||||
{ label: "11日", value: 11 },
|
||||
{ label: "12日", value: 12 },
|
||||
{ label: "13日", value: 13 },
|
||||
{ label: "15日及以上", value: "15plus" },
|
||||
];
|
||||
|
||||
const activePanel = ref<FilterPanel | null>(null);
|
||||
const filterPopupRef = ref<UniPopupExpose | null>(null);
|
||||
const popupTop = ref("0px");
|
||||
const draftMin = ref("");
|
||||
const draftMax = ref("");
|
||||
const draftDuration = ref<SearchDurationValue | null>(null);
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const previewState = computed<SearchFilterState>(() => {
|
||||
if (activePanel.value === "price") {
|
||||
return {
|
||||
...props.modelValue,
|
||||
priceMin: normalizePriceInput(draftMin.value),
|
||||
priceMax: normalizePriceInput(draftMax.value),
|
||||
};
|
||||
}
|
||||
|
||||
if (activePanel.value === "duration") {
|
||||
return {
|
||||
...props.modelValue,
|
||||
duration: draftDuration.value,
|
||||
};
|
||||
}
|
||||
|
||||
return props.modelValue;
|
||||
});
|
||||
|
||||
const previewCount = computed(() => filterAndSortSearchProducts(props.products, previewState.value).length);
|
||||
|
||||
function getInputValue(event: InputEvent) {
|
||||
const uniEvent = event as InputEvent & { detail?: { value?: unknown } };
|
||||
if (uniEvent.detail?.value !== undefined) return String(uniEvent.detail.value);
|
||||
|
||||
const target = event.target as { value?: unknown } | null;
|
||||
if (target?.value !== undefined) return String(target.value);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function onDraftMinInput(event: InputEvent) {
|
||||
draftMin.value = getInputValue(event);
|
||||
}
|
||||
|
||||
function onDraftMaxInput(event: InputEvent) {
|
||||
draftMax.value = getInputValue(event);
|
||||
}
|
||||
|
||||
function emitFilter(patch: Partial<SearchFilterState>) {
|
||||
emit("update:modelValue", {
|
||||
...props.modelValue,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
function syncDraft(panel: FilterPanel) {
|
||||
if (panel === "price") {
|
||||
draftMin.value = props.modelValue.priceMin === null ? "" : String(props.modelValue.priceMin);
|
||||
draftMax.value = props.modelValue.priceMax === null ? "" : String(props.modelValue.priceMax);
|
||||
}
|
||||
|
||||
if (panel === "duration") {
|
||||
draftDuration.value = props.modelValue.duration;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePopupTop() {
|
||||
return new Promise<void>((resolve) => {
|
||||
uni
|
||||
.createSelectorQuery()
|
||||
.in(instance?.proxy)
|
||||
.select(".search-filter-tabs")
|
||||
.boundingClientRect((rect) => {
|
||||
const node = rect as { bottom?: number } | null;
|
||||
if (typeof node?.bottom === "number") {
|
||||
popupTop.value = `${Math.max(0, node.bottom)}px`;
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.exec();
|
||||
});
|
||||
}
|
||||
|
||||
async function openPanel(panel: FilterPanel) {
|
||||
syncDraft(panel);
|
||||
activePanel.value = panel;
|
||||
await nextTick();
|
||||
await updatePopupTop();
|
||||
filterPopupRef.value?.open("top");
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
filterPopupRef.value?.close();
|
||||
activePanel.value = null;
|
||||
}
|
||||
|
||||
function togglePanel(panel: FilterPanel) {
|
||||
if (activePanel.value === panel) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
void openPanel(panel);
|
||||
}
|
||||
|
||||
function selectSort(sortKey: SearchSortKey) {
|
||||
emitFilter({ sortKey });
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function clearPrice() {
|
||||
draftMin.value = "";
|
||||
draftMax.value = "";
|
||||
emitFilter({ priceMin: null, priceMax: null });
|
||||
}
|
||||
|
||||
function applyPrice() {
|
||||
emitFilter({
|
||||
priceMin: normalizePriceInput(draftMin.value),
|
||||
priceMax: normalizePriceInput(draftMax.value),
|
||||
});
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function clearDuration() {
|
||||
draftDuration.value = null;
|
||||
emitFilter({ duration: null });
|
||||
}
|
||||
|
||||
function applyDuration() {
|
||||
emitFilter({ duration: draftDuration.value });
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function onPopupChange(event: { show: boolean }) {
|
||||
if (!event.show) activePanel.value = null;
|
||||
}
|
||||
|
||||
function isPanelMarked(panel: FilterPanel) {
|
||||
if (activePanel.value === panel) return true;
|
||||
if (panel === "sort") return props.modelValue.sortKey !== "recommended";
|
||||
if (panel === "price") return props.modelValue.priceMin !== null || props.modelValue.priceMax !== null;
|
||||
return props.modelValue.duration !== null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-filter-tabs {
|
||||
position: relative;
|
||||
z-index: 70;
|
||||
}
|
||||
|
||||
.search-filter-popup-shell {
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:deep(.search-filter-popup.uni-popup) {
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.search-filter-panel {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-bottom: 1px solid rgba(36, 92, 69, 0.1);
|
||||
border-bottom-left-radius: 22px;
|
||||
border-bottom-right-radius: 22px;
|
||||
box-shadow: 0 18px 30px rgba(31, 44, 37, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-action {
|
||||
display: flex !important;
|
||||
box-sizing: border-box;
|
||||
height: 40px !important;
|
||||
min-height: 40px !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<view class="mb-4 px-4">
|
||||
<view class="flex items-end justify-between gap-3">
|
||||
<view class="min-w-0 flex-1">
|
||||
<text class="block truncate text-[20px] font-bold leading-7 text-[#1f2924]">{{ title }}</text>
|
||||
<text v-if="subtitle" class="mt-1 block text-[12px] leading-5 text-[#68766e]">{{ subtitle }}</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="action"
|
||||
class="tap-feedback m-0 min-h-[32px] shrink-0 rounded-[8px] border border-[#1f6b57]/15 bg-[#f4f7f5] px-3 py-1 text-[12px] font-semibold text-[#1f6b57]"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
{{ action }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
eyebrow?: string;
|
||||
action?: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
action: [];
|
||||
}>();
|
||||
</script>
|
||||
@@ -22,8 +22,7 @@
|
||||
{
|
||||
"path": "pages/detail/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "线路详情",
|
||||
"navigationStyle": "custom"
|
||||
"navigationBarTitleText": "线路详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ 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";
|
||||
import ConciergeContactSheet from "./components/ConciergeContactSheet.vue";
|
||||
import ConciergeContactSheet from "@/components/ConciergeContactSheet.vue";
|
||||
import type { ConciergeAdvisor as ConciergeAdvisorData } from "./components/conciergeTypes";
|
||||
|
||||
const principles = [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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')">
|
||||
@click="$emit('call')">
|
||||
<uni-icons type="phone" :size="18" color="#81776f" />
|
||||
<text class="mt-0.5 text-[9px]">电话</text>
|
||||
</view>
|
||||
@@ -14,6 +14,18 @@
|
||||
<text class="mt-0.5 text-[9px]">管家</text>
|
||||
</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" />
|
||||
<text class="mt-0.5 text-[9px]">分享</text>
|
||||
<button
|
||||
open-type="share"
|
||||
plain
|
||||
hover-class="none"
|
||||
aria-label="分享"
|
||||
class="absolute inset-0 z-10 m-0 h-full w-full border-0 bg-transparent p-0 opacity-0"
|
||||
/>
|
||||
</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>
|
||||
@@ -37,8 +49,7 @@ defineProps<{
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
home: [];
|
||||
toggleFavorite: [];
|
||||
call: [];
|
||||
concierge: [];
|
||||
booking: [];
|
||||
}>();
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
<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] 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>
|
||||
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>
|
||||
|
||||
@@ -18,22 +18,26 @@
|
||||
<DetailMediaGallery :images="presentation.gallery" />
|
||||
</scroll-view>
|
||||
|
||||
<DetailActionBar :price="1.68" :favorite="favorite" @home="goHome" @toggle-favorite="toggleFavorite"
|
||||
@concierge="goConcierge" @booking="goBooking(product)" />
|
||||
<DetailActionBar :price="1.68" :favorite="favorite" @call="goCall" @concierge="goConcierge"
|
||||
@booking="goBooking(product)" />
|
||||
|
||||
<ConciergeContactSheet :open="Boolean(selectedAdvisor)" :advisor="selectedAdvisor" @close="handleConciergeClosed" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShareAppMessage, onShow } from "@dcloudio/uni-app";
|
||||
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 ConciergeContactSheet from "@/components/ConciergeContactSheet.vue";
|
||||
import type { ConciergeAdvisor } from "../concierge/components/conciergeTypes";
|
||||
import { createDetailPresentation } from "./components/detailPresentation";
|
||||
import { getDetailMeta, getProductDetailSections } from "@/lib/data";
|
||||
import { goBack, goBooking, goRoot } from "@/lib/navigation";
|
||||
import { getDetailMeta, getProductDetailSections, productKey } from "@/lib/data";
|
||||
import { goBack, goBooking } from "@/lib/navigation";
|
||||
import { findProduct, isProductFavorite, loadAppData, rememberProduct, toggleFavoriteProduct } from "@/lib/store";
|
||||
import type { Product } from "@/lib/types";
|
||||
|
||||
@@ -48,6 +52,12 @@ onShow(() => {
|
||||
void loadAppData();
|
||||
});
|
||||
|
||||
onShareAppMessage(() => ({
|
||||
title: presentation.value.title,
|
||||
path: `/pages/detail/index?product=${encodeURIComponent(productKey(product.value))}`,
|
||||
imageUrl: product.value.image,
|
||||
}));
|
||||
|
||||
const meta = computed(() => getDetailMeta(product.value));
|
||||
const presentation = computed(() => createDetailPresentation(product.value));
|
||||
const detailSections = computed(() => getProductDetailSections(product.value, meta.value));
|
||||
@@ -74,11 +84,28 @@ function toggleFavorite() {
|
||||
|
||||
|
||||
|
||||
function goHome() {
|
||||
goRoot("/pages/home/index");
|
||||
function goCall() {
|
||||
uni.makePhoneCall({ phoneNumber: '13800000000' });
|
||||
}
|
||||
|
||||
function goConcierge() {
|
||||
goRoot("/pages/concierge/index");
|
||||
selectedAdvisor.value = conciergeAdvisor;
|
||||
}
|
||||
|
||||
function handleConciergeClosed() {
|
||||
selectedAdvisor.value = null;
|
||||
}
|
||||
|
||||
const conciergeAdvisor: ConciergeAdvisor = {
|
||||
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: "擅长领域:瀑降 / 探洞" },
|
||||
],
|
||||
};
|
||||
|
||||
const selectedAdvisor = ref<ConciergeAdvisor | null>(null);
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
<template>
|
||||
<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'"
|
||||
<view class="no-scrollbar h-full min-h-0 w-[104px] shrink-0 bg-[#f5f5f5]">
|
||||
<view v-for="(category, index) in categories" :key="category.id"
|
||||
class="relative flex h-[62px] w-full items-center justify-center rounded-none border-0 px-2 text-center text-[13px] leading-5 text-[#171717]"
|
||||
:class="activeId === category.id ? 'bg-white font-semibold' : 'font-medium'"
|
||||
:aria-current="activeId === category.id ? 'page' : undefined" :aria-label="category.label"
|
||||
@click="$emit('select', category.id)">
|
||||
<view v-if="index !== 0 && activeId === category.id"
|
||||
class="absolute right-0 top-[-12px] h-3 w-3 before:block before:h-3 before:w-3 before:rounded-br-[12px] before:content-[''] before:bg-[#f5f5f5] bg-white" />
|
||||
<text>{{ category.label }}</text>
|
||||
<view v-if="index !== categories.length - 1 && activeId === category.id"
|
||||
class="absolute right-0 bottom-[-12px] h-3 w-3 before:block before:h-3 before:w-3 before:rounded-tr-[12px] before:content-[''] before:bg-[#f5f5f5] bg-white" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { PlayCategory } from "./playData";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
categories: PlayCategory[];
|
||||
activeId: string;
|
||||
}>();
|
||||
|
||||
const activeIndex = computed(() => props.categories.findIndex((category) => category.id === props.activeId));
|
||||
|
||||
defineEmits<{
|
||||
select: [id: string];
|
||||
}>();
|
||||
|
||||
Reference in New Issue
Block a user