一、创建页面
在pages文件夹下创建index文件夹,下面添加index.vue页面。
二、配置路由
在pages.json中配置首页的信息
{
"path": "pages/index/index",
"style": {
// "navigationBarTitleText": "",
"navigationBarTitleText": "体育馆预约系统",
"enablePullDownRefresh": false,
// 网站类型
"navigationStyle": "custom"
}
},
三、写接口文件(api)
本项目的首页需要写关于以下几个方面的接口函数
1.定位的接口函数(腾讯地图api)
这里我根据腾讯位置服务中提供的一些接口,编写地址的请求函数,主要是IP定位和逆地址解析。
官方文档:
IP定位API文档:
https://lbs.qq.com/service/webService/webServiceGuide/position/webServiceIp
逆地址解析API文档:
https://lbs.qq.com/service/webService/webServiceGuide/address/Gcoder
实现代码:
//IP定位
const IP = '111.206.145.41';
const API_KEY = '你的key';
export function getLocationByIP(a) {
a.$jsonp("https://apis.map.qq.com/ws/geocoder/v1/ip", {
key: API_KEY,
output: 'jsonp',
// ip: IP, //要把这个ip这一行注释掉
// location: '31.973929,119.756208',//可以通过uni.getLocation获取,谷歌浏览器会对定位请求清除,有时候定位准,有时候定位不准会出现初始地址甘肃省,但项目发布上https就行了,不准的时候用其他浏览器测试
// location: '33.67,119.28',
get_poi: '0'
}).then(resp => {
let res = resp;
console.log(JSON.stringify(resp));
let a = resp.result.ad_info;
console.log(JSON.stringify(a));
})
}
//逆地址解析
export async function reverseGeocoding(that, latitude, longitude) {
try {
const resp = await that.$jsonp("https://apis.map.qq.com/ws/geocoder/v1", {
key: API_KEY,
output: 'jsonp',
location: `${latitude},${longitude}`,
get_poi: '0'
});
return resp.result.formatted_addresses.recommend; // 明确返回 recommend
} catch (error) {
console.log("报错啦");
console.error('根据经纬度逆地址解析失败:', error);
throw error; // 重新抛出错误
}
}
2.获取场馆分类的数据
export function getVenueTypes(keyword) {
return httpRequest.request({
url: '接口地址',
method: 'GET',
params: keyword
})
}
3.获取附近场馆列表的数据
// 获取场馆列表
export function getVenueList(venueListReqDTO) {
return httpRequest.request({
url: '接口地址',
method: 'post',
data: venueListReqDTO
})
}
四、开发首页页面
1.顶部区域
实时定位,icon小图标
2.搜索框
3.场馆分类
场馆分类的组件(基础实现和改进版本)
基础版(使用u-scroll-list横向滚动列表):
改进版(使用swiper实现滑动翻页):
4.附近场馆列表
场馆列表的组件(该组件也可以在查询页面的场馆列表渲染时复用)
五、难点介绍
1.实时定位功能的实现
思路:
开发者需要在腾讯位置服务先注册一个账号,然后选择你想要的地图相关功能,为这个功能分配一定的额度,个人开发者每天都有一定量的免费的额度,自己使用是足够的了。下面是腾讯位置服务官网:
https://lbs.qq.com/location/
核心逻辑:
1)优先获取精准定位:
这个项目主要使用了IP定位和逆地址解析两个服务,或者为了更快获取经纬度信息,还可以使用uni.getLocation获取经纬度,这是uniapp的内置方法。成功获取经纬度后,通过腾讯位置服务提供的逆地址解析功能,把经纬度信息解析为具体的地址,并显示在页面顶部的定位栏中。
2)缓存机制:
定位信息这里,还采用了缓存机制,将定位结果(经纬度)
在哪里查看缓存呢?如下图所示,点击应用程序,再展开本地存储,就可以看到你的位置信息已经缓存起来了,这样可以在你接下来再来访问这个页面的时候不用重新定位了,毕竟定位也需要重复请求花费一定的时间和额度。
代码中还实现了基于用户名的隔离缓存策略(避免多账号冲突)
3)降级策略:
若用户拒绝定位权限,尝试通过 IP 定位获取大致位置。
4)交互反馈:
定位过程中显示“定位中…”,成功/失败后更新地址栏,点击地址栏可清空缓存重新定位。
实现代码:
async getLocation() {
this.isLocating = true; // 开始定位,设置状态为定位中
try {
const res = await new Promise((resolve, reject) => {
uni.getLocation({
type: 'wgs84',
success: (res) => {
resolve(res);
},
fail: (err) => {
reject(err);
}
});
});
this.locationInfo = {
latitude: res.latitude,
longitude: res.longitude,
};
console.log('当前位置的纬度:', res.latitude);
console.log('当前位置的经度:', res.longitude);
// 调用逆地址解析函数
try {
const recommend = await reverseGeocoding(this, res.latitude, res.longitude);
// 更新推荐地址
this.recommend = recommend;
// 存储到缓存
const userName = uni.getStorageSync('curUser').userName;
// console.log("userName:" + JSON.stringify(userName));
const cacheKey = `location_${userName}`;
let location = {
latitude: res.latitude,
longitude: res.longitude,
recommend: recommend
};
console.log("location:" + JSON.stringify(location));
uni.setStorageSync(cacheKey, location);
console.log("逆地址解析成功,缓存键:", cacheKey);
} catch (error) {
console.error('逆地址解析失败:', error);
uni.showToast({
title: '逆地址解析失败',
icon: 'none'
});
}
} catch (err) {
console.error('获取位置失败,尝试通过 IP 获取', err);
try {
const location = await getLocation();
if (location) {
this.locationInfo = {
latitude: location.lat,
longitude: location.lng
};
console.log('通过 IP 获取的位置 - 纬度:', location.lat);
console.log('通过 IP 获取的位置 - 经度:', location.lng);
} else {
uni.showToast({
title: '通过 IP 获取位置失败',
icon: 'none'
});
}
} catch (ipErr) {
console.error('通过 IP 获取位置失败', ipErr);
// uni.showToast({
// title: '获取位置失败',
// icon: 'none'
// });
}
} finally {
this.isLocating = false; // 定位结束,无论成功与否,都设置状态为定位结束
}
},
2.场馆分类组件的实现
思路:
可以使用u-scroll-list横向滚动列表:
https://uviewui.com/components/scrollList.html#api
改进版使用swiper:
https://uniapp.dcloud.net.cn/component/swiper.html
实现代码:
<!-- 设置 u-scroll-list 宽度为屏幕宽度 -->
<u-scroll-list direction="horizontal" :show-scrollbar="false" :enhanced="false" style="width: 100vw">
<!-- 按每页 10 个元素分组渲染 -->
<view class="page" v-for="(page, pageIndex) in groupedPages" :key="pageIndex">
<view class="type-row" v-for="(row, rowIndex) in splitIntoRows(page)" :key="rowIndex">
<view class="type-item" v-for="(item, index) in row" :key="index">
<view class="icon-container">
<text class="iconfont" v-html="item.icon"></text>
</view>
<text class="type-name">{{item.value}}</text>
</view>
</view>
</view>
</u-scroll-list>
<swiper class="swiper-container" :current="currentPage" :circular="false"
:display-multiple-items="1" :indicator-dots="false">
<swiper-item v-for="(page, pageIndex) in groupedPages" :key="pageIndex">
<view class="page">
<view class="type-row" v-for="(row, rowIndex) in splitIntoRows(page)" :key="rowIndex">
<view class="type-item" v-for="(item, index) in row" :key="index"
>
<view class="icon-container">
<text class="iconfont" v-html="item.icon"></text>
</view>
<text class="type-name"
:style="{ color: selectedType === item.value ? 'blue' : 'inherit' }">{{item.value}}</text>
</view>
</view>
</view>
</swiper-item>
</swiper>
3.附近场馆列表组件的实现
思路:
1)将场馆列表单独封装成组件,通过props接收数据。
2)用户体验:通过图片懒加载、文字截断处理(省略号)、开放时间分开显示等美化组件的布局,提升用户体验。
实现代码:
<!-- 场馆列表 -->
<view class="venue-list">
<view class="venue-row">
<view class="venue-item" v-for="(item,index) in venueList" :key="index" @click="goToVenueDetail(item.id)">
<!-- 图片容器,添加加载效果 -->
<view class="image-container">
<image class="venue-image"
:src="item.pictureList && item.pictureList.length > 0 ? urlConstruct(item.pictureList[0].url) : '{{item.url}}'"
lazy-load="true" mode="aspectFill" @error="onImageError(index)"
@load="onImageLoad(index)">
</image>
<!-- 加载动画 -->
<view class="image-loading" v-if="!imageLoaded[index]">
<u-loading-icon mode="circle" color="#2979ff" size="24"></u-loading-icon>
</view>
</view>
<view class="venue-info">
<view class="venue-name-tag">
<view class="venue-name">{{truncateName(item.name)}}</view>
<view class="venue-tags">
<text class="tag">{{item.typeName}}</text>
</view>
</view>
<view class="venue-meta">
<view class="map-distance">
<u-icon name="map" color="#666" size="13"></u-icon>
<text>{{item.distance ? parseFloat(item.distance).toFixed(1) : '0.0'}}km</text>
<text class="text-ellipsis">{{item.address}}</text>
</view>
</view>
<view class="venue-contact"></view>
<view class="venue-hours">
<view class="icon-text-container">
<u-icon name="clock" color="#666" size="12"></u-icon>
<span style="margin-left: 3px;">{{truncateOpenTimeFirstLine(item.openTime)}}</span>
</view>
<span class="remaining-open-time">{{truncateOpenTimeRemaining(item.openTime)}}</span>
</view>
</view>
</view>
</view>
</view>
4.分页加载
思路:
1)初始化状态
2)首屏数据加载
3)滚动监听触发
4)分页请求处理
5)边界状态管理
数据加载完毕的判定和异常错误处理
核心逻辑:
1)数据结构设计:
venueListData.data
存储分页数据(包含 current/size/total/records 字段)page
对象维护当前页码(pageNum)和分页大小(pageSize)loadmoreStatus
控制加载状态(loadmore/loading/nomore/error)
2)核心触发机制:
通过onReachBottom生命周期监听滚动到底部事件
滚动位置通过onPageScroll实时更新,用于控制返回顶部按钮
3)细节:
页码计算采用
current = pageNum - 1
的转换逻辑(适配后端0-based分页)使用数组合并策略:
records = [...oldRecords, ...newRecords]
双重状态判断(records.length >= total 和 API响应空数据)
4)分页加载流程图
实现代码:
监听用户滑动到底部
// 监听用户滑动到底部
onReachBottom() {
this.getMoreVenueList();
console.log('页面滚动到底部,触发分页加载');
},
watch: {
loadmoreStatus(newStatus) {
console.log('loadmoreStatus 发生变化,新值为:', newStatus);
if (newStatus === 'loadmore') {
console.log('分页加载成功');
} else if (newStatus === 'nomore') {
console.log('分页加载无新数据');
} else if (newStatus === 'error') {
console.log('分页加载失败');
}
}
},
请求下一页的数据:
/**
* 发起场馆列表请求
*/
async fetchVenueList() {
try {
return await getVenueList({
current: this.page.pageNum - 1,
size: this.page.pageSize,
latitude: this.locationInfo.latitude,
longitude: this.locationInfo.longitude,
km: 10,
});
if (!response.data || !response.data.records || response.data.records.length === 0) {
console.error('获取场馆列表数据为空');
this.dataLoadError = true;
this.loading = false;
throw new Error('获取场馆列表数据为空');
}
return response;
} catch (error) {
console.error('获取场馆列表数据失败:', error);
this.loading = false; // 隐藏骨架屏
throw error;
}
},
/**
* 获取下一页的场馆信息
*/
async getMoreVenueList() {
if (this.venueListData.data.records.length >= this.total) {
// 没有更多数据了
this.loadmoreStatus = "nomore";
} else {
if (!this.loading) {
this.page.pageNum++;
// 显示正在加载
this.loadmoreStatus = "loading";
// 修改后
try {
const newData = await this.fetchVenueList();
this.venueListData.data.records = this.venueListData.data.records.concat(newData.data
.records);
this.loadmoreStatus = newData.data.records.length > 0 ? "loadmore" : "nomore";
} catch (error) {
console.error('获取下一页场馆列表数据失败:', error);
this.loadmoreStatus = "error";
this.loading = false; // 隐藏骨架屏
this.loadmoreStatus = "error";
}
}
}
},
5.提升用户体验
1)骨架屏:
数据加载前显示骨架屏,骨架屏与真实布局高度一致,避免空白页带来的视觉焦虑。
代码实现:
<!-- 骨架屏结构与真实场馆列表保持DOM结构一致 -->
<u-skeleton
avatarSize="88" // 匹配场馆封面图尺寸
rows="2" // 模拟描述文字行数
rowsWidth="90%" // 模拟文字长度
:animate="true" // 呼吸动画减少等待焦虑
/>
2)回到顶部:
滚动时显示 u-back-top
按钮,优化长列表浏览。
template:
<!-- 回到上方按钮 -->
<u-back-top :scroll-top="scrollTop"></u-back-top>
script
// 用来控制滚动到最上方,在data(){}中设置
scrollTop: 0,
// 在滑动过程实时获取现在的滚动条位置,并保存当前的滚动条位置
onPageScroll(e) {
this.scrollTop = e.scrollTop;
},
3)错误提示:
通过 u-toast
显示操作反馈(如生成数据成功提示)。
6.样式与交互设计
响应式布局:通过 Flex 布局适配不同屏幕尺寸。
动效反馈:骨架屏动画、按钮点击态(
:active
样式)提升操作感。
六、思路和建议
在首页的搭建过程中可以采用从上到下的搭建方式,从顶部位置信息栏开始,到搜索框,再到场馆分类,附近场馆列表。
思路上要注意,由于附近场馆列表的信息中有相关位置信息,所以这里的逻辑是需要定位完成才可以显示,所以要先进行定位,然后才能通过定位信息进一步展示出附近场馆信息,这里附近的范围为10km。也就是说,如果你附近10km没有场馆,附近场馆列表就没有数据显示,这进一步说明了先进行定位是必要的。
建议:定位功能可以使用浏览器自带的,也可以使用腾讯地图,谷歌地图等的api,当然在使用前,你需要看一下这个定位功能是否在你想展示的平台都兼容,比如说你想要做一个网页的平台,你选择的定位功能必须要在浏览器上兼容;如果你想做小程序,你就必须选择能和你的小程序(如微信小程序、支付宝小程序、抖音小程序等)能够兼容的定位功能。
项目说明和其他介绍:
具体代码可以查看相关开源仓库,项目介绍视频可见:
场快订高并发场馆预订平台开源啦,我的第一个开源项目欢迎大家多多支持!_哔哩哔哩_bilibili
完整的开源说明请见:
感谢你的支持,希望我的文章对你有所帮助!