Files
longli-mp/src/pages-sub/detail/index.vue
T

261 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>