Files
longli-mp/src/components/SectionTile/index.vue
2026-09-20 09:27:05 +08:00

139 lines
6.0 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>
/**
* 首页板块卡(图片 + 顶部锚定文字 + 圆形箭头)
* 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>