一、前言
欢迎回到mypet项目实战!📋 今天我们实现商家服务管理核心功能—— 商家服务列表展示与上拉加载更多。商家成功注册并通过审核后,可发布宠物服务项目(如洗澡、美容、医疗等),用户通过服务列表浏览并预约。
本次实现的核心是 “分页加载” 技术:后端使用 MyBatis-Plus 的Page对象实现分页查询,前端集成Mescroll-Uni组件实现上拉加载更多(类似电商 APP 的商品列表)。即使是零基础,也能通过 “复制代码 + 注释解析” 掌握分页逻辑与滚动加载的实现。
📌 学习目标:
- 掌握 MP 的Page分页查询,实现按商家 ID、服务状态等条件的分页数据返回;
- 熟练使用Mescroll-Uni组件,实现上拉加载更多、下拉刷新功能;
- 理解 “分页参数传递”“数据追加”“加载状态管理” 等关键逻辑;
- 解决 “数据重复加载”“无更多数据判断”“下拉刷新重置” 等实战问题。
二、前置准备
开始编码前,请确认以下内容已就绪:
| 项目 | 检查内容 | 注意事项 |
|---|---|---|
| 数据库表结构 | fuwuxiangmu(服务项目表)需包含以下字段:id(主键)、shangjia_id(关联商家 ID)、fuwumingcheng(服务名称)、fuwujiage(服务价格)、fuwudescribe(服务描述)、zhuangtai(状态:0 - 下架,1 - 上架)、addtime(添加时间) | 若表 / 字段缺失,执行建表 SQL:sqlCREATE TABLE fuwuxiangmu (id BIGINT PRIMARY KEY AUTO_INCREMENT, shangjia_id BIGINT NOT NULL, fuwumingcheng VARCHAR(100) NOT NULL, fuwujiage DECIMAL(10,2) NOT NULL, zhuangtai TINYINT DEFAULT 1 COMMENT '0-下架,1-上架'); |
| 后端配置 | 1. 已配置 MP 分页插件(MybatisPlusConfig中注册PaginationInterceptor);2. 商家已通过审核(shangjia表zhuangtai=1) | 若未配置分页插件,需在MybatisPlusConfig中添加:@Bean public PaginationInterceptor paginationInterceptor() { return new PaginationInterceptor(); } |
| 前端组件 | 1. 已导入Mescroll-Uni组件(HBuilder X→插件市场搜索安装);2. pages.json配置路由: "pages": [{"path": "pages/fuwuxiangmu/list","style": {"navigationBarTitleText": "我的服务列表"}}] | 确保Mescroll-Uni组件路径正确(默认/components/mescroll-uni/mescroll-uni.vue) |
| 测试数据 | 往fuwuxiangmu表插入测试数据(关联已通过审核的商家 ID):INSERT INTO fuwuxiangmu (shangjia_id, fuwumingcheng, fuwujiage, zhuangtai) VALUES (1, '宠物洗澡', 39.90, 1), (1, '毛发修剪', 69.90, 1); | 确保shangjia_id对应的数据在shangjia表中存在且zhuangtai=1 |
三、服务列表分页加载流程图
先通过流程图理清 “初始加载→上拉加载→下拉刷新” 的完整逻辑:
flowchart TD
A[用户进入服务列表页] --> B[页面初始化<br>调用Mescroll的init方法]
B --> C[默认触发下拉刷新<br>首次加载等同于刷新]
C --> D[重置分页参数<br>page=1,list=空,hasMore=true]
D --> E[调用后端/page接口<br>传递page=1,size=10,shangjia_id=当前商家ID]
E --> F{后端返回数据}
F -- 否 --> G[显示暂无服务数据]
F -- 是 --> H[前端list接收第一页数据<br>res.data.records]
H --> I[Mescroll结束刷新<br>显示第一页服务列表]
I --> J[用户上拉页面至底部]
J --> K{Mescroll检测到上拉<br>且hasMore=true}
K -- 否 --> L[显示没有更多数据了]
K -- 是 --> M[page+1 page=2<br>调用后端/page接口]
M --> N{第二页有数据}
N -- 是 --> O[list拼接新数据<br>list = list.concat新数据]
N -- 否 --> P[设置hasMore=false<br>不再触发上拉加载]
O/P --> Q[Mescroll结束加载<br>更新列表显示]
Q --> R[用户下拉页面<br>触发下拉刷新]
R --> D[重置分页参数<br>重新加载第一页]
四、代码实现
4.1 后端:分页查询接口开发(按商家 ID 过滤)
4.1.1 1. FuwuxiangmuController:服务列表分页查询
路径:src/main/java/com/controller/FuwuxiangmuController.java
核心功能:接收分页参数 + 当前商家 ID→按条件分页查询服务列表→返回分页数据。
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.entity.FuwuxiangmuEntity;
import com.entity.ShangjiaEntity;
import com.service.FuwuxiangmuService;
import com.service.ShangjiaService;
import com.utils.R;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/fuwuxiangmu")
public class FuwuxiangmuController {
@Autowired
private FuwuxiangmuService fuwuxiangmuService;
@Autowired
private ShangjiaService shangjiaService;
// JWT密钥(与Day10一致)
private static final String JWT_SECRET = "mypet-secret-2024";
/**
* 商家服务列表分页查询(仅查询当前商家的服务,且支持按状态筛选)
* @param current 页码(从1开始)
* @param size 每页条数
* @param zhuangtai 服务状态(可选:0-下架,1-上架,null-全部)
* @param request 用于获取Token,解析用户ID→查询关联的商家ID
*/
@GetMapping("/page")
public R getServicePage(
@RequestParam(defaultValue = "1") long current, // 默认第一页
@RequestParam(defaultValue = "10") long size, // 默认每页10条
@RequestParam(required = false) Integer zhuangtai, // 可选参数:状态筛选
HttpServletRequest request
) {
// 从Token解析当前登录用户ID→查询该用户关联的商家ID
String token = request.getHeader("token");
Claims claims = Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token).getBody();
Long userId = Long.parseLong(claims.getSubject());
// 查询用户关联的商家(一个用户只能注册一个商家)
ShangjiaEntity shangjia = shangjiaService.getOne(
new QueryWrapper<ShangjiaEntity>().eq("user_id", userId)
);
if (shangjia == null) {
return R.error("您还未注册商家,无法查看服务列表!");
}
Long shangjiaId = shangjia.getId(); // 当前商家ID
// 构建MP分页对象(current页码,size每页条数)
Page<FuwuxiangmuEntity> page = new Page<>(current, size);
// 构建查询条件(仅查询当前商家的服务,支持按状态筛选)
QueryWrapper<FuwuxiangmuEntity> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("shangjia_id", shangjiaId); // 仅显示当前商家的服务
if (zhuangtai != null) {
queryWrapper.eq("zhuangtai", zhuangtai); // 可选:按状态筛选(上架/下架)
}
queryWrapper.orderByDesc("addtime"); // 按添加时间倒序(最新的在前面)
// MP的page()方法:执行分页查询
IPage<FuwuxiangmuEntity> servicePage = fuwuxiangmuService.page(page, queryWrapper);
// 返回分页数据(包含总条数、总页数、当前页数据等)
return R.ok().put("page", servicePage);
}
}
📌 后端关键讲解:
- 数据权限控制:通过shangjia_id过滤,确保商家只能看到自己发布的服务,避免越权查看他人服务;
- 灵活筛选:支持传入zhuangtai参数筛选 “上架 / 下架” 服务(后续可在前端添加筛选按钮);
- 分页参数默认值:current默认 1、size默认 10,避免前端未传参数时的空指针异常;
- 排序逻辑:按addtime倒序,确保最新发布的服务显示在前面,提升用户体验。
4.1.2 2. Service 层:无需自定义实现(MP 原生方法)
路径(Service):src/main/java/com/service/FuwuxiangmuService.java
路径(ServiceImpl):src/main/java/com/service/impl/FuwuxiangmuServiceImpl.java
Service 接口:
import com.entity.FuwuxiangmuEntity;
import com.baomidou.mybatisplus.extension.service.IService;
public interface FuwuxiangmuService extends IService<FuwuxiangmuEntity> {
// 无需自定义方法,MP的IService已包含page()(分页查询)等
}
ServiceImpl 实现类:
import com.entity.FuwuxiangmuEntity;
import com.mapper.FuwuxiangmuMapper;
import com.service.FuwuxiangmuService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
@Service
public class FuwuxiangmuServiceImpl extends ServiceImpl<FuwuxiangmuMapper, FuwuxiangmuEntity> implements FuwuxiangmuService {
// 无需重写page(),父类已实现分页查询逻辑
}
4.2 前端:服务列表 + Mescroll 上拉加载
路径:pages/fuwuxiangmu/list.vue
核心功能:初始化 Mescroll 组件、上拉加载更多、下拉刷新、服务列表展示。
<template>
<view class="service-list-page">
<!-- Mescroll-Uni组件(上拉加载+下拉刷新) -->
<mescroll-uni
ref="mescrollRef"
:down="downOption"
:up="upOption"
@down="onDownRefresh"
@up="onUpLoad"
class="mescroll"
>
<!-- 服务列表 -->
<view class="service-list">
<!-- 服务项 -->
<view class="service-item" v-for="(item, index) in serviceList" :key="item.id">
<view class="service-name">
{{ item.fuwumingcheng }}
<view class="status-tag" :class="item.zhuangtai === 1 ? 'status-on' : 'status-off'">
{{ item.zhuangtai === 1 ? '已上架' : '已下架' }}
</view>
</view>
<view class="service-price">¥{{ item.fuwujiage.toFixed(2) }}</view>
<view class="service-desc">{{ item.fuwudescribe || '暂无描述' }}</view>
<view class="service-opt">
<button @click="handleEdit(item)">编辑</button>
<button @click="handleChangeStatus(item)">
{{ item.zhuangtai === 1 ? '下架' : '上架' }}
</button>
</view>
</view>
<!-- 空状态(无服务数据时显示) -->
<view class="empty-view" v-if="serviceList.length === 0 && !isLoading">
<image src="/static/images/empty_service.png" mode="widthFix" class="empty-img"></image>
<text class="empty-text">暂无服务数据,点击添加服务吧~</text>
<button class="add-btn" @click="navToAdd">添加服务</button>
</view>
</view>
</mescroll-uni>
<!-- 添加服务按钮(悬浮在右下角) -->
<button class="float-add-btn" @click="navToAdd">+</button>
</view>
</template>
<script>
import MescrollUni from '@/components/mescroll-uni/mescroll-uni.vue';
import request from '@/api/request.js';
export default {
components: { MescrollUni },
data() {
return {
// 服务列表数据
serviceList: [],
// 分页参数
pageNum: 1, // 当前页码(从1开始)
pageSize: 10, // 每页条数
hasMore: true, // 是否还有更多数据(控制上拉加载)
isLoading: false, // 是否正在加载中(防止重复请求)
// Mescroll下拉刷新配置
downOption: {
use: true, // 启用下拉刷新
auto: true, // 页面初始化时自动执行一次下拉刷新
offset: 60 // 下拉刷新的触发距离(px)
},
// Mescroll上拉加载配置
upOption: {
use: true, // 启用上拉加载
auto: false, // 不自动执行上拉加载(首次加载由下拉刷新完成)
page: {
num: 0, // 初始页码(0代表不需要组件内部管理页码,由我们自己管理)
size: 10 // 每页条数(与pageSize保持一致)
},
noMoreSize: 5, // 当剩余数据不足5条时,视为无更多数据
offset: 100 // 上拉加载的触发距离(px)
}
};
},
onLoad() {
// 初始化Mescroll(可选,用于手动控制)
this.$nextTick(() => {
this.mescroll = this.$refs.mescrollRef;
});
},
methods: {
// 1. 下拉刷新(重新加载第一页数据)
onDownRefresh() {
// 重置分页参数
this.pageNum = 1;
this.hasMore = true;
this.isLoading = true;
// 调用接口加载数据
this.loadServiceList()
.then(() => {
// 刷新成功:结束下拉刷新动画
this.mescroll.endSuccess();
})
.catch(() => {
// 刷新失败:结束下拉刷新动画(显示错误状态)
this.mescroll.endErr();
})
.finally(() => {
this.isLoading = false;
});
},
// 2. 上拉加载更多(加载下一页数据)
onUpLoad(mescroll) {
// 如果没有更多数据,直接结束加载
if (!this.hasMore) {
mescroll.endNoMore();
return;
}
this.isLoading = true;
// 页码+1,加载下一页
this.pageNum++;
// 调用接口加载数据
this.loadServiceList()
.then((hasData) => {
if (hasData) {
// 有新数据:结束加载,更新列表
mescroll.endSuccess(hasData.length);
} else {
// 无新数据:标记无更多,结束加载
this.hasMore = false;
mescroll.endNoMore();
}
})
.catch(() => {
// 加载失败:恢复页码,结束加载(允许重试)
this.pageNum--;
mescroll.endErr();
})
.finally(() => {
this.isLoading = false;
});
},
// 3. 加载服务列表数据(通用方法,供下拉刷新和上拉加载调用)
loadServiceList() {
return new Promise((resolve, reject) => {
// 调用后端分页接口(传递页码、每页条数、可选状态筛选)
request.get(`/fuwuxiangmu/page?current=${this.pageNum}&size=${this.pageSize}`)
.then(res => {
if (res.data.code === 0) {
const pageData = res.data.page;
const records = pageData.records || []; // 当前页数据
if (this.pageNum === 1) {
// 第一页:直接覆盖列表(下拉刷新场景)
this.serviceList = records;
} else {
// 非第一页:拼接列表(上拉加载场景)
this.serviceList = this.serviceList.concat(records);
}
// 判断是否还有更多数据(当前页数据条数 < 每页条数 → 无更多)
const hasMoreData = records.length >= this.pageSize;
this.hasMore = hasMoreData;
resolve(records); // 返回当前页数据,供上拉加载判断
} else {
// 接口返回错误(如未注册商家)
uni.showToast({ title: res.data.msg, icon: 'none' });
reject(res.data.msg);
}
})
.catch(err => {
// 网络错误
uni.showToast({ title: '加载服务列表失败', icon: 'none' });
console.error('服务列表加载失败:', err);
reject(err);
});
});
},
// 4. 跳转到添加服务页面
navToAdd() {
uni.navigateTo({ url: '/pages/fuwuxiangmu/add' });
},
// 5. 编辑服务(后续实现)
handleEdit(item) {
uni.navigateTo({ url: `/pages/fuwuxiangmu/edit?id=${item.id}` });
},
// 6. 更改服务状态(上架/下架,后续实现)
handleChangeStatus(item) {
// 后续实现状态切换逻辑
const newStatus = item.zhuangtai === 1 ? 0 : 1;
uni.showModal({
title: '提示',
content: `确定要${newStatus === 1 ? '上架' : '下架'}该服务吗?`,
success: (modalRes) => {
if (modalRes.confirm) {
// 调用状态更新接口(此处仅为示例,后续实现)
item.zhuangtai = newStatus;
uni.showToast({ title: `已${newStatus === 1 ? '上架' : '下架'}`, icon: 'success' });
}
}
});
}
}
};
</script>
<style scoped>
/* 页面整体样式 */
.service-list-page {
width: 100%;
min-height: 100vh;
background-color: #f5f5f5;
}
/* Mescroll容器样式(必须设置高度) */
.mescroll {
width: 100%;
min-height: calc(100vh - 0rpx); /* 占满屏幕高度 */
padding-bottom: 120rpx; /* 预留底部添加按钮空间 */
}
/* 服务列表样式 */
.service-list {
padding: 20rpx;
}
/* 服务项样式 */
.service-item {
background-color: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
/* 服务名称与状态 */
.service-name {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.status-tag {
padding: 4rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: normal;
}
.status-on {
background-color: #e6f7ee;
color: #00b42a;
}
.status-off {
background-color: #fff2e8;
color: #ff7d00;
}
/* 服务价格 */
.service-price {
font-size: 30rpx;
color: #f53f3f;
margin-bottom: 16rpx;
}
/* 服务描述 */
.service-desc {
font-size: 26rpx;
color: #666;
line-height: 1.5;
margin-bottom: 24rpx;
display: -webkit-box;
-webkit-line-clamp: 2; /* 最多显示2行 */
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 操作按钮 */
.service-opt {
display: flex;
justify-content: flex-end;
gap: 20rpx;
}
.service-opt button {
padding: 8rpx 24rpx;
font-size: 26rpx;
border-radius: 8rpx;
}
.service-opt button:first-child {
background-color: #f2f3f5;
color: #333;
}
.service-opt button:last-child {
background-color: #007aff;
color: #fff;
}
/* 空状态样式 */
.empty-view {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 200rpx;
}
.empty-img {
width: 300rpx;
margin-bottom: 40rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
}
.add-btn {
padding: 20rpx 60rpx;
background-color: #007aff;
color: #fff;
font-size: 28rpx;
border-radius: 50rpx;
}
/* 悬浮添加按钮 */
.float-add-btn {
position: fixed;
right: 40rpx;
bottom: 40rpx;
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background-color: #007aff;
color: #fff;
font-size: 60rpx;
line-height: 100rpx;
text-align: center;
box-shadow: 0 4rpx 16rpx rgba(0, 122, 255, 0.3);
padding: 0;
}
</style>
📌 前端关键细节讲解:
- Mescroll 配置优化:
-
- 下拉刷新auto: true:页面初始化时自动加载第一页数据,无需用户手动下拉;
-
- 上拉加载page.num: 0:禁用组件内部页码管理,由前端手动控制pageNum,避免页码混乱;
-
- noMoreSize: 5:当剩余数据不足 5 条时视为无更多,提前显示 “没有更多数据”,提升体验。
- 分页逻辑控制:
-
- 下拉刷新:重置pageNum=1,覆盖serviceList,实现 “重新加载”;
-
- 上拉加载:pageNum++,用concat拼接新数据,实现 “加载更多”;
-
- hasMore判断:通过 “当前页数据条数 < 每页条数” 判断是否还有更多,避免无效请求。
- 用户体验优化:
-
- 空状态处理:无数据时显示引导图片和 “添加服务” 按钮,减少用户困惑;
-
- 加载状态管理:isLoading防止重复请求,按钮禁用状态随加载状态变化;
-
- 服务状态可视化:用不同颜色标签区分 “已上架”“已下架”,直观清晰。
五、效果验证
按以下步骤验证服务列表与分页加载功能:
✅ 1. 后端接口测试(Postman)
- 请求方式:GET
- 请求头:token: 有效商家用户Token(该用户已注册并通过商家审核)
- 成功返回:
{
"code": 0,
"msg": "success",
"page": {
"records": [
{
"id": 1,
"shangjia_id": 1,
"fuwumingcheng": "宠物洗澡",
"fuwujiage": 39.90,
"zhuangtai": 1
},
{
"id": 2,
"shangjia_id": 1,
"fuwumingcheng": "毛发修剪",
"fuwujiage": 69.90,
"zhuangtai": 1
}
],
"total": 2, // 总条数
"size": 10, // 每页条数
"current": 1, // 当前页码
"pages": 1 // 总页数
}
}
✅ 2. 前端功能测试(Uni-App 模拟器 / 真机)
- 登录与跳转:使用已通过审核的商家账号登录→跳转至服务列表页;
- 初始加载:页面自动触发下拉刷新→显示第一页服务数据(如 “宠物洗澡”“毛发修剪”);
- 上拉加载:
-
- 若服务数据超过 10 条(可手动在数据库添加),上拉页面至底部→自动加载第二页数据,列表追加显示;
-
- 若数据不足 10 条(如仅 2 条),上拉后显示 “没有更多数据了”;
- 下拉刷新:下拉页面→列表清空并重新加载第一页数据,恢复初始状态;
- 空状态验证:删除fuwuxiangmu表中该商家的所有数据→页面显示空状态图片和 “添加服务” 按钮。
六、常见问题与排查
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 1. 上拉加载重复请求同一页 | 1. pageNum未正确递增;2. hasMore未设为 false | 1. 检查onUpLoad中是否执行this.pageNum++;2. 确认hasMore在 “当前页数据 < pageSize” 时设为 false;3. 打印pageNum,确保每次上拉 + 1 |
| 2. 下拉刷新后数据未更新 | 1. 未重置pageNum=1;2. 未覆盖serviceList | 1. 检查onDownRefresh中是否重置this.pageNum=1;2. 确认第一页数据用this.serviceList = records(覆盖而非拼接);3. 打印serviceList,确认刷新后为最新数据 |
| 3. Mescroll 不触发上拉加载 | 1. 组件高度不足(未占满屏幕);2. up.use=false | 1. 确保.mescroll样式设置min-height: 100vh;2. 检查upOption.use是否为 true;3. 上拉时超过offset设置的距离(如 100px) |
| 4. 服务列表显示其他商家数据 | 后端未添加shangjia_id条件过滤 | 1. 检查queryWrapper.eq("shangjia_id", shangjiaId)是否执行;2. 打印shangjiaId,确认与当前商家匹配;3. 数据库查询验证,确保fuwuxiangmu表shangjia_id正确 |
| 5. 无更多数据仍显示加载中 | mescroll.endNoMore()未调用 | 1. 检查 “无新数据” 分支是否执行mescroll.endNoMore();2. 确认hasMore设为 false 后,不再触发上拉加载;3. 打印records.length,确认小于pageSize时进入无更多分支 |
七、扩展与提升
7.1 功能优化:服务状态筛选与搜索
当前仅显示所有服务,可扩展筛选功能:
- 前端添加 “全部 / 已上架 / 已下架” 筛选按钮,点击后传递zhuangtai参数重新加载;
- 添加搜索框,支持按服务名称模糊搜索(后端queryWrapper.like("fuwumingcheng", keyword));
- 筛选 / 搜索后自动重置分页参数(pageNum=1),避免页码混乱。
7.2 体验优化:预加载与骨架屏
减少用户等待感:
- 实现预加载:当用户上拉至距离底部 200px 时,提前加载下一页数据;
- 添加骨架屏:数据加载过程中显示服务项骨架屏(用uni-skeleton组件),替代空白页面。
7.3 性能优化:列表项复用
长列表优化:
- 使用uni-recycle-view替代普通v-for,实现列表项 DOM 复用,减少内存占用;
- 图片懒加载:服务项若有图片,添加lazy-load属性,滚动到可视区域再加载。
八、课堂互动
🙋♂️ 思考题 / 互动:
- 如何实现 “下拉刷新时显示最新数据,且不重复加载已删除的服务”?(提示:依赖后端数据更新)
- 若服务列表包含图片,如何优化上拉加载时的图片加载性能?
💡 互动引导:你的服务列表和上拉加载功能正常吗?如果遇到 “数据重复” 或 “Mescroll 不触发” 的问题,欢迎分享你的loadServiceList代码或Mescroll配置,我们一起排查!
九、下节预告
👉 明天 Day15:添加与编辑商家服务!我们将学习:
- 实现 “添加服务” 表单(服务名称、价格、描述等字段);
- 开发 “编辑服务” 功能(回显数据 + 修改提交);
- 服务状态切换接口(上架 / 下架);
- 前端表单校验与后端参数验证的双重保障。
记得提前复习表单提交(Day12)和 MP 的saveOrUpdate方法哦!