261 lines
11 KiB
Vue
261 lines
11 KiB
Vue
<script setup>
|
||
/**
|
||
* 三级 · 商家详情页(需求 §6)
|
||
* 数据来自 GET /v1/merchants/:id/detail —— 一次返回「商家 + 到店权益 + 同类推荐」。
|
||
* 区块顺序:
|
||
* 头图 swiper → 白面板【名称 + 类型徽标 / 营业时间 / 地址条(内嵌复制钮)】
|
||
* → 介绍卡(标题按 section_key 命名)→ 联系方式 → 到店权益(品牌染色卡)
|
||
* → 附近同类推荐 → 底部留白
|
||
* 不展示:tags 标签、设施服务、地图导航(数据无经纬度)。
|
||
*/
|
||
import { computed, ref } from "vue";
|
||
import { onLoad, onPageScroll, onShareAppMessage } from "@dcloudio/uni-app";
|
||
import contentApi from "@/api/content";
|
||
import { BENEFIT_STATUS, SECTION_INTRO_TITLE } from "@/constant";
|
||
import { useBenefitStore } from "@/store";
|
||
import { copyText, goDetail, makeCall, previewImages, requireLogin } from "@/utils/nav";
|
||
import { unique } from "@/utils";
|
||
|
||
const benefitStore = useBenefitStore();
|
||
|
||
const itemId = ref("");
|
||
const merchant = ref(null);
|
||
const benefits = ref([]);
|
||
const related = ref([]);
|
||
const current = ref(0);
|
||
|
||
/** 到店权益折叠:超过 3 张默认只展示前 3 张 */
|
||
const BENEFIT_VISIBLE = 3;
|
||
const benefitsExpanded = ref(false);
|
||
const collapsibleBenefit = computed(() => benefits.value.length > BENEFIT_VISIBLE);
|
||
const shownBenefits = computed(() =>
|
||
benefitsExpanded.value ? benefits.value : benefits.value.slice(0, BENEFIT_VISIBLE)
|
||
);
|
||
|
||
const images = computed(() => {
|
||
if (!merchant.value) return [];
|
||
return unique(
|
||
[merchant.value.main_image_url, ...(merchant.value.gallery_image_urls || [])].filter(Boolean)
|
||
);
|
||
});
|
||
const badge = computed(() => merchant.value?.type_label || "");
|
||
const introTitle = computed(() => SECTION_INTRO_TITLE[merchant.value?.section_key] || "介绍");
|
||
const addressText = computed(() => merchant.value?.address || "");
|
||
const phone = computed(() => merchant.value?.contact_phone || "");
|
||
const businessHours = computed(() => merchant.value?.business_hours || "");
|
||
|
||
/**
|
||
* 卡片通用底:白卡 + 22px 圆角 + 0.5px 描边 + 双侧柔影
|
||
*/
|
||
const CARD = "bg-white rounded-hero p-[18px] mx-3 mt-3 border-[0.5px] border-line shadow-block";
|
||
/** 品牌染色卡(到店权益) */
|
||
const CARD_PROMO =
|
||
"bg-white rounded-hero p-[18px] mx-3 mt-3 border-[0.5px] border-[rgba(0,181,120,0.2)] shadow-block grad-promo";
|
||
|
||
const load = async () => {
|
||
const data = await contentApi.getMerchantDetail(itemId.value);
|
||
if (!data || !data.merchant) {
|
||
uni.showToast({ title: "内容不存在", icon: "none" });
|
||
setTimeout(() => uni.navigateBack(), 800);
|
||
return;
|
||
}
|
||
merchant.value = data.merchant;
|
||
benefits.value = data.benefits?.items || [];
|
||
related.value = data.related_merchants || [];
|
||
};
|
||
|
||
onLoad(async (options) => {
|
||
itemId.value = options?.id || "";
|
||
await load();
|
||
});
|
||
|
||
/**
|
||
* 导航栏滚动渐变:压在头图上时透明 + 白字;向下滚动渐变为主色浅底,并切为深色字。
|
||
* NAV_DISTANCE 是「渐变走完」的滚动距离;跑满后切深色字(浅底上白字不可读)。
|
||
*/
|
||
const NAV_DISTANCE = 180;
|
||
const navProgress = ref(0);
|
||
const navSolid = ref(false);
|
||
|
||
onPageScroll((e) => {
|
||
const raw = Math.min(1, Math.max(0, (e?.scrollTop || 0) / NAV_DISTANCE));
|
||
// 量化到 0.05 —— onPageScroll 触发极频繁,量化能大幅减少 setData 次数
|
||
const p = Math.round(raw * 20) / 20;
|
||
if (p !== navProgress.value) navProgress.value = p;
|
||
const solid = p >= 0.6;
|
||
if (solid !== navSolid.value) navSolid.value = solid;
|
||
});
|
||
|
||
const onSwiperChange = (e) => {
|
||
current.value = e.detail.current;
|
||
};
|
||
|
||
const onPreview = (index) => previewImages(images.value, images.value[index]);
|
||
|
||
const onCopyAddress = () => copyText(addressText.value, "地址已复制");
|
||
|
||
const onCall = () => makeCall(phone.value);
|
||
|
||
/** 到店权益领取 */
|
||
const onClaimBenefit = async (benefit) => {
|
||
if (benefit.status === BENEFIT_STATUS.CLAIMED) {
|
||
uni.showToast({ title: "该权益已领取,可在「我的权益」使用", icon: "none" });
|
||
return;
|
||
}
|
||
if (benefit.status === BENEFIT_STATUS.SOLD_OUT) {
|
||
uni.showToast({ title: "该权益今日名额已发完", icon: "none" });
|
||
return;
|
||
}
|
||
if (!requireLogin()) return;
|
||
await benefitStore.claim(benefit.id);
|
||
await load();
|
||
uni.showToast({ title: "领取成功", icon: "none" });
|
||
};
|
||
|
||
/** 附近推荐点击 */
|
||
const goRelated = (rec) => goDetail(rec.id);
|
||
|
||
onShareAppMessage(() => ({
|
||
title: merchant.value?.name || "龙里文旅",
|
||
path: `/pages-sub/detail/index?id=${itemId.value}`,
|
||
imageUrl: images.value[0] || "",
|
||
}));
|
||
</script>
|
||
|
||
<template>
|
||
<view class="relative min-h-screen w-full overflow-hidden skin-detail">
|
||
<template v-if="merchant">
|
||
<view class="fixed left-0 right-0 top-0 z-[60]">
|
||
<view class="absolute inset-0 bg-brand-soft" :style="{ opacity: navProgress }" />
|
||
<view class="relative">
|
||
<AppStatusBar />
|
||
<AppNavBar :theme="navSolid ? 'dark' : 'light'" bare :glass="!navSolid" :title="merchant.name"
|
||
:subtitle="merchant.type_label" show-back />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="relative overflow-hidden bg-[#0E4432]">
|
||
<view class="relative h-[316px]" @click="onPreview(current)">
|
||
<swiper class="w-full h-[316px] bg-[#0E4432]" :current="current" :circular="images.length > 1"
|
||
:autoplay="images.length > 1" :interval="3500" :duration="500" @change="onSwiperChange">
|
||
<swiper-item v-for="(url, i) in images" :key="i">
|
||
<image class="block w-full h-[316px]" :src="url" mode="aspectFill" />
|
||
</swiper-item>
|
||
</swiper>
|
||
|
||
<view class="pointer-events-none absolute left-0 right-0 top-0 h-[140px] scrim-dtop" />
|
||
<view class="pointer-events-none absolute left-0 right-0 bottom-0 h-[170px] scrim-dbottom" />
|
||
|
||
<view v-if="images.length > 1"
|
||
class="pointer-events-none absolute right-[14px] bottom-[52px] h-[22px] px-[10px] rounded-full flex items-center text-[11px] text-white bg-[rgba(6,26,19,0.44)] border-[0.5px] border-[rgba(255,255,255,0.24)]">
|
||
{{ current + 1 }} / {{ images.length }}
|
||
</view>
|
||
|
||
<view v-if="images.length > 1" class="pointer-events-none absolute left-0 right-0 bottom-[52px] flex justify-center gap-[5px]">
|
||
<view v-for="(url, i) in images" :key="i" class="rounded-full"
|
||
:class="i === current ? 'w-4 h-[5px] bg-white' : 'w-[5px] h-[5px] bg-[rgba(255,255,255,0.5)]'" />
|
||
</view>
|
||
</view>
|
||
|
||
</view>
|
||
|
||
<!-- 白面板上浮 -->
|
||
<view class="relative z-[4] -mt-[34px] rounded-t-panel bg-white pt-7 px-4 pb-[18px] shadow-wcard">
|
||
<view class="flex items-center gap-[9px]">
|
||
<text class="text-[24px] font-bold tracking-[-0.6px] text-ink">{{ merchant.name }}</text>
|
||
<text v-if="badge"
|
||
class="shrink-0 text-hint font-semibold px-2 py-1 rounded-[7px] bg-[rgba(0,181,120,0.1)] text-brand-deep border-[0.5px] border-[rgba(0,181,120,0.2)]">
|
||
{{ badge }}
|
||
</text>
|
||
</view>
|
||
|
||
<view v-if="merchant.english_name" class="mt-px text-[11.5px] text-ink3">{{ merchant.english_name }}</view>
|
||
|
||
<view v-if="businessHours"
|
||
class="mt-2 flex items-center gap-[5px] text-[12px] font-medium text-brand-deep tracking-[0.1px]">
|
||
<AppIcon name="clock" tone="brand" :size="12" />
|
||
<text>营业时间 {{ businessHours }}</text>
|
||
</view>
|
||
|
||
<!-- 地址条(无经纬度 → 不做地图导航,点击复制) -->
|
||
<view v-if="addressText"
|
||
class="mt-3 flex items-center gap-[7px] px-[11px] py-[10px] rounded-[14px] grad-address border-[0.5px] border-[rgba(0,181,120,0.14)] text-[12px] leading-[1.55] text-ink2"
|
||
@click="onCopyAddress">
|
||
<AppIcon name="pin" tone="ink3" :size="14" />
|
||
<text class="flex-1 min-w-0">{{ addressText }}</text>
|
||
<view
|
||
class="shrink-0 ml-auto w-7 h-7 rounded-full flex items-center justify-center bg-white border-[0.5px] border-[rgba(0,181,120,0.22)] shadow-[0_3px_8px_rgba(0,181,120,0.14)]">
|
||
<AppIcon name="copy" tone="brandDeep" :size="14" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 介绍卡(标题按板块命名) -->
|
||
<view v-if="merchant.description" :class="CARD">
|
||
<view class="flex items-center gap-2 text-[16px] font-bold tracking-[-0.3px] text-ink deco-titledot">
|
||
{{ introTitle }}
|
||
</view>
|
||
<view class="mt-[9px] text-[13.5px] leading-[1.9] text-[#4A5560]">{{ merchant.description }}</view>
|
||
</view>
|
||
|
||
<!-- 联系方式 -->
|
||
<view v-if="phone" :class="CARD">
|
||
<view class="flex items-center gap-2 text-[16px] font-bold tracking-[-0.3px] text-ink deco-titledot">
|
||
联系方式
|
||
</view>
|
||
<view class="mt-3 flex items-center justify-between gap-3">
|
||
<view class="flex flex-col gap-[3px] min-w-0">
|
||
<text class="text-[11px] text-ink3 tracking-[0.2px]">预订 / 咨询电话</text>
|
||
<text class="text-title font-bold text-ink tracking-[0.3px]">{{ phone }}</text>
|
||
</view>
|
||
<view
|
||
class="shrink-0 w-11 h-11 rounded-full flex items-center justify-center grad-call shadow-[0_8px_18px_rgba(0,181,120,0.32)]"
|
||
hover-class="opacity-90" @click="onCall">
|
||
<AppIcon name="phone" tone="white" :size="20" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 到店权益(品牌染色卡) -->
|
||
<view v-if="benefits.length" :class="CARD_PROMO">
|
||
<view class="flex items-center gap-2 text-[16px] font-bold tracking-[-0.3px] text-ink deco-titledot">
|
||
到店权益
|
||
</view>
|
||
<BenefitCard v-for="benefit in shownBenefits" :key="benefit.id" :benefit="benefit" mode="detail"
|
||
@action="onClaimBenefit" />
|
||
|
||
<view v-if="collapsibleBenefit"
|
||
class="mt-[11px] h-[34px] rounded-full flex items-center justify-center gap-[3px] text-cap font-semibold text-brand-deep bg-brand-soft border-[0.5px] border-brand-line"
|
||
hover-class="opacity-90" @click="benefitsExpanded = !benefitsExpanded">
|
||
<text>{{ benefitsExpanded ? "收起" : `展开全部权益 · 共 ${benefits.length} 项` }}</text>
|
||
<view class="flex" :class="benefitsExpanded ? '[rotate:-90deg]' : '[rotate:90deg]'">
|
||
<AppIcon name="chev" tone="brandDeep" :size="11" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 附近同类推荐(图上压字卡) -->
|
||
<view v-if="related.length" :class="CARD">
|
||
<view class="flex items-center gap-2 text-[16px] font-bold tracking-[-0.3px] text-ink deco-titledot">
|
||
附近同类推荐
|
||
</view>
|
||
<scroll-view class="mt-[11px] flex flex-row overflow-hidden" scroll-x enable-flex :show-scrollbar="false">
|
||
<view class="flex shrink-0">
|
||
<view v-for="rec in related" :key="rec.id" class="relative shrink-0 w-[154px] h-[102px] mr-[11px]"
|
||
@click="goRelated(rec)">
|
||
<image class="block w-[154px] h-[102px] rounded-[16px] shadow-[0_8px_20px_rgba(8,30,22,0.14)]"
|
||
:src="rec.main_image_url" mode="aspectFill" />
|
||
<view
|
||
class="absolute left-0 right-0 bottom-0 px-[10px] pt-[22px] pb-[9px] rounded-b-[16px] text-white text-[11.5px] font-semibold line-clamp-1 scrim-rec">
|
||
{{ rec.name }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<view class="h-6" />
|
||
</template>
|
||
</view>
|
||
</template>
|