SaaS场快订首页的前端搭建【持续更新】

发布于:2025-05-12 ⋅ 阅读:(15) ⋅ 点赞:(0)

一、创建页面

在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,当然在使用前,你需要看一下这个定位功能是否在你想展示的平台都兼容,比如说你想要做一个网页的平台,你选择的定位功能必须要在浏览器上兼容;如果你想做小程序,你就必须选择能和你的小程序(如微信小程序、支付宝小程序、抖音小程序等)能够兼容的定位功能。

项目说明和其他介绍:

SaaS场快订平台项目说明【持续更新】-CSDN博客

具体代码可以查看相关开源仓库,项目介绍视频可见:

场快订高并发场馆预订平台开源啦,我的第一个开源项目欢迎大家多多支持!_哔哩哔哩_bilibili

完整的开源说明请见:

场快订场馆预定平台开源说明-CSDN博客

感谢你的支持,希望我的文章对你有所帮助!


网站公告

今日签到

点亮在社区的每一天
去签到