uniapp-ZHSQ/pages/index2/index2.vue
2026-07-10 11:16:22 +08:00

674 lines
26 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.

<template>
<view class="home2" v-if="loading">
<!-- ===== 顶部红色头区(状态栏 + 定位 + 搜索 + 分类条) ===== -->
<view class="hd" :style="{ paddingTop: top + 'px' }">
<!-- 定位 -->
<view class="hd-loc" @tap="map">
<image class="hd-loc-icon" src="https://static.hshuishang.com/Index_add.png" mode="widthFix" />
<text class="hd-loc-txt">{{ currentCity.cityName || '选择城市' }}</text>
<text class="hd-loc-arrow">▾</text>
</view>
<!-- 搜索框(新增,本轮仅 UI + 占位,后台好店搜索接口回头接入 handleSearch -->
<view class="hd-search">
<image class="hd-search-icon"
src="https://static.hshuishang.com/property-img-file/com_communitySearchIcon.png" mode="aspectFill" />
<input class="hd-search-input" v-model="keyword" :placeholder="searchPlaceholder"
confirm-type="search" @confirm="handleSearch" />
<view class="hd-search-btn" @tap="handleSearch">搜索</view>
</view>
</view>
<!-- ===== 主体(白底圆角上浮) ===== -->
<view class="bd">
<!-- 轮播图 -->
<view class="banner" v-if="isRecommend && bannerList.length">
<swiper class="banner-swiper" @animationfinish="swipers" autoplay circular>
<swiper-item v-for="(item, index) in bannerList" :key="index" @click="handleAdClick(item)">
<image class="banner-img" :src="item.pic_src" mode="aspectFill" />
</swiper-item>
</swiper>
<view class="banner-dots">
<view :class="['dot', currentIdx == index ? 'on' : '']" v-for="(item, index) in bannerList" :key="index">
</view>
</view>
</view>
<!-- 金刚区(自绘 flex 布局,间距完全可控,不用 u-grid 以免组件自带底部空间) -->
<view class="grid" v-if="isRecommend && tabList.length">
<view class="grid-item" :style="{ width: (100 / (rowNum || 5)) + '%' }"
v-for="(item, index) in tabList" :key="index" @tap="headerServerClick(item)">
<image class="grid-icon" :src="item.icon_src" mode="aspectFill"></image>
<text class="grid-txt">{{ item.title }}</text>
</view>
</view>
<!-- 广告栏:固定三格(左大 + 右上 + 右下),空位显示招商占位 -->
<view class="ads" v-if="isRecommend">
<view class="ads-left">
<image v-if="isRealAd(serverLeftList[0])" :src="serverLeft" mode="aspectFill"
@tap="handleAdClick(serverLeftList[0])" />
<view v-else class="ad-ph">广告招商中</view>
</view>
<view class="ads-right">
<view class="ads-right-item" v-for="(item, index) in serverRightList" :key="index">
<image v-if="isRealAd(item)" :src="item.pic_src" mode="aspectFill" @tap="handleAdClick(item)" />
<view v-else class="ad-ph">广告招商中</view>
</view>
</view>
</view>
<!-- 好店列表标题 -->
<view class="wf-title" v-if="isRecommend">
<!--view class="wf-title-main">
<text class="wf-title-loc" v-if="currentCity.district">{{ currentCity.district }}</text>
附近好店
</view>
<view class="wf-title-tag">一刻钟便利生活圈</view-->
</view>
<!-- 分类条移到瀑布上方上滑时吸顶(top=页头高度)仅瀑布在其下滚动 -->
<scroll-view scroll-x class="hd-cate" v-if="categoryList.length" :style="{ top: cateTop + 'px' }">
<view class="hd-cate-item" :class="{ on: currentTab === 'recommend' }" @tap="selectRecommend">附近</view>
<!-- 分类条改用商家分类表数据(categoryList)与下方好店(merchant_cate_id)一一对应 -->
<view class="hd-cate-item" v-for="(item, index) in categoryList" :key="index"
:class="{ on: currentTab === item.id }" @tap="selectCircle(item)">
{{ item.cate_name }}
</view>
<view class="hd-cate-item" @tap="goMoreLocalLife">更多</view>
</scroll-view>
<!-- 好店瀑布两列图片按原始宽高比制造瀑布效果 -->
<view class="wf">
<view class="wf-col">
<view class="wf-card" v-for="item in leftCol" :key="item.id" @click="Info(item)">
<image class="wf-img" :src="item.showImg" :style="{ height: item.imgH + 'rpx' }" mode="aspectFill" />
<view class="wf-meta">
<view class="wf-name">{{ item.merchant_name }}</view>
<view class="wf-sub">
<view class="wf-stars">
<image v-for="i in 5" :key="i" :src="i < item.rating
? 'https://static.hshuishang.com/property-img-file/local_start1.png'
: 'https://static.hshuishang.com/property-img-file/local_start2.png'" mode="aspectFill"></image>
</view>
<text class="wf-dist">{{ item.distances }}</text>
</view>
<view class="wf-tags">
<text class="wf-tag" v-if="item.refund_property_fee_ratio">买单返岁银</text>
<text class="wf-tag" v-if="item.refund_user_points_ratio">买单返积分</text>
</view>
<view class="wf-addr">{{ item.comAddress }}</view>
</view>
</view>
</view>
<view class="wf-col">
<view class="wf-card" v-for="item in rightCol" :key="item.id" @click="Info(item)">
<image class="wf-img" :src="item.showImg" :style="{ height: item.imgH + 'rpx' }" mode="aspectFill" />
<view class="wf-meta">
<view class="wf-name">{{ item.merchant_name }}</view>
<view class="wf-sub">
<view class="wf-stars">
<image v-for="i in 5" :key="i" :src="i < item.rating
? 'https://static.hshuishang.com/property-img-file/local_start1.png'
: 'https://static.hshuishang.com/property-img-file/local_start2.png'" mode="aspectFill"></image>
</view>
<text class="wf-dist">{{ item.distances }}</text>
</view>
<view class="wf-tags">
<text class="wf-tag" v-if="item.refund_property_fee_ratio">买单返岁银</text>
<text class="wf-tag" v-if="item.refund_user_points_ratio">买单返积分</text>
</view>
<view class="wf-addr">{{ item.comAddress }}</view>
</view>
</view>
</view>
</view>
<view class="wf-more" v-if="merchantHasMore">上拉加载更多</view>
<view class="wf-more" v-else-if="leftCol.length || rightCol.length">没有更多了</view>
<nav-footer :current="0" />
</view>
</view>
</template>
<script>
import {
NavgateTo,
calculateDistance,
} from '../../utils/index';
import {
util,
picUrl,
request,
menuButtonInfo
} from '../../utils/index.js';
import {
apiArr
} from '../../api/community.js';
import {
apiArr as apiArr2
} from '../../api/v2Home.js'
import nav from '../../components/nav/nav';
// 首页广告位常量(对应后台 home_banner_region.ad_position与旧首页保持一致
const AD_POS = {
TOP_BANNER: 1, // 顶部轮播
CARD_LEFT: 2, // 广告栏左(大图)
CARD_RIGHT_TOP: 3, // 广告栏右上
CARD_RIGHT_BOTTOM: 4, // 广告栏右下
MID_LEFT: 5, // 中部广告位左
MID_CENTER_TOP: 6, // 生活圈:中部中上
MID_CENTER: 7, // 生活圈:中部中
MID_CENTER_BOTTOM: 8, // 生活圈:中部中下
MID_RIGHT_TOP: 9, // 生活圈:中部右上
MID_RIGHT_CENTER: 10, // 生活圈:中部右中
MID_RIGHT_BOTTOM: 11, // 生活圈:中部右下
POPUP: 12,
BOTTOM_RECOMMEND: 13,
};
// 瀑布单列图片宽度rpx页面左右各 20rpx 内边距 + 两列间距 20rpx → (750-60)/2 = 345
const WF_COL_W = 345;
// 卡片图片下方文字区的估算高度rpx仅用于两列高度均衡不影响真实布局
const WF_META_H = 150;
export default {
components: { nav },
data() {
return {
loading: false,
top: 0,
localHeight: 44,
cateTop: 0, // 分类条吸顶位置 = 页头(.hd)高度(px)
currentCity: '', // 定位信息
qqmap_key: '',
keyword: '', // 搜索关键字(本轮仅占位)
hotWord: [], // 搜索热词(取第一条做 placeholder
currentIdx: 0,
bannerList: [], // 轮播图位1
tabList: [], // 金刚区
rowNum: 0,
colNum: 0,
// 广告栏位2 / 位3 / 位4
serverLeft: '',
serverLeftList: [],
serverRightList: [],
// 分类条生活圈位6-11
currentTab: 'recommend', // 当前分类条选中项:'recommend' 或某生活圈标题
// 分类(驱动好店列表)
categoryList: [],
currentCategoryId: null,
// 好店瀑布
page_num: 1,
page_size: 10,
leftCol: [],
rightCol: [],
colHeights: [0, 0],
merchantHasMore: false,
loadingMore: false,
_bannerGroups: null,
}
},
computed: {
searchPlaceholder() {
return this.hotWord && this.hotWord.length ? this.hotWord[0] : '搜索好店 / 商家 / 优惠'
},
// 仅「附近」页展示轮播/金刚区/广告栏等;选中具体生活圈时只留瀑布列表
isRecommend() {
return this.currentTab === 'recommend'
}
},
methods: {
/* ---------- 顶部/通用 ---------- */
map() {
uni.navigateTo({ url: '/pages/shopcity/shopcity' });
},
// 搜索:本轮仅占位。后台好店搜索接口接入后,这里改为按 keyword 过滤 getMechantList。
handleSearch() {
const kw = (this.keyword || '').trim()
// TODO: 接后台好店关键字搜索接口(给 getMerchantList 增加 keyword 参数)
uni.showToast({
title: kw ? `搜索「${kw}」功能即将开放` : '请输入关键字',
icon: 'none'
})
},
swipers(e) {
this.currentIdx = e.detail.current
},
NotOpen() {
uni.showModal({ title: '提示', content: '此功能暂未开通!', showCancel: false, complete: () => { } })
},
// 统一广告点击:按后台 promotion_type 分发(与旧首页一致)
handleAdClick(item) {
if (!item) return
switch (item.promotion_type) {
case 3:
if (!item.link_url) return this.NotOpen()
if (item.link_url === '/packages/shopEnter/index/index') return this.headershopEnterClick()
return NavgateTo(item.link_url, { isLogin: false })
case 4:
if (!item.appid) return this.NotOpen()
return uni.navigateToMiniProgram({ appId: item.appid, path: item.link_url || '', envVersion: 'release' })
case 5:
return NavgateTo('/packages/advertising/index/index?id=' + item.id, { isLogin: false })
case 1:
default:
return this.NotOpen()
}
},
// 金刚区点击(与旧首页 headerServerClick 一致)
headerServerClick(e) {
if (!e.link_url) return this.NotOpen()
if (e.link_url === '/packages/shopEnter/index/index') return this.headershopEnterClick()
// #ifdef APP-PLUS
uni.navigateTo({ url: '/pages/webview/webview?url=' + encodeURIComponent(e.link_url) });
// #endif
// #ifdef H5
window.open(e.link_url, '_blank');
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU
if (e.appid) {
uni.navigateToMiniProgram({ appId: e.appid, path: e.link_url, extraData: { data1: 'test' } })
} else {
NavgateTo(e.link_url, { isLogin: false })
}
// #endif
},
// 「附近」对应的分类 id取商家分类表第一个
recommendCateId() {
return this.categoryList.length ? this.categoryList[0].id : null
},
// 点击「附近」:显示默认(第一个)分类内容
async selectRecommend() {
this.currentTab = 'recommend'
const id = this.recommendCateId()
if (id) await this.switchCategory(id)
},
// 点击某商家分类:显示该分类对应的好店瀑布(merchant_cate_id = 该分类 id)
async selectCircle(item) {
this.currentTab = item.id
await this.switchCategory(item.id)
},
// 「更多」:进入本地生活完整列表
goMoreLocalLife() {
NavgateTo('/packages/localLife/index/index', { isLogin: false })
},
async headershopEnterClick() {
if (!uni.getStorageSync('userId')) {
return NavgateTo('/packages/shopEnter/choose/index', { isLogin: false })
}
const res = await request(apiArr2.statusQuery, 'POST', {}, { silent: false })
if (res.status) {
NavgateTo('/packages/shopEnter/auditStatus/index?itemObj=' + JSON.stringify(res))
} else {
NavgateTo('/packages/shopEnter/choose/index')
}
},
/* ---------- 定位 + 首屏取数 ---------- */
init() {
uni.showLoading({ title: '加载中', mask: true })
this.qqmap_key = '55NBZ-MUQYW-EAJRL-YIWPA-ZXCR6-4NBPP'
this.getUserLocation()
},
getUserLocation() {
const that = this
const { qqmap_key } = this
const userlocat = uni.getStorageSync('location')
uni.getLocation({
type: 'wgs84',
success: (res) => {
const latitude = userlocat ? userlocat.lat : res.latitude
const longitude = userlocat ? userlocat.lng : res.longitude
uni.request({
url: `https://apis.map.qq.com/ws/geocoder/v1/?location=${latitude},${longitude}&key=${qqmap_key}&get_poi=0`,
success: async (r) => {
const { data: { result: { address_component, ad_info } } } = r
const preciseLocation = {
cityName: address_component.city,
region: address_component.province + address_component.city + address_component.district,
district: address_component.district + `${address_component.street_number ? address_component.street_number : address_component.street}`,
address: address_component.city + address_component.district + address_component.street_number,
lat: latitude,
lng: longitude,
}
that.currentCity = preciseLocation
uni.setStorageSync('location', preciseLocation)
uni.setStorageSync('ad_code', ad_info.adcode)
// 一次拉取各广告位1-11后续各 getHomeXxx 读缓存
await this.prefetchBannerGroups()
const [bannerList, serverLeft, serverRightList, tabList] = await Promise.all([
this.getHomeBanner(),
this.getServerLeft(),
this.getServerRight(),
this.getButtonNum(), // 金刚区
])
this.bannerList = bannerList
this.serverLeft = serverLeft
this.serverRightList = serverRightList
this.tabList = tabList
// 先渲染首屏(轮播/金刚/广告/分类条),好店瀑布再异步加载填充,
// 避免整页等待瀑布——与原主页一致(原页 loading=true 后才单独加载好店列表)。
this.loading = true
uni.hideLoading()
this.$nextTick(() => setTimeout(() => this.measureLayout(), 60)) // 量测吸顶高度 + 对齐金刚区上下间距
this.getCateList().catch(e => console.warn('好店分类/瀑布加载失败:', e)) // 异步:拉分类 + 加载首个分类的好店瀑布
},
fail: (err) => {
console.warn('地图API解析地址失败:', err)
uni.showToast({ title: '定位失败,请检查网络', icon: 'none' })
uni.hideLoading()
}
})
},
fail: (err) => {
console.warn('获取位置信息失败:', err)
if (err.errMsg && err.errMsg.indexOf('auth deny') >= 0) {
uni.showModal({
title: '提示', content: '需要您授权位置信息才能使用此功能', confirmText: '去授权',
success: (res) => {
if (res.confirm) {
// #ifdef MP-WEIXIN
wx.openSetting();
// #endif
// #ifdef APP-PLUS
uni.openSetting();
// #endif
}
uni.hideLoading()
}
})
} else {
uni.showToast({ title: '定位失败,请重试', icon: 'none' })
uni.hideLoading()
}
}
})
},
/* ---------- 广告位取数(沿用旧首页逻辑) ---------- */
async prefetchBannerGroups(positions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) {
try {
const res = await request(apiArr2.getHomeBannerMulti, 'POST', {
ad_code: Number(uni.getStorageSync('ad_code')),
ad_positions: positions,
longitude: uni.getStorageSync('location').lng,
latitude: uni.getStorageSync('location').lat,
page_size: 50
}, { silent: false })
this._bannerGroups = (res && res.groups) ? res.groups : {}
} catch (e) {
console.warn('批量拉取首页广告失败,回退逐个请求:', e)
this._bannerGroups = null
}
},
async getBannerRows(position) {
if (this._bannerGroups) {
return this._bannerGroups[String(position)] || []
}
const res = await request(apiArr2.getHomeBanner, 'POST', {
ad_code: uni.getStorageSync('ad_code'),
ad_position: position,
longitude: uni.getStorageSync('location').lng,
latitude: uni.getStorageSync('location').lat,
page_num: 1,
page_size: 50
}, { silent: false })
return (res && res.rows) ? res.rows : []
},
withPicUrl(list) {
list.forEach(item => { item.pic_src = picUrl + item.pic_src })
return list
},
filterShowList(list, type) {
if (list && list.length) return list.filter(item => item.show_status == type)
return []
},
async getBannerAt(position, { placeholder = false } = {}) {
const rows = await this.getBannerRows(position)
const [first] = this.withPicUrl(this.filterShowList(rows, 1))
if (first) return first
return placeholder ? { ad_position: position, pic_src: '' } : null
},
async getBannersAt(positions, opts) {
return Promise.all(positions.map(p => this.getBannerAt(p, opts)))
},
async getHomeBanner() {
const rows = await this.getBannerRows(AD_POS.TOP_BANNER)
return this.withPicUrl(this.filterShowList(rows, 1))
},
// 广告栏固定三格:左大(位2) + 右上(位3) + 右下(位4)。
// 某位无「已发布且在投放期」广告时用占位对象补齐,保证版式始终是设计稿的三格。
async getServerLeft() {
const item = await this.getBannerAt(AD_POS.CARD_LEFT, { placeholder: true })
this.serverLeftList = [item]
return item ? item.pic_src : ''
},
async getServerRight() {
// placeholder:true → 始终返回两格空位为占位对象pic_src 为空串)
return this.getBannersAt([AD_POS.CARD_RIGHT_TOP, AD_POS.CARD_RIGHT_BOTTOM], { placeholder: true })
},
// 是否为真实可点广告(有图);占位格不可点
isRealAd(item) {
return !!(item && item.pic_src)
},
/* ---------- 金刚区 ---------- */
async getButtonNum() {
const res = await request(apiArr2.getButtonNum, 'POST', {}, { slice: false })
this.rowNum = res.nav_row_num
this.colNum = res.nav_row_total
return await this.getHomeButton()
},
async getHomeButton() {
const res = await request(apiArr2.getHomeButton, 'POST', {
ad_code: Number(uni.getStorageSync('ad_code')),
page_num: 1,
page_size: 50
}, { slice: false })
const filterRes = this.filterShowList(res?.rows, 1)
const newList = filterRes.slice(0, this.rowNum * this.colNum)
if (newList.length) {
newList.forEach(item => { item.icon_src = picUrl + item.icon_src })
return newList
}
return []
},
/* ---------- 分类 + 好店瀑布 ---------- */
async getCateList() {
const res = await request(apiArr2.getCateList, 'POST', { isShop: 1 }, { slice: false })
if (res.rows && res.rows.length) {
this.categoryList = res.rows
// 默认选中「附近」,内容暂取美食圈
this.currentTab = 'recommend'
this.currentCategoryId = this.recommendCateId() || res.rows[0].id
await this.getMechantList(true)
} else {
this.categoryList = []
}
},
async switchCategory(id) {
if (this.currentCategoryId === id) return
this.currentCategoryId = id
await this.getMechantList(true)
// 不再强制滚动:分类条常驻吸顶,切换时保持用户当前位置,避免突兀的自动滚动
},
// reset=true 表示重新加载(换分类/下拉刷新);否则为触底追加
async getMechantList(reset = false) {
if (this.loadingMore) return
this.loadingMore = true
if (reset) {
this.page_num = 1
this.leftCol = []
this.rightCol = []
this.colHeights = [0, 0]
}
try {
const res = await request(apiArr2.getMerchantList, 'POST', {
merchant_cate_id: this.currentCategoryId,
page_num: this.page_num,
page_size: this.page_size,
})
const latitude = uni.getStorageSync('location').lat
const longitude = uni.getStorageSync('location').lng
const rows = (res && res.rows) ? res.rows : []
rows.forEach((item) => {
item.bigImg = item.album_images?.split(',')
item.showImg = item.album_images ? picUrl + item.album_images.split(',')[0] : ''
const distanceInKm = calculateDistance(latitude, longitude, item.latitude, item.longitude)
item.distances = distanceInKm >= 1
? `${distanceInKm.toFixed(1)}km`
: `${(distanceInKm * 1000).toFixed(1)}m`
item.comAddress = item.ad ? item.ad.ad_name.replace(/,/g, '') + item.address : item.address
})
// 就近排序
rows.sort((a, b) => {
const va = parseFloat(a.distances) * (a.distances.includes('km') ? 1000 : 1)
const vb = parseFloat(b.distances) * (b.distances.includes('km') ? 1000 : 1)
return va - vb
})
this.merchantHasMore = rows.length === this.page_size
if (this.merchantHasMore) this.page_num++
await this.appendMasonry(rows)
} catch (e) {
console.warn('好店列表加载失败:', e)
} finally {
this.loadingMore = false
}
},
// 并行量图(并发比串行快很多)→ 按两列累计高度均衡分列,避免一列过长、另一列底部大片留白。
// 图片仍 mode="widthFix" 自适应显示;此处测量仅用于分列均衡。
async appendMasonry(rows) {
const ratios = await Promise.all(rows.map(item => new Promise(resolve => {
if (!item.showImg) return resolve(0.8)
uni.getImageInfo({
src: item.showImg,
success: info => resolve(info && info.width ? info.height / info.width : 0.8),
fail: () => resolve(0.8)
})
})))
rows.forEach((item, i) => {
item.imgH = Math.round(WF_COL_W * ratios[i]) // 显式高度,即时到位不回流
const target = this.colHeights[0] <= this.colHeights[1] ? 0 : 1
if (target === 0) this.leftCol.push(item)
else this.rightCol.push(item)
this.colHeights[target] += item.imgH + WF_META_H
})
},
// 量测页头真实高度,供分类条吸顶定位
measureLayout() {
uni.createSelectorQuery().in(this)
.select('.hd').boundingClientRect(rect => {
if (rect && rect.height) this.cateTop = Math.round(rect.height)
}).exec()
},
Info(e) {
uni.setStorageSync('merchantInfo', e)
NavgateTo('/packages/localLife/detail/index', { isLogin: false })
},
async getSearchVal() {
try {
const res = await request(apiArr.hotWord, 'POST', {}, { slice: false })
this.hotWord = res.search_hot_word ? res.search_hot_word.split(/[,]/) : []
} catch (e) { this.hotWord = [] }
},
},
onLoad() {
const menu = menuButtonInfo()
this.top = menu.top
this.localHeight = menu.height
// 分类条吸顶位置 = 页头高度:状态栏 top(px) + 页头内容(定位64+搜索8+72+下内边距20=164rpx)换算 px
const sys = uni.getSystemInfoSync()
this.cateTop = Math.round(this.top + 164 * sys.windowWidth / 750)
this.init()
this.getSearchVal()
},
onReady() {
// 用真实渲染高度校正吸顶位置(估算可能有偏差)
this.$nextTick(() => {
uni.createSelectorQuery().in(this).select('.hd').boundingClientRect(rect => {
if (rect && rect.height) this.cateTop = Math.round(rect.height)
}).exec()
})
},
onShow() {
const storageLocation = uni.getStorageSync('location')
if (storageLocation && this.loading) {
// 仅当定位城市/区域「真正变化」时才重新初始化。
// 注意:不能拿 currentCity.address 和 storageLocation.district 比(两者是不同字段,
// 几乎恒为不等),否则每次返回首页都会重载 → 回到顶部、丢失当前分类,体验很差。
const changed = !this.currentCity ||
this.currentCity.cityName !== storageLocation.cityName ||
this.currentCity.district !== storageLocation.district
if (changed) {
this.currentCity = storageLocation
this.init()
}
}
},
onPullDownRefresh() {
this.init()
uni.stopPullDownRefresh()
},
async onReachBottom() {
if (this.merchantHasMore && !this.loadingMore) {
await this.getMechantList(false)
}
}
}
</script>
<style>
@import url("./index2.css");
</style>