139 lines
6.0 KiB
Vue
139 lines
6.0 KiB
Vue
<script setup>
|
||
/**
|
||
* 首页板块卡(图片 + 顶部锚定文字 + 圆形箭头)
|
||
* variant: tall(左一通高) | short(右二上下)
|
||
* 结构与文案位置随 variant 变化,用 computed 输出「互斥」的类串,
|
||
* 避免同一属性被两个类同时声明而依赖 CSS 顺序。
|
||
*
|
||
* ⚠️ 尺寸传递(踩过的坑):小程序里自定义组件是「宿主节点 → 根节点」两层,
|
||
* 父级写在 <SectionTile class="…"> 上的尺寸类**只落在宿主节点上**,根节点拿不到。
|
||
* 而本组件内部全是绝对定位,根节点高度一旦是 auto 就塌成 0 → 整块不可见。
|
||
* → 因此**高度由组件自持**(sizeCls,按 variant 给),不依赖父级;
|
||
* **宽度**仍由父级在宿主节点上分配(flex-[1.06] / flex-1)。
|
||
*/
|
||
import { computed, ref } from "vue";
|
||
import { goCatalog } from "@/utils/nav";
|
||
|
||
const props = defineProps({
|
||
/** 首页 /v1/homepage 的 entries[i]:section_key / title / subtitle / image_url / image_urls */
|
||
section: { type: Object, required: true },
|
||
variant: { type: String, default: "short" },
|
||
});
|
||
|
||
const isTall = computed(() => props.variant === "tall");
|
||
|
||
/** 轮播图集:接口给 image_urls(旅居卡 = 各分类代表图),单张或无则回落 image_url */
|
||
const images = computed(() => {
|
||
const list = props.section?.image_urls;
|
||
if (Array.isArray(list) && list.length) return list;
|
||
return props.section?.image_url ? [props.section.image_url] : [];
|
||
});
|
||
/**
|
||
* ≥2 张才起轮播,且**只有通高卡轮播**(首页旅居卡 = 四个住宿分类各一张)。
|
||
* 右二半高卡保持单图 —— 与设计稿一致,接口即使给了多张也不轮。
|
||
*/
|
||
const hasSlider = computed(() => isTall.value && images.value.length > 1);
|
||
|
||
const activeIndex = ref(0);
|
||
/** 取模防越界:图集长度变化(如热更新/接口返回变少)时进度点不会全部熄灭 */
|
||
const activeDot = computed(() => (images.value.length ? activeIndex.value % images.value.length : 0));
|
||
const onSwiperChange = (e) => {
|
||
activeIndex.value = Number(e.detail?.current || 0);
|
||
};
|
||
|
||
/**
|
||
* ⚠️ 卡片高度**由组件自持**,不吃父级传来的尺寸。
|
||
* 原因:小程序自定义组件是「宿主节点 → 内部根节点」两层,父级写在
|
||
* `<SectionTile class="h-[226px]">` 上的类只落在**宿主节点**,内部根节点拿不到;
|
||
* 而本组件内部全是绝对定位,根节点高度一旦是 auto 就塌成 0 → 整块不可见。
|
||
* 若改成内部用 h-full(百分比),又要求宿主恰好有确定高度,链路太脆;
|
||
* 高度本来就是 variant 的固有属性,直接写在这里最稳(宿主会被内容撑开)。
|
||
*/
|
||
const sizeCls = computed(() => (isTall.value ? "h-[226px]" : "h-[108px]"));
|
||
|
||
const shadowText = "[text-shadow:0_1px_3px_rgba(0,0,0,0.55),0_2px_14px_rgba(0,0,0,0.5)]";
|
||
|
||
/** 文字块定位:通高卡稍微下移,半高卡收紧 */
|
||
const titleBoxCls = computed(() =>
|
||
isTall.value
|
||
? "pointer-events-none absolute left-[15px] right-[13px] top-[15px] z-[2]"
|
||
: "pointer-events-none absolute left-[13px] right-[12px] top-3 z-[2]"
|
||
);
|
||
|
||
const titleCls = computed(() =>
|
||
isTall.value
|
||
? `block text-white text-[20px] font-bold tracking-[0.2px] leading-[1.2] ${shadowText}`
|
||
: `block text-white text-[15px] font-bold tracking-[0.4px] leading-[1.2] ${shadowText}`
|
||
);
|
||
|
||
const subCls = computed(() =>
|
||
isTall.value
|
||
? `block text-[rgba(255,255,255,0.94)] text-hint leading-[1.5] mt-[6px] line-clamp-3 ${shadowText}`
|
||
: `block text-[rgba(255,255,255,0.94)] text-[9.5px] leading-[1.45] mt-1 line-clamp-2 ${shadowText}`
|
||
);
|
||
|
||
const arrowCls = computed(() =>
|
||
isTall.value
|
||
? "right-[14px] bottom-[14px] w-11 h-11 border-[rgba(255,255,255,0.6)]"
|
||
: "right-3 bottom-3 w-8 h-8 border-[rgba(255,255,255,0.46)]"
|
||
);
|
||
|
||
const onTap = () => goCatalog(props.section.section_key);
|
||
</script>
|
||
|
||
<template>
|
||
<view
|
||
class="relative rounded-tile overflow-hidden shadow-tile"
|
||
:class="sizeCls"
|
||
hover-class="opacity-95"
|
||
@click="onTap"
|
||
>
|
||
<!-- 图:≥2 张自动轮播(首页旅居卡 = 酒店/民宿/公寓/露营 四个分组各一张),否则单图。
|
||
swiper 用 absolute inset-0 填满卡片 —— 根节点高度由 sizeCls 自持(226px),
|
||
百分比高度链路在小程序里太脆,靠绝对定位拿确定高度最稳。 -->
|
||
<swiper
|
||
v-if="hasSlider"
|
||
class="absolute inset-0 w-full h-full"
|
||
circular
|
||
autoplay
|
||
:interval="3500"
|
||
:duration="500"
|
||
:indicator-dots="false"
|
||
@change="onSwiperChange"
|
||
>
|
||
<swiper-item v-for="url in images" :key="url">
|
||
<image class="w-full h-full" :src="url" mode="aspectFill" />
|
||
</swiper-item>
|
||
</swiper>
|
||
<image v-else class="absolute inset-0 w-full h-full" :src="section.image_url" mode="aspectFill" />
|
||
|
||
<!-- 遮罩:文字锚顶部 → 顶部最重 -->
|
||
<view class="pointer-events-none absolute inset-0" :class="isTall ? 'scrim-tile-tall' : 'scrim-tile'" />
|
||
|
||
<!-- 内描边 -->
|
||
<view class="pointer-events-none absolute inset-0 rounded-tile border-[0.5px] border-[rgba(255,255,255,0.24)]" />
|
||
|
||
<view :class="titleBoxCls">
|
||
<text :class="titleCls">{{ section.title }}</text>
|
||
<text :class="subCls">{{ section.subtitle }}</text>
|
||
</view>
|
||
|
||
<view
|
||
class="pointer-events-none absolute rounded-full flex items-center justify-center z-[2] bg-[rgba(255,255,255,0.2)] border-[0.5px] shadow-[0_6px_16px_rgba(0,0,0,0.22)]"
|
||
:class="arrowCls"
|
||
>
|
||
<AppIcon name="arrow" tone="white" :size="isTall ? 22 : 17" />
|
||
</view>
|
||
|
||
<!-- 通高卡左下角轮播指示点:张数 = 图集张数(旅居卡 4 个分组 → 4 个点) -->
|
||
<view v-if="isTall && hasSlider" class="pointer-events-none absolute left-[15px] bottom-[23px] flex gap-[5px] z-[2]">
|
||
<view
|
||
v-for="(url, i) in images"
|
||
:key="`${url}-dot`"
|
||
class="h-[5px] rounded-full transition-all duration-300"
|
||
:class="i === activeDot ? 'w-[14px] bg-white' : 'w-[5px] bg-[rgba(255,255,255,0.45)]'"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</template>
|