Compare commits

...
2 Commits
Author SHA1 Message Date
zoujing d2dab27afa feat: 详情页轮播 2026-09-17 11:05:53 +08:00
zoujing d4790ba82c feat: 首页板块的轮播实现 2026-09-17 11:00:36 +08:00
5 changed files with 89 additions and 37 deletions

No files matched your search

+16
View File
@@ -96,6 +96,21 @@ export const getSectionBannerImages = async (sectionKey) => {
return unique(urls.filter(Boolean)).slice(0, 3);
};
/**
* 首页板块卡轮播图集:**每个分组取一张代表图**(组内首个有主图的条目)。
* 与 getSectionBannerImages 的区别:那个是「组内逐个条目」取前 3 张,
* 可能全落在同一个分组里;首页旅居卡要的是「酒店 / 民宿别院 / 旅居公寓 / 露营基地」
* 四个分组各一张,张数 = 分组数。
*/
export const getSectionGroupCovers = async (sectionKey) => {
const section = await getSection(sectionKey);
if (!section) return [];
const urls = section.groups
.map((g) => g.items.find((it) => it.main_image_url)?.main_image_url)
.filter(Boolean);
return unique(urls);
};
export default {
getSections,
getSection,
@@ -104,4 +119,5 @@ export default {
getFeaturedItems,
getOverviewMapAsset,
getSectionBannerImages,
getSectionGroupCovers,
};
+3 -23
View File
@@ -1,25 +1,21 @@
<script setup>
/**
* 内容条目卡(原 .item)
* - 首页「精选推荐」:显示等级/标签,不显示地址
* - 板块列表页:不显示标签(v0.9 已去掉描述性标签),显示地址行
* - 首页「精选推荐」:名称 + 简介,不显示地址
* - 板块列表页:名称 + 简介 + 地址行
* 等级与描述性标签(`grade` / `tags`)全站不展示 —— 字段保留在数据层,页面不渲染。
* 图片地址一律取 item.main_image_url(只读字段,不拼路径)
*/
import { computed } from "vue";
import { goDetail } from "@/utils/nav";
const props = defineProps({
item: { type: Object, required: true },
showTags: { type: Boolean, default: false },
showAddress: { type: Boolean, default: false },
thumbSize: { type: Number, default: 92 },
});
const emit = defineEmits(["tap"]);
const tags = computed(() => (Array.isArray(props.item?.tags) ? props.item.tags.slice(0, 2) : []));
const grade = computed(() => props.item?.grade || "");
const onTap = () => {
emit("tap", props.item);
if (props.item?.collection && props.item?.id) {
@@ -44,22 +40,6 @@ const onTap = () => {
<view class="min-w-0 flex-1 flex flex-col">
<view class="text-[13.5px] font-semibold leading-[1.35] text-ink line-clamp-2">{{ item.name }}</view>
<view v-if="showTags && (grade || tags.length)" class="flex gap-1 mt-[5px] overflow-hidden">
<text
v-if="grade"
class="shrink-0 whitespace-nowrap text-[9.5px] px-[5px] py-[2px] rounded-[4px] border-[0.5px] border-[rgba(0,181,120,0.2)] bg-brand-soft text-brand-deep"
>
{{ grade }}
</text>
<text
v-for="(t, i) in tags"
:key="i"
class="shrink-0 whitespace-nowrap text-[9.5px] px-[5px] py-[2px] rounded-[4px] border-[0.5px] border-[rgba(62,139,240,0.14)] bg-info-soft text-info-deep"
>
{{ t }}
</text>
</view>
<view v-if="item.description" class="text-hint leading-[1.5] text-ink3 mt-[5px] line-clamp-2">
{{ item.description }}
</view>
+47 -7
View File
@@ -11,16 +11,36 @@
* → 因此**高度由组件自持**(sizeCls,按 variant 给),不依赖父级;
* **宽度**仍由父级在宿主节点上分配(flex-[1.06] / flex-1)。
*/
import { computed } from "vue";
import { computed, ref } from "vue";
import { goCatalog } from "@/utils/nav";
const props = defineProps({
section: { type: Object, required: true },
variant: { type: String, default: "short" },
/**
* 卡内轮播图集。首页旅居卡传「每个分组一张代表图」(4 张),
* 传空则回落为板块封面单图(右二半高卡就是这种)。
*/
covers: { type: Array, default: () => [] },
});
const isTall = computed(() => props.variant === "tall");
/** 轮播图集:有 covers 用 covers,否则单张封面 —— 统一成一个数组,模板只判长度 */
const images = computed(() => {
if (props.covers?.length) return props.covers;
return props.section?.coverImage ? [props.section.coverImage] : [];
});
/** ≥2 张才起轮播:单张时 swiper 没有意义,还会白白多一层节点 */
const hasSlider = computed(() => 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);
};
/**
* ⚠️ 卡片高度**由组件自持**,不吃父级传来的尺寸。
* 原因:小程序自定义组件是「宿主节点 → 内部根节点」两层,父级写在
@@ -66,7 +86,24 @@ const onTap = () => goCatalog(props.section.key);
hover-class="opacity-95"
@click="onTap"
>
<image class="absolute inset-0 w-full h-full" :src="section.coverImage" mode="aspectFill" />
<!-- 图:≥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.coverImage" mode="aspectFill" />
<!-- 遮罩:文字锚顶部 → 顶部最重 -->
<view class="absolute inset-0" :class="isTall ? 'scrim-tile-tall' : 'scrim-tile'" />
@@ -86,11 +123,14 @@ const onTap = () => goCatalog(props.section.key);
<AppIcon name="arrow" tone="white" :size="isTall ? 22 : 17" />
</view>
<!-- 通高卡右下角放轮播指示点 -->
<view v-if="isTall" class="absolute left-[15px] bottom-[23px] flex gap-[5px] z-[2]">
<view class="w-[14px] h-[5px] rounded-full bg-white" />
<view class="w-[5px] h-[5px] rounded-full bg-[rgba(255,255,255,0.45)]" />
<view class="w-[5px] h-[5px] rounded-full bg-[rgba(255,255,255,0.45)]" />
<!-- 通高卡左下角轮播指示点:张数 = 图集张数(旅居卡 4 个分组 → 4 个点) -->
<view v-if="isTall && hasSlider" class="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>
+16 -4
View File
@@ -142,12 +142,24 @@ onShareAppMessage(() => ({
</view>
</view>
<!-- 头图 -->
<!-- 头图:多图自动轮播(≥2 张才启用),点击任意位置预览大图。
⚠️ 点击必须绑在**最外层容器**上,不能只绑 <image>:上下两条 scrim 渐隐层与
指示点都是绝对定位的 <view>,会吃掉落在图片上的 tap,只绑 image 的话
只有中段一条窄带能点开预览。tap 默认冒泡,所以只在最外层绑一次即可
(image 上不要再绑,否则会连触两次)。 -->
<view class="relative overflow-hidden bg-[#0E4432]">
<view class="relative h-[316px]">
<swiper class="w-full h-[316px] bg-[#0E4432]" :current="current" :circular="images.length > 1" @change="onSwiperChange">
<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" @click="onPreview(i)" />
<image class="block w-full h-[316px]" :src="url" mode="aspectFill" />
</swiper-item>
</swiper>
+7 -3
View File
@@ -14,6 +14,8 @@ import { goCatalog, goDetail, previewImages } from "@/utils/nav";
const sections = ref([]);
const featured = ref([]);
const mapAsset = ref(null);
/** 旅居卡轮播图集:住宿 4 个分组各取一张代表图 */
const lodgingCovers = ref([]);
const byKey = (key) => sections.value.find((s) => s.key === key) || {};
@@ -25,14 +27,16 @@ const tileSpecialties = computed(() => byKey("specialties"));
const mapUrl = computed(() => mapAsset.value?.oss_url || "");
const load = async () => {
const [list, feat, map] = await Promise.all([
const [list, feat, map, covers] = await Promise.all([
contentApi.getSections(),
contentApi.getFeaturedItems(),
contentApi.getOverviewMapAsset(),
contentApi.getSectionGroupCovers("lodging"),
]);
sections.value = list || [];
featured.value = feat || [];
mapAsset.value = map;
lodgingCovers.value = covers || [];
};
load();
@@ -122,7 +126,7 @@ const onFeaturedTap = (item) => goDetail(item.collection, item.id);
传不进组件内部,所以高度交给组件自己;这里父级只管**宽度比例**。
右列 108×2 + gap 10 = 226,与外层容器自洽。 -->
<view class="flex gap-[10px] h-[226px] mt-[2px]">
<SectionTile class="flex-[1.06] min-w-0" :section="tileLodging" variant="tall" />
<SectionTile class="flex-[1.06] min-w-0" :section="tileLodging" variant="tall" :covers="lodgingCovers" />
<view class="flex-1 min-w-0 flex flex-col gap-[10px]">
<SectionTile :section="tileFood" variant="short" />
<SectionTile :section="tileSpecialties" variant="short" />
@@ -154,7 +158,7 @@ const onFeaturedTap = (item) => goDetail(item.collection, item.id);
</view>
</view>
<view v-for="(item, i) in featured" :key="item.id" :class="i ? 'mt-[10px]' : ''">
<ContentCard :item="item" show-tags @tap="onFeaturedTap" />
<ContentCard :item="item" @tap="onFeaturedTap" />
</view>
<view class="mt-5 mx-1 text-[10px] leading-[1.7] text-ink3 text-center">内容依据龙里县官方旅游指南整理</view>