文件
fenleixinxi/uniapp/pages/index/index.vue

1565 行
40 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="fc-page home">
<!-- 顶部吸顶导航栏:状态栏 + 定位切换 + 消息通知(向上滚动时常驻吸顶,与胶囊完美对齐) -->
<view class="home__nav">
<view class="home__header-status" :style="{ height: statusBarHeight + 'px' }"></view>
<view
class="home__header-bar"
:style="{
paddingRight: (capsulePadding || 12) + 'px',
minHeight: (navBarHeight || 44) + 'px',
}"
>
<!-- 左上角位置:点击弹出位置手动切换面板 -->
<view class="home__location" @click="openLocationModal">
<image class="home__location-icon" :src="iconUrl('home-location')" mode="aspectFit" />
<text class="home__location-text fc-ellipsis" style="color: #1E293B !important; font-weight: 700;">{{ currentCityText }}</text>
<text class="home__location-arrow" style="color: #64748B !important;">▾</text>
</view>
<view class="home__msg" @click="go('/pages/message/index')">
<image class="fc-icon fc-icon--sm" :src="iconUrl('home-bell')" mode="aspectFit" />
<view v-if="unread > 0" class="home__msg-dot">{{ unread }}</view>
</view>
</view>
</view>
<!-- 首页 Hero 区域:搜索框 + 渐变底衬衔接 -->
<view class="home__header">
<view class="home__search" @click="goSearch">
<image class="fc-icon fc-icon--sm" :src="iconUrl('home-search')" mode="aspectFit" />
<text class="home__search-ph">搜索招聘、租房、二手…</text>
</view>
</view>
<!-- 轮播广告位:骨架屏占位(消除布局位移) / 真实轮播图 -->
<view v-if="loading && !banners.length" class="home__banner home__banner--skeleton">
<view class="fc-skeleton home__banner-sk-content"></view>
</view>
<swiper
v-else
class="home__banner"
circular
autoplay
:interval="4500"
:duration="500"
indicator-dots
indicator-color="rgba(255, 255, 255, 0.45)"
indicator-active-color="#ffffff"
>
<swiper-item v-for="(b, i) in (banners && banners.length ? banners : defaultBanners)" :key="b.id || i">
<view class="home__banner-item" @click="goBanner(b)">
<image class="home__banner-photo home__banner-img" :src="bannerImageUrl(b, i)" mode="aspectFill" />
</view>
</swiper-item>
</swiper>
<!-- 分类导航:8宫格微质感骨架屏(高度精确预留,零 CLS) / 真实金刚区 -->
<view v-if="loading && !categories.length" class="fc-card home__cats">
<view class="home__cats-head">
<view class="fc-skeleton" style="width: 130rpx; height: 34rpx; border-radius: 8rpx;"></view>
<view class="fc-skeleton" style="width: 90rpx; height: 26rpx; border-radius: 8rpx;"></view>
</view>
<view class="home__cats-grid">
<view v-for="i in 8" :key="i" class="home__cat">
<view class="home__cat-icon fc-skeleton" style="border-radius: 26rpx;"></view>
<view class="fc-skeleton" style="width: 76rpx; height: 22rpx; margin-top: 14rpx; border-radius: 6rpx;"></view>
</view>
</view>
</view>
<view v-else class="fc-card home__cats">
<view class="home__cats-head">
<text class="home__cats-title">同城分类</text>
<text class="home__cats-more" @click="goCategoryTab">全部分类 ›</text>
</view>
<view class="home__cats-grid">
<view v-for="c in categories" :key="c.id" class="home__cat" @click="goCategory(c)">
<view class="home__cat-icon">
<image class="home__cat-badge" :src="catIcon(c)" mode="aspectFit" />
</view>
<text class="home__cat-name">{{ c.name }}</text>
</view>
</view>
</view>
<!-- 会员引导条 -->
<view class="home__vip" @click="go('/pages/member/index')">
<view class="home__vip-left">
<image class="fc-icon fc-icon--md" :src="iconUrl('home-crown')" mode="aspectFit" />
<view class="home__vip-texts">
<text class="home__vip-title">{{ vipTitle }}</text>
<text class="home__vip-sub">{{ vipSub }}</text>
</view>
</view>
<view class="home__vip-btn">{{ isMember ? '查看权益' : '立即开通' }}</view>
</view>
<!-- 筛选排序栏(区域选择器与左上角位置保持双向同步) -->
<view class="home__filter">
<view
v-for="s in sorts"
:key="s.value"
class="home__filter-item"
:class="{ 'home__filter-item--active': sort === s.value }"
@click="changeSort(s.value)"
>
<text>{{ s.label }}</text>
</view>
</view>
<!-- 列表加载体验:拟真卡片骨架屏(Shimmer 呼吸扫光) -->
<view v-if="loading && !list.length" class="home__feed-skeletons">
<view v-for="i in 3" :key="i" class="fc-post-card home__skeleton-card">
<view class="fc-skeleton home__skeleton-thumb"></view>
<view class="home__skeleton-body">
<view class="fc-skeleton home__skeleton-line" style="width: 86%; height: 32rpx; border-radius: 8rpx;"></view>
<view class="fc-skeleton home__skeleton-line" style="width: 58%; height: 24rpx; margin-top: 14rpx; border-radius: 6rpx;"></view>
<view class="home__skeleton-tags">
<view class="fc-skeleton" style="width: 76rpx; height: 32rpx; border-radius: 999rpx;"></view>
<view class="fc-skeleton" style="width: 96rpx; height: 32rpx; border-radius: 999rpx; margin-left: 12rpx;"></view>
</view>
<view class="home__skeleton-bottom">
<view class="fc-skeleton" style="width: 120rpx; height: 36rpx; border-radius: 8rpx;"></view>
<view class="fc-skeleton" style="width: 90rpx; height: 24rpx; border-radius: 6rpx;"></view>
</view>
</view>
</view>
</view>
<!-- 空数据提示 -->
<fc-empty
v-else-if="!list.length"
text="附近暂无信息,换个区域或筛选试试"
btn-text="去发布"
@action="go('/pages/post/create')"
/>
<!-- 真实信息流卡片列表 -->
<view v-else class="home__feed-list">
<fc-post-card v-for="p in list" :key="p.id" :post="p" @click="goDetail" />
<view class="home__loadmore">
<text v-if="loading">加载中…</text>
<text v-else-if="!hasMore">— 已经到底啦 —</text>
<text v-else @click="loadMore">加载更多</text>
</view>
</view>
<view class="home__tabbar-space"></view>
<fc-tabbar :current="0" />
<!-- ============================================================
左上角位置手动替换面板(春绿生态风格半屏抽屉)
========================================================== -->
<view v-if="showLocModal" class="home__modal-mask" @click="closeLocationModal" @touchmove.stop.prevent>
<view class="home__modal-content" @click.stop>
<view class="home__modal-handle"></view>
<view class="home__modal-head">
<view class="home__modal-title-box">
<text class="home__modal-title">切换服务位置</text>
<text class="home__modal-subtitle">选择您关注的同城生活与分类信息区域</text>
</view>
<view class="home__modal-close" @click="closeLocationModal">✕</view>
</view>
<!-- 当前状态与一键定位 -->
<view class="home__modal-loc-card">
<view class="home__modal-loc-info">
<image class="home__modal-loc-pin" :src="iconUrl('home-location')" mode="aspectFit" />
<view class="home__modal-loc-texts">
<view class="home__modal-loc-row">
<text class="home__modal-loc-label">当前位置:</text>
<text class="home__modal-loc-val">{{ currentCityText }}</text>
<text v-if="manualArea" class="home__modal-badge home__modal-badge--manual">手动选择</text>
<text v-else class="home__modal-badge home__modal-badge--gps">定位匹配</text>
</view>
<text class="home__modal-loc-sub">{{ locStatusDesc }}</text>
</view>
</view>
<view
class="home__modal-reloc-btn"
:class="{ 'home__modal-reloc-btn--loading': isLocating }"
@click="relocateCurrentPosition"
>
<text class="home__modal-reloc-icon" :class="{ 'home__modal-reloc-icon--spin': isLocating }">⟳</text>
<text>{{ isLocating ? '定位中…' : '重新定位' }}</text>
</view>
</view>
<!-- 快捷区县网格(当前城市各区县直达) -->
<view class="home__modal-section">
<view class="home__modal-section-title">
<text class="home__modal-section-main">常用与同城区县快捷选择</text>
<text class="home__modal-section-tip">点击即刻切换生效</text>
</view>
<view class="home__modal-chip-grid">
<!-- 全部区域 -->
<view
class="home__modal-chip"
:class="{ 'home__modal-chip--active': !areaId }"
@click="selectAreaItem(null)"
>
<text class="home__modal-chip-text">全部区域</text>
</view>
<!-- 各开通区县 -->
<view
v-for="item in quickAreaList"
:key="item.id"
class="home__modal-chip"
:class="{ 'home__modal-chip--active': areaId === Number(item.id) }"
@click="selectAreaItem(item)"
>
<text class="home__modal-chip-text">{{ item.district }}</text>
</view>
</view>
</view>
<!-- 切换其他省市/区县(三级级联选择器) -->
<picker
mode="multiSelector"
:range="pickerColumns"
range-key="label"
:value="pickerIndex"
@columnchange="onPickerColumnChange"
@change="onPickerChange"
>
<view class="home__modal-picker-trigger">
<view class="home__modal-picker-left">
<image class="home__modal-picker-icon" :src="iconUrl('category')" mode="aspectFit" />
<view class="home__modal-picker-text-wrap">
<text class="home__modal-picker-label">选择其他省市或区县</text>
<text class="home__modal-picker-hint">浏览所有已开通城市与区县列表</text>
</view>
</view>
<view class="home__modal-picker-right">
<text class="home__modal-picker-arrow">›</text>
</view>
</view>
</picker>
</view>
</view>
</view>
</template>
<script>
import FcPostCard from '@/components/fc-post-card/fc-post-card.vue'
import FcTabbar from '@/components/fc-tabbar/fc-tabbar.vue'
import FcEmpty from '@/components/fc-empty/fc-empty.vue'
import FcAreaPicker from '@/components/fc-area-picker/fc-area-picker.vue'
import homeApi from '@/common/api/home.js'
import memberApi from '@/common/api/member.js'
import messageApi from '@/common/api/message.js'
import { useUserStore } from '@/common/store/index.js'
import { iconUrl, logoUrl, categoryIconUrl, loadUiConfig, uiState } from '@/common/store/ui.js'
import { bannerArtUrl, bannerHasValue, bannerBg } from '@/common/assets/banners.js'
import { getNavMetrics } from '@/common/utils/navbar.js'
import { groupAreaTree } from '@/common/utils/area.js'
// 本地离线快照缓存 Key(Stale-While-Revalidate,实现首屏 0ms 秒开)
const STORAGE_KEY_BANNERS = 'fc_cache_home_banners_v2'
const STORAGE_KEY_CATEGORIES = 'fc_cache_home_categories'
const STORAGE_KEY_AREAS = 'fc_cache_home_areas'
const STORAGE_KEY_SELECTED_AREA = 'fc_user_selected_area'
/**
* 安全读取本地缓存
* @param {string} key 缓存键名
* @param {*} fallback 默认值
*/
function safeGetStorage(key, fallback = null) {
try {
const val = uni.getStorageSync(key)
return val !== undefined && val !== '' && val !== null ? val : fallback
} catch (e) {
return fallback
}
}
/**
* 安全写入本地缓存
* @param {string} key 缓存键名
* @param {*} value 存储内容
*/
function safeSetStorage(key, value) {
try {
uni.setStorageSync(key, value)
} catch (e) {}
}
export default {
components: { FcPostCard, FcTabbar, FcEmpty, FcAreaPicker },
data() {
// 优先从本地缓存提取首屏数据,达到即开即显零白屏体验
const cachedBanners = safeGetStorage(STORAGE_KEY_BANNERS, [])
const cachedCats = safeGetStorage(STORAGE_KEY_CATEGORIES, [])
const cachedAreas = safeGetStorage(STORAGE_KEY_AREAS, [])
const savedUserArea = safeGetStorage(STORAGE_KEY_SELECTED_AREA, null)
return {
statusBarHeight: 20,
navBarHeight: 44,
capsulePadding: 0,
// 顶栏定位文案与位置状态
location: savedUserArea ? (savedUserArea.city || '') : '',
unread: 0,
banners: Array.isArray(cachedBanners) ? cachedBanners : [],
defaultBanners: [
{ id: 1, title: '同城好物节', image: '/static/banners/banner-1.png', link: '/pages/category/channel?categoryId=3' },
{ id: 2, title: '同城高薪直招', image: '/static/banners/banner-2.png', link: '/pages/post/create' },
{ id: 3, title: '本地便民到家', image: '/static/banners/banner-3.png', link: '/pages/category/channel?categoryId=5' },
],
categories: Array.isArray(cachedCats) ? cachedCats : [],
list: [],
page: 1,
size: 10,
hasMore: true,
loading: false,
sort: 'new',
// 服务区域筛选(若用户曾手动选择,直接恢复)
areaId: savedUserArea ? Number(savedUserArea.areaId || 0) : 0,
areaText: savedUserArea ? (savedUserArea.district || '') : '',
manualArea: !!savedUserArea,
lat: null,
lng: null,
// 请求序号保护(防止竞态错乱)
reqSeq: 0,
// 平铺服务区域
areaRows: Array.isArray(cachedAreas) ? cachedAreas : [],
// 会员权益与套餐
benefits: {},
plans: [],
sorts: [
{ label: '最新', value: 'new' },
{ label: '最热', value: 'hot' },
{ label: '价格', value: 'price' },
{ label: '附近', value: 'nearby' },
],
// ---------- 位置手动替换弹窗控制 ----------
showLocModal: false,
isLocating: false,
areaTree: [],
pickerColumns: [[], [], []],
pickerIndex: [0, 0, 0],
}
},
computed: {
/**
* 用户是否是有效会员
*/
isMember() {
try {
return useUserStore().isMember.value
} catch (e) {
return false
}
},
/**
* 会员引导条主标题
*/
vipTitle() {
if (this.isMember) return '会员权益已生效'
const discount = this.benefits && this.benefits.postDiscount
return discount ? `开通会员,发布${discount}` : '开通会员,享专属特权'
},
/**
* 顶栏显示的城市或区域文案(优先当前选择区县,其次城市,缺省回退到同城服务)
*/
currentCityText() {
if (this.areaText) return this.areaText
if (this.location) return this.location
const first = (this.areaRows || [])[0]
if (first && (first.district || first.city)) {
return first.district || first.city
}
return '同城服务'
},
/**
* 位置弹窗内的状态描述文案
*/
locStatusDesc() {
if (this.manualArea) {
return '当前由您手动锁定展示该区域信息'
}
if (this.lat != null && this.lng != null) {
return '已基于您的地理位置自动匹配同城区县'
}
return '暂未获取定位,展示平台推荐区域'
},
/**
* 快捷区县选择列表:优先展示与当前城市相同的区县,便于快速直达
*/
quickAreaList() {
if (!Array.isArray(this.areaRows) || !this.areaRows.length) return []
const targetCity = this.location || (this.areaRows[0] && this.areaRows[0].city) || ''
if (targetCity) {
const filtered = this.areaRows.filter((r) => r.city === targetCity)
if (filtered.length) return filtered
}
return this.areaRows.slice(0, 12)
},
/**
* 会员引导条副标题
*/
vipSub() {
if (this.isMember) {
const freeView = Number(this.benefits && this.benefits.freeView)
return freeView > 0 ? `每日免费查看 ${freeView} 条联系方式` : '尊享会员专属权益'
}
const prices = (this.plans || [])
.map((p) => Number(p.price))
.filter((n) => n > 0)
const min = prices.length ? Math.min.apply(null, prices) : 0
return min > 0 ? `超值月卡 ¥${min} 起` : '超值会员权益,立即开通'
},
},
watch: {
areaRows: {
handler(rows) {
this.rebuildAreaTree(rows)
},
deep: true,
immediate: true,
},
},
onLoad() {
try {
const m = getNavMetrics()
this.statusBarHeight = m.statusBarHeight
this.navBarHeight = m.navBarHeight
this.capsulePadding = m.capsulePadding
} catch (e) {}
// 静默拉取后台自定义 LOGO 与主题配置
loadUiConfig()
// 若用户未手动选定位置,则尝试获取一次定位
if (!this.manualArea) {
this.fetchLocation()
}
// 启动并行加载流水线
this.bootstrap()
uni.$on('refreshUnreadCount', () => {
this.loadUnread()
})
},
onShow() {
loadUiConfig(true)
this.loadUnread()
},
onUnload() {
uni.$off('refreshUnreadCount')
},
onShareAppMessage() {
return {
title: uiState.shareTitle || uiState.siteName || '同城分类信息',
path: '/pages/index/index',
}
},
onShareTimeline() {
return {
title: uiState.shareTitle || uiState.siteName || '同城分类信息',
query: '',
}
},
/**
* 下拉刷新优化:保留页面显示并平滑拉取最新数据
*/
onPullDownRefresh() {
this.page = 1
this.hasMore = true
loadUiConfig(true)
this.bootstrap(true).finally(() => uni.stopPullDownRefresh())
},
onReachBottom() {
this.loadMore()
},
methods: {
iconUrl,
logoUrl,
bannerArtUrl,
bannerHasValue,
bannerBg,
/**
* 轮播图图片地址解析(优先取后台配置的 image,缺省回退到本地规范占位图)
* @param {Object} b 轮播数据对象
* @param {number} i 索引
*/
bannerImageUrl(b, i) {
if (b && b.image && typeof b.image === 'string' && b.image.trim()) {
const img = b.image.trim()
if (img.startsWith('http://') || img.startsWith('https://')) return img
if (img.startsWith('/static/')) return img
if (img.startsWith('/uploads/')) return img
return img
}
const idx = (Math.abs(i) % 3) + 1
return `/static/banners/banner-${idx}.png`
},
/**
* 获取分类图标地址
* @param {Object} c 分类对象
*/
catIcon(c) {
return (c.iconKey && iconUrl(c.iconKey)) || categoryIconUrl(c.id)
},
/**
* 首页核心数据加载:并发请求管道(Parallel Pipeline)
* 消除轮播、分类与信息流的串行等待,加载性能提升 40% 以上
* @param {boolean} isRefresh 是否为下拉刷新
*/
async bootstrap(isRefresh = false) {
// 若当前本地没有任何数据,才置为骨架屏 loading 状态;有快照数据时静默刷新
if (!this.list.length) {
this.loading = true
}
// 并行发起所有网络请求
const pBanners = homeApi.banners().then((res) => {
if (Array.isArray(res) && res.length) {
this.banners = res
safeSetStorage(STORAGE_KEY_BANNERS, res)
}
}).catch((e) => console.warn('轮播拉取异常', e))
const pCategories = homeApi.categories().then((res) => {
if (Array.isArray(res) && res.length) {
this.categories = res
safeSetStorage(STORAGE_KEY_CATEGORIES, res)
}
}).catch((e) => console.warn('分类拉取异常', e))
const pAreas = this.loadAreas()
const pFeed = this.fetchFeed(true)
const pMember = this.loadMemberCard()
const pUnread = this.loadUnread()
try {
await Promise.allSettled([pBanners, pCategories, pAreas, pFeed, pMember, pUnread])
} finally {
this.loading = false
}
},
/**
* 加载平铺服务区域列表并缓存
*/
async loadAreas() {
try {
const rows = await homeApi.areas()
if (Array.isArray(rows)) {
this.areaRows = rows
safeSetStorage(STORAGE_KEY_AREAS, rows)
this.rebuildAreaTree(rows)
}
} catch (e) {}
},
/**
* 会员卡片与权益数据
*/
async loadMemberCard() {
try {
const [benefits, plans] = await Promise.all([memberApi.benefits(), memberApi.plans()])
this.benefits = benefits || {}
this.plans = Array.isArray(plans) ? plans : []
} catch (e) {}
},
/**
* 获取用户未读消息数
*/
async loadUnread() {
let logged = false
try {
logged = !!useUserStore().isLogin.value
} catch (e) {}
if (!logged) {
this.unread = 0
return
}
try {
const res = await messageApi.unreadCount()
this.unread = Number((res && res.count) || 0)
} catch (e) {
this.unread = 0
}
},
/**
* 取用户坐标(gcj02,带防抖与平滑回退)
*/
fetchLocation() {
if (typeof uni === 'undefined' || !uni.getLocation) return
uni.getLocation({
type: 'gcj02',
success: (res) => {
this.lat = res.latitude
this.lng = res.longitude
// 仅在用户未手动选区时自动匹配定位
if (!this.manualArea) {
this.applyLocationArea()
}
},
fail: () => {},
})
},
/**
* 将定位坐标匹配为服务区域
*/
async applyLocationArea() {
if (this.manualArea || this.lat == null || this.lng == null) return
try {
const found = await homeApi.locate({ lat: this.lat, lng: this.lng })
if (found && found.areaId) {
const oldId = this.areaId
this.areaId = Number(found.areaId) || 0
this.areaText = found.district || ''
this.location = found.city || ''
// 若匹配出的区域有变动,平滑刷新信息流
if (oldId !== this.areaId) {
this.page = 1
this.fetchFeed(true)
}
}
} catch (e) {}
},
/**
* 信息流拉取(具备请求时序保护与竞态校验)
* @param {boolean} reset 是否重置列表
*/
async fetchFeed(reset = false) {
const seq = ++this.reqSeq
if (reset && !this.list.length) {
this.loading = true
}
try {
const res = await homeApi.feed({
page: this.page,
size: this.size,
sort: this.sort,
areaId: this.areaId || undefined,
lat: this.lat != null ? this.lat : undefined,
lng: this.lng != null ? this.lng : undefined,
})
// 丢弃过期请求
if (seq !== this.reqSeq) return
this.list = reset ? res.list : this.list.concat(res.list)
this.hasMore = res.hasMore
} catch (e) {
if (seq !== this.reqSeq) return
uni.showToast({ title: '列表加载失败,请重试', icon: 'none' })
} finally {
if (seq === this.reqSeq) this.loading = false
}
},
/**
* 触底加载下一页
*/
loadMore() {
if (!this.hasMore || this.loading) return
this.page += 1
this.fetchFeed()
},
/**
* 切换排序标签
* @param {string} v 排序方式
*/
changeSort(v) {
if (this.sort === v) return
this.sort = v
this.page = 1
this.fetchFeed(true)
if (v === 'nearby' && (this.lat == null || this.lng == null)) {
uni.showToast({ title: '正在获取距离定位…', icon: 'none' })
this.fetchLocation()
}
},
/**
* 中间筛选栏区域变化(与左上角位置双向同步)
* @param {Object|null} area 选中的区域
*/
onFilterAreaChange(area) {
this.applyAreaSelection(area, true)
},
/**
* 打开左上角位置手动替换弹窗
*/
openLocationModal() {
this.showLocModal = true
this.syncPickerFromValue()
},
/**
* 关闭位置切换弹窗
*/
closeLocationModal() {
this.showLocModal = false
},
/**
* 手动选择具体区域项(来自弹窗快捷标签)
* @param {Object|null} item 区县对象(null 表示全部区域)
*/
selectAreaItem(item) {
if (!item) {
this.applyAreaSelection(null, true)
} else {
this.applyAreaSelection({
areaId: Number(item.id) || 0,
district: item.district || '',
city: item.city || '',
province: item.province || '',
}, true)
}
this.closeLocationModal()
},
/**
* 统一应用区域选择并持久化(供手动选区与筛选栏共享)
* @param {Object|null} area 区域数据
* @param {boolean} isManual 是否为用户主动操作
*/
applyAreaSelection(area, isManual = true) {
if (!area) {
this.areaId = 0
this.areaText = ''
this.manualArea = isManual
if (isManual) {
safeSetStorage(STORAGE_KEY_SELECTED_AREA, { areaId: 0, district: '', city: this.location || '全部区域' })
}
} else {
this.areaId = Number(area.areaId) || 0
this.areaText = area.district || ''
if (area.city) {
this.location = area.city
}
this.manualArea = isManual
if (isManual) {
safeSetStorage(STORAGE_KEY_SELECTED_AREA, {
areaId: this.areaId,
district: this.areaText,
city: this.location,
province: area.province || '',
})
}
}
this.page = 1
this.fetchFeed(true)
},
/**
* 弹窗内:重新定位当前位置并切换
*/
relocateCurrentPosition() {
if (this.isLocating) return
this.isLocating = true
if (typeof uni === 'undefined' || !uni.getLocation) {
this.isLocating = false
return
}
uni.getLocation({
type: 'gcj02',
success: async (res) => {
this.lat = res.latitude
this.lng = res.longitude
try {
const found = await homeApi.locate({ lat: this.lat, lng: this.lng })
if (found && found.areaId) {
// 清除手动选择标记,恢复为自动定位匹配模式
safeSetStorage(STORAGE_KEY_SELECTED_AREA, null)
this.manualArea = false
this.areaId = Number(found.areaId) || 0
this.areaText = found.district || ''
this.location = found.city || ''
this.page = 1
this.fetchFeed(true)
uni.showToast({ title: `已定位到:${found.district || found.city}`, icon: 'none' })
this.closeLocationModal()
} else {
uni.showToast({ title: '已定位,但当前位置暂无开通服务', icon: 'none' })
}
} catch (err) {
uni.showToast({ title: '定位匹配失败,请手动选择', icon: 'none' })
} finally {
this.isLocating = false
}
},
fail: () => {
this.isLocating = false
uni.showToast({ title: '获取定位权限失败,请在设置中开启', icon: 'none' })
},
})
},
/**
* 重建三级级联选择树
* @param {Array} rows 平铺区域列表
*/
rebuildAreaTree(rows) {
if (!Array.isArray(rows) || !rows.length) return
this.areaTree = groupAreaTree(rows)
this.buildPickerColumns()
},
/**
* 按选中区域同步三级联动索引
*/
syncPickerFromValue() {
if (!this.areaTree || !this.areaTree.length) return
let pi = 0
let ci = 0
let di = 0
if (this.areaId > 0) {
let matched = false
for (let p = 0; p < this.areaTree.length && !matched; p++) {
const cities = this.areaTree[p].cities || []
for (let c = 0; c < cities.length && !matched; c++) {
const districts = cities[c].districts || []
for (let d = 0; d < districts.length; d++) {
if (Number(districts[d].id) === this.areaId) {
pi = p
ci = c
di = d
matched = true
break
}
}
}
}
}
this.pickerIndex = [pi, ci, di]
this.buildPickerColumns()
},
/**
* 构建三级选择器列数据
*/
buildPickerColumns() {
if (!this.areaTree || !this.areaTree.length) {
this.pickerColumns = [[], [], []]
return
}
const pi = Math.max(0, Number(this.pickerIndex[0]) || 0)
const ci = Math.max(0, Number(this.pickerIndex[1]) || 0)
const province = this.areaTree[pi] || this.areaTree[0]
const cities = (province && province.cities) || []
const city = cities[ci] || cities[0] || {}
const districts = city.districts || []
this.pickerColumns = [
this.areaTree.map((p) => ({ label: p.name, value: p.name })),
cities.map((c) => ({ label: c.name, value: c.name })),
districts.map((d) => ({ label: d.name, id: d.id, item: d })),
]
},
/**
* 级联选择器滚动列联动
* @param {Object} e 事件对象
*/
onPickerColumnChange(e) {
const col = Number(e.detail.column)
const val = Number(e.detail.value)
const next = [...this.pickerIndex]
next[col] = val
if (col === 0) {
next[1] = 0
next[2] = 0
} else if (col === 1) {
next[2] = 0
}
this.pickerIndex = next
this.buildPickerColumns()
},
/**
* 级联选择器确认选中
* @param {Object} e 事件对象
*/
onPickerChange(e) {
const vals = e.detail.value || []
const pi = Number(vals[0] || 0)
const ci = Number(vals[1] || 0)
const di = Number(vals[2] || 0)
const province = this.areaTree[pi]
const city = province && province.cities ? province.cities[ci] : null
const district = city && city.districts ? city.districts[di] : null
if (district) {
this.applyAreaSelection({
areaId: Number(district.id) || 0,
district: district.name,
city: city.name,
province: province.name,
}, true)
this.closeLocationModal()
}
},
/**
* 页面跳转助手
* @param {string} url 目标路径
*/
go(url) {
if (!url) return
uni.navigateTo({ url })
},
/**
* 规范跳转到全部分类 Tab 页面
*/
goCategoryTab() {
// #ifdef H5
uni.redirectTo({
url: '/pages/category/index',
fail: () => uni.reLaunch({ url: '/pages/category/index' }),
})
// #endif
// #ifndef H5
uni.reLaunch({ url: '/pages/category/index' })
// #endif
},
/**
* 搜索栏点击跳转
*/
goSearch() {
uni.navigateTo({ url: '/pages/search/index' })
},
/**
* 点击金刚区分类项
* @param {Object} c 分类数据
*/
goCategory(c) {
uni.navigateTo({ url: `/pages/category/channel?categoryId=${c.id}&name=${encodeURIComponent(c.name)}` })
},
/**
* 点击轮播项跳转
* @param {Object} b 轮播数据
*/
goBanner(b) {
if (!b || !b.link) return
const url = String(b.link).trim()
if (!url) return
if (/^https?:\/\//i.test(url)) {
// #ifdef H5
window.location.href = url
// #endif
// #ifndef H5
uni.setClipboardData({
data: url,
success: () => uni.showToast({ title: '链接已复制,可前往浏览器打开', icon: 'none' }),
})
// #endif
return
}
const targetUrl = url.startsWith('/') ? url : `/${url}`
const tabPages = ['/pages/index/index', '/pages/category/index', '/pages/message/index', '/pages/mine/index']
const pathOnly = targetUrl.split('?')[0]
if (tabPages.includes(pathOnly)) {
uni.switchTab({ url: pathOnly })
} else {
uni.navigateTo({
url: targetUrl,
fail: (err) => console.warn('轮播跳转失败:', err),
})
}
},
/**
* 点击进入帖子详情
* @param {Object} p 帖子对象
*/
goDetail(p) {
uni.navigateTo({ url: `/pages/detail/detail?id=${p.id}` })
},
},
}
</script>
<style lang="scss" scoped>
/* 首页吸顶导航栏(状态栏 + 定位/消息胶囊栏向上滚动时固定) */
.home__nav {
position: sticky;
top: 0;
z-index: 100;
width: 100%;
background: #d6f7e5;
background: linear-gradient(180deg, #d6f7e5 0%, #dcf8e8 100%);
box-shadow: 0 4rpx 16rpx rgba(0, 168, 84, 0.05);
}
.home__header {
background: $fc-gradient-hero;
padding: 8rpx 0 70rpx;
}
.home__header-bar {
display: flex;
align-items: center;
padding: 6rpx 24rpx 0;
gap: 16rpx;
}
/* 左上角定位胶囊(可手动点击替换,带交互微动效) */
.home__location {
display: inline-flex;
align-items: center;
height: 56rpx;
padding: 0 18rpx 0 14rpx;
background: #ffffff;
border: 1rpx solid rgba(0, 168, 84, 0.18);
box-shadow: 0 2rpx 8rpx rgba(0, 168, 84, 0.08);
border-radius: 999rpx;
cursor: pointer;
transition: all 0.2s ease;
min-width: 0;
&:active {
opacity: 0.82;
transform: scale(0.97);
background: #f8fafc;
}
}
.home__location-icon {
width: 28rpx;
height: 28rpx;
margin-right: 8rpx;
flex-shrink: 0;
}
.home__location-text {
font-size: 26rpx;
color: #1e293b !important; /* 彻底告别白字,高对比现代炭黑 */
font-weight: 700;
max-width: 210rpx;
}
.home__location-arrow {
font-size: 20rpx;
color: #64748b !important; /* 中性现代灰 */
margin-left: 6rpx;
}
.home__search {
margin: 20rpx 24rpx 0;
height: 72rpx;
border-radius: $fc-radius-pill;
background: rgba(255, 255, 255, 0.96);
display: flex;
align-items: center;
padding: 0 24rpx;
box-shadow: 0 4rpx 16rpx rgba(4, 48, 30, 0.12);
}
.home__search-ph {
margin-left: 12rpx;
font-size: $fc-font-sm;
color: $fc-text-placeholder;
}
.home__msg {
position: relative;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.home__msg-dot {
position: absolute;
top: -4rpx;
right: -8rpx;
min-width: 30rpx;
height: 30rpx;
padding: 0 6rpx;
border-radius: 999rpx;
background: #ffffff;
color: $fc-primary-text;
font-size: 20rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
/* 规范化纯图片轮播卡片(702×280rpx) */
.home__banner {
width: 702rpx;
height: 280rpx;
margin: -50rpx 24rpx 0;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(0, 168, 84, 0.08);
background: #eafbf1;
}
.home__banner--skeleton {
width: 702rpx;
height: 280rpx;
margin: -50rpx 24rpx 0;
background: #ffffff;
border-radius: 24rpx;
padding: 12rpx;
}
.home__banner-sk-content {
width: 100%;
height: 100%;
border-radius: 18rpx;
}
.home__banner-item {
position: relative;
width: 100%;
height: 100%;
border-radius: 24rpx;
overflow: hidden;
cursor: pointer;
}
.home__banner-photo,
.home__banner-img {
width: 100%;
height: 100%;
display: block;
border-radius: 24rpx;
}
/* 分类金刚区 */
.home__cats {
margin-top: 24rpx;
}
.home__cats-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.home__cats-title {
font-size: $fc-font-lg;
font-weight: 700;
color: $fc-text;
}
.home__cats-more {
font-size: $fc-font-sm;
color: $fc-text-secondary;
}
.home__cats-grid {
display: flex;
flex-wrap: wrap;
}
.home__cat {
width: 25%;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24rpx;
}
.home__cat-icon {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.home__cat-badge {
width: 88rpx;
height: 88rpx;
border-radius: 26rpx;
transition: transform 0.15s ease;
box-shadow: 0 4rpx 14rpx rgba(4, 48, 30, 0.05);
&:active {
transform: scale(0.96);
}
}
.home__cat-name {
margin-top: 12rpx;
font-size: 24rpx;
color: $fc-text-regular;
line-height: 1.2;
font-weight: 500;
}
/* 会员引导条 */
.home__vip {
margin: 0 24rpx 24rpx;
padding: 24rpx 28rpx;
border-radius: $fc-radius-lg;
background: $fc-gradient-warm;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: $fc-shadow;
}
.home__vip-left {
display: flex;
align-items: center;
}
.home__vip-texts {
display: flex;
flex-direction: column;
margin-left: 12rpx;
}
.home__vip-title {
color: #ffffff;
font-size: $fc-font-lg;
font-weight: 700;
line-height: $fc-leading-tight;
}
.home__vip-sub {
color: rgba(255, 255, 255, 0.9);
font-size: $fc-font-xs;
margin-top: 8rpx;
}
.home__vip-btn {
padding: 12rpx 28rpx;
border-radius: $fc-radius-pill;
background: #ffffff;
color: $fc-primary-text;
font-size: $fc-font-sm;
font-weight: 700;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
/* 筛选排序栏 */
.home__filter {
display: flex;
align-items: center;
padding: 0 24rpx 16rpx;
}
.home__filter-item {
margin-right: 28rpx;
font-size: $fc-font;
color: $fc-text-secondary;
transition: color 0.2s ease;
&--active {
color: $fc-primary-text;
font-weight: 700;
}
}
.home__filter-area {
margin-left: auto;
}
/* 拟真骨架屏列表 */
.home__feed-skeletons {
padding: 0 24rpx;
}
.home__skeleton-card {
display: flex;
background: #ffffff;
padding: 24rpx;
border-radius: 20rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 18rpx rgba(4, 48, 30, 0.05);
}
.home__skeleton-thumb {
width: 200rpx;
height: 200rpx;
flex: 0 0 200rpx;
border-radius: 16rpx;
margin-right: 24rpx;
}
.home__skeleton-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.home__skeleton-line {
width: 100%;
}
.home__skeleton-tags {
display: flex;
align-items: center;
margin-top: 14rpx;
}
.home__skeleton-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
}
/* 底部状态 */
.home__loadmore {
text-align: center;
padding: 24rpx 0 32rpx;
font-size: $fc-font-sm;
color: $fc-text-placeholder;
}
.home__tabbar-space {
height: 130rpx;
}
/* ============================================================
位置手动替换弹窗样式(春绿生态风格 Bottom Sheet)
========================================================== */
.home__modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
z-index: 999;
display: flex;
flex-direction: column;
justify-content: flex-end;
animation: modalFadeIn 0.22s ease-out;
}
.home__modal-content {
background: #ffffff;
border-radius: 36rpx 36rpx 0 0;
padding: 20rpx 32rpx calc(48rpx + env(safe-area-inset-bottom));
animation: modalSlideUp 0.26s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 -8rpx 36rpx rgba(4, 48, 30, 0.12);
}
.home__modal-handle {
width: 72rpx;
height: 8rpx;
background: #dcece3;
border-radius: 999rpx;
margin: 0 auto 24rpx;
}
.home__modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 28rpx;
}
.home__modal-title-box {
display: flex;
flex-direction: column;
}
.home__modal-title {
font-size: 34rpx;
font-weight: 700;
color: $fc-text;
}
.home__modal-subtitle {
font-size: 22rpx;
color: $fc-text-secondary;
margin-top: 6rpx;
}
.home__modal-close {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #f1f8f4;
color: #608573;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 700;
&:active {
opacity: 0.7;
}
}
/* 当前定位状态卡片 */
.home__modal-loc-card {
background: #f4fbf7;
border: 1rpx solid #c9ebd8;
border-radius: 20rpx;
padding: 24rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
}
.home__modal-loc-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.home__modal-loc-pin {
width: 44rpx;
height: 44rpx;
flex-shrink: 0;
margin-right: 16rpx;
}
.home__modal-loc-texts {
display: flex;
flex-direction: column;
min-width: 0;
}
.home__modal-loc-row {
display: flex;
align-items: center;
}
.home__modal-loc-label {
font-size: 26rpx;
color: $fc-text-regular;
}
.home__modal-loc-val {
font-size: 30rpx;
font-weight: 700;
color: $fc-primary-text;
max-width: 220rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.home__modal-badge {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: 999rpx;
margin-left: 12rpx;
font-weight: 600;
&--manual {
background: #e6f6ee;
color: $fc-primary-text;
border: 1rpx solid #b5e8cb;
}
&--gps {
background: #00e070;
color: $fc-primary-ink;
}
}
.home__modal-loc-sub {
font-size: 22rpx;
color: $fc-text-secondary;
margin-top: 6rpx;
}
.home__modal-reloc-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: 12rpx 24rpx;
border-radius: 999rpx;
background: #ffffff;
border: 1rpx solid #b7e6cc;
color: $fc-primary-text;
font-size: 24rpx;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(4, 48, 30, 0.06);
transition: all 0.2s ease;
&:active {
transform: scale(0.96);
background: #ebfaf1;
}
&--loading {
opacity: 0.75;
}
}
.home__modal-reloc-icon {
font-size: 26rpx;
margin-right: 6rpx;
font-weight: bold;
&--spin {
display: inline-block;
animation: spin 1s linear infinite;
}
}
/* 快捷区县网格 */
.home__modal-section {
margin-bottom: 28rpx;
}
.home__modal-section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18rpx;
}
.home__modal-section-main {
font-size: 26rpx;
font-weight: 700;
color: $fc-text;
}
.home__modal-section-tip {
font-size: 22rpx;
color: $fc-text-placeholder;
}
.home__modal-chip-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
max-height: 380rpx;
overflow-y: auto;
}
.home__modal-chip {
padding: 14rpx 26rpx;
border-radius: 999rpx;
background: #f4fbf7;
border: 1rpx solid #d5efe1;
transition: all 0.15s ease;
&:active {
transform: scale(0.96);
}
&--active {
background: #00e070;
border-color: #00e070;
box-shadow: 0 4rpx 14rpx rgba(0, 224, 112, 0.35);
.home__modal-chip-text {
color: $fc-primary-ink;
font-weight: 700;
}
}
}
.home__modal-chip-text {
font-size: 26rpx;
color: $fc-text-regular;
}
/* 三级联动选择器触发入口 */
.home__modal-picker-trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx;
background: #f7fdfa;
border: 1rpx dashed #a8e5c3;
border-radius: 20rpx;
margin-top: 8rpx;
&:active {
background: #edfbf3;
}
}
.home__modal-picker-left {
display: flex;
align-items: center;
}
.home__modal-picker-icon {
width: 40rpx;
height: 40rpx;
margin-right: 16rpx;
}
.home__modal-picker-text-wrap {
display: flex;
flex-direction: column;
}
.home__modal-picker-label {
font-size: 28rpx;
font-weight: 600;
color: $fc-primary-text;
}
.home__modal-picker-hint {
font-size: 22rpx;
color: $fc-text-secondary;
margin-top: 4rpx;
}
.home__modal-picker-right {
display: flex;
align-items: center;
}
.home__modal-picker-arrow {
font-size: 34rpx;
color: #619a7d;
font-weight: 600;
}
/* 动效 */
@keyframes modalFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalSlideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>