主题
报修业务(一)
本页对应 报修业务,共 8 段视频,约 1 小时 22 分。
学习目标
- 能使用 ActionSheet、Popup、DatetimePicker 组件完成房屋选择、维修项目选择、预约时间选择等交互,并正确绑定显示状态、数据源及 select/confirm/cancel/close 事件。
- 能调用房屋列表与维修项目接口获取数据,将返回数据渲染到 ActionSheet,并在选择后更新页面展示及根据选中状态切换文字样式。
- 能限制预约时间不早于当天,获取用户选择的时间戳,并封装日期格式化方法输出“年-月-日”格式且补齐小于 10 的月份和日期。
- 能使用 Uploader 组件配置文件列表、上传数量限制和预览尺寸,在 after-read 事件中调用上传接口,将上传成功的图片追加到附件数组并实现预览。
- 能获取手机号、问题描述等表单数据,定义并应用验证规则,在提交时根据验证结果决定是否调用新增报修接口。
- 能按接口要求提交房屋、维修项目、手机号、预约时间、问题描述和附件参数,根据返回 code 判断成功与否,并在成功后跳转到报修列表页面。
知识点
在线报修页面要完成什么
为什么需要:业主报修时要填写房屋、维修项目、联系电话、上门时间和问题描述,还可能上传图片;这些信息必须能选择、能校验、能提交。
怎么想:页面先获取“可报修房屋”和“维修项目”两类基础数据,再用 Vant 弹层组件完成选择,最后统一校验并提交。
怎么做:本页用到 van-action-sheet 展示列表选择,van-popup 作为底部弹层容器,van-datetimepicker 选择日期,van-uploader 上传附件。进入页面前要保证用户已登录;房屋列表只返回审核通过的房屋。
易错点:van-action-sheet 的 actions 数据项必须包含 name 才能显示文字;控制显示隐藏的是 show,不是“受”;未登录时不要继续发请求。
获取并选择房屋列表
为什么需要:报修必须落到具体房屋上,所以要先查询当前用户可报修的房屋。
怎么想:页面加载时调 /house,把返回数组交给 actions;用户点选后监听 select,从 ev.detail 取 id 和 name。
怎么做:
javascript
// repair_pkg/pages/form/index.js
Page({
data: {
houseList: [],
houseId: '',
houseName: ''
},
async getHouseList() {
const { code, data: houseList } = await wx.http.get('/house')
if (code !== 10000) return wx.utils.toast()
this.setData({ houseList })
},
selectHouseInfo(ev) {
this.setData({
houseId: ev.detail.id,
houseName: ev.detail.name,
})
},
})xml
<van-action-sheet
bind:close="closeHouseLayer"
bind:cancel="closeHouseLayer"
bind:select="selectHouseInfo"
round
show="{{houseLayerVisible}}"
actions="{{houseList}}"
cancel-text="取消"
title="选择报修房屋"
/>
<van-cell-group border="{{false}}" title="报修房屋">
<van-cell
value="{{houseName || '请选择报修房屋'}}"
value-class="{{houseName && 'active-cell'}}"
bind:click="openHouseLayer"
is-link
border="{{false}}"
/>
</van-cell-group>易错点:close、cancel 都要负责关闭弹层;value-class 要用条件,否则初始提示文字也会变成深色。调试时先 console.log(ev.detail) 确认字段,不要猜。
获取并选择维修项目
为什么需要:维修项目决定师傅上门处理什么问题。
怎么想:与房屋选择同构:接口取数组,actions 渲染,select 取选择结果。
怎么做:
javascript
Page({
data: {
repairItem: [],
repairItemId: '',
repairItemName: ''
},
onLoad() {
this.getRepairItem()
},
async getRepairItem() {
const { code, data: repairItem } = await wx.http.get('/repairItem')
if (code !== 10000) return wx.utils.toast()
this.setData({ repairItem })
},
selectRepairItemInfo(ev) {
this.setData({
repairItemId: ev.detail.id,
repairItemName: ev.detail.name,
})
},
})xml
<van-action-sheet
bind:close="closeRepairLayer"
bind:cancel="closeRepairLayer"
bind:select="selectRepairItemInfo"
round
show="{{repairLayerVisible}}"
actions="{{repairItem}}"
cancel-text="取消"
title="选择维修项目"
/>
<van-cell
title-width="100"
title="维修项目"
value-class="{{repairItemName && 'active-cell'}}"
bind:click="openRepairLayer"
value="{{repairItemName || '请选择维修项目'}}"
is-link
/>易错点:事件名必须是 selectRepairItemInfo,转写中曾把 select 误写成 get 导致不打印;字段名 repairItemId 提交时也不能写错。
预约时间与日期格式化
为什么需要:上门时间必须是将来时间,且展示要友好。
怎么想:用 van-popup 从底部包住 van-datetimepicker,min-date 设为当天,confirm 取时间戳并格式化。
怎么做:
xml
<van-popup
bind:close="closeDateLayer"
round
show="{{dateLayerVisible}}"
position="bottom"
>
<van-datetime-picker
bind:cancel="closeDateLayer"
bind:confirm="selectDateInfo"
type="date"
value="{{currentDate}}"
min-date="{{currentDate}}"
/>
</van-popup>javascript
selectDateInfo(ev) {
this.setData({
appointment: wx.utils.dataFormat(ev.detail),
dateLayerVisible: false,
})
}易错点:ev.detail 是时间戳;格式化时月份要 +1,小于 10 要补 0;点“确认”后也要关闭弹层。
上传附件
为什么需要:图片能帮助维修师傅快速判断问题。
怎么想:file-list 保存已上传成功图片,after-read 拿到文件后调用 wx.uploadFile,成功后追加到 attachment。
怎么做:
xml
<van-uploader
bind:after-read="uploadPicture"
max-count="6"
preview-size="100"
file-list="{{attachment}}"
/>javascript
uploadPicture(ev) {
const { file } = ev.detail
wx.uploadFile({
url: wx.http.baseURL + '/upload',
filePath: file.url,
name: 'file',
header: {
Authorization: 'Bearer ' + getApp().token,
},
success: (result) => {
const data = JSON.parse(result.data)
if (data.code !== 10000) return wx.utils.toast('文件上传失败!')
const { attachment } = this.data
attachment.push(data.data)
this.setData({ attachment })
},
})
}易错点:上传前要清掉示例默认图片,否则提交脏数据;result.data 是 JSON 字符串,要 JSON.parse;filePath 用 file.url。
表单校验与提交
为什么需要:避免空房屋、空项目、错误手机号直接提交。
怎么想:用 wechat-validate 定义规则,提交前 this.validate(),通过后按接口字段名提交。
怎么做:
javascript
import wxValidate from 'wechat-validate'
Page({
behaviors: [wxValidate],
data: {
houseId: '',
repairItemId: '',
mobile: '',
appointment: '',
description: '',
attachment: []
},
rules: {
houseId: [{ required: true, message: '请选择报修房屋!' }],
repairItemId: [{ required: true, message: '请选择维修的项目!' }],
mobile: [
{ required: true, message: '请填写手机号码!' },
{ pattern: /^1[3-8]\d{9}$/, message: '请填写正确的手机号码!' },
],
appointment: [{ required: true, message: '请选择预约日期!' }],
description: [{ required: true, message: '请填写问题描述!' }],
},
async submitForm() {
if (!this.validate()) return
const { houseId, repairItemId, mobile, appointment, description, attachment } = this.data
const { code } = await wx.http.post('/repair', {
houseId,
repairItemId,
mobile,
appointment,
description,
attachment,
})
if (code !== 10000) return wx.utils.toast('在线报修失败!')
wx.redirectTo({ url: '/repair_pkg/pages/list/index' })
},
})易错点:附件不是必填项,不必加规则;validate() 返回假要直接 return;提交参数名必须与接口一致,转写中曾把 repairItemId 写错导致失败。提交成功后跳转报修列表页。
先明确目标:报修列表要展示什么
为什么需要:用户提交在线报修后,需要在一个页面里集中看到自己报修过的记录,并能继续进入详情或新增报修。
怎么想:列表页的核心不是“写死几条数据”,而是“进入页面就请求接口,把接口返回的 rows 渲染出来”。
怎么做:在 onLoad 中调用 getRepairList,接口使用 wx.http.get('/repair', { current: 1, pageSize: 10 }),成功条件按接口文档判断为 code === 10000。
易错点:转写稿里“季录”应理解为“记录”;不要把接口返回的 data.rows 误写成 data 本身,否则 wx:for 拿不到数组。
接口文档地址保留如下:接口文档地址
登录态与页面结构
为什么需要:报修列表接口要求用户保持登录状态,未登录时直接请求没有意义,也会得到失败结果。
怎么想:列表页应先用 authorization 组件包住需要登录后才能看的内容,未登录时由组件引导去登录;登录后再请求列表。转写稿中也强调过:用户没有登录时不允许在线报修,登录检测用 authorization 组件。
怎么做:模板中用 <authorization> 包裹列表主体和空状态分支;onLoad 里请求列表。
易错点:不要把弹层、隐藏内容随意包进授权区;转写稿说“默认隐藏的弹层不需要包裹”,列表主体才需要。还要注意“受”是 show 的口语误写,本节不涉及弹层显示控制。
调用接口获取列表
为什么需要:列表数据来自服务端,不能靠前端假数据。
怎么想:先封装 getRepairList,在方法里请求接口,再判断 code,最后 setData 渲染。
怎么做:保留示例代码:
javascript
// repqir_pkg/pages/list/index.js
Page({
data: {
repairList: []
},
onLoad() {
// 获取报修列表数据
this.getRepairList()
},
// 报修列表接口
async getRepairList() {
// 调用接口
const {
code,
data: { rows: repairList },
} = await wx.http.get('/repair', { current: 1, pageSize: 10 })
// 检测接口是否用成功
if (code !== 10000) return wx.utils.toast()
// 渲染数据
this.setData({
repairList,
isEmpty: repairList.length === 0,
})
},
})易错点:code !== 10000 时直接 return,不要再 setData;rows 可能为空数组,必须同时维护 isEmpty。转写稿中“接口是否用成功”应纠正为“接口是否调用成功”。
空状态判断
为什么需要:用户没有报修记录时,如果只渲染空列表,页面会显得像坏掉了。
怎么想:用 isEmpty 作为分支条件:有数据渲染列表,无数据渲染提示和“添加”入口。
怎么做:模板中用 <block wx:if="{{!isEmpty}}"> 包列表,<view wx:else class="blank"> 显示“您还没有报修记录,请点击 添加”。
易错点:isEmpty 要在接口成功后根据 repairList.length === 0 计算;不要只写 wx:if="{{repairList.length}}" 而忘记空状态文案。转写稿中“添加”入口对应的是报修表单页,路径以模板中的 navigator 为准。
状态标签渲染
为什么需要:报修记录有不同进度,用户要一眼看出当前状态。
怎么想:接口状态是数字,模板按 status 分支显示文字和类名。
怎么做:状态对应关系为:0 已取消,类名 cancel;1 受理中,类名 info;2 上门中,类名 success;3 已完成,类名 complete。
易错点:不要用 = 判断,要写 ===;四个 wx:if 要覆盖完整,避免状态值异常时什么都不显示。转写稿中“内幕”是 name 的误写,模板里真正使用的是 item.status、item.houseInfo 等字段。
列表模板与交互入口
为什么需要:列表不仅要展示,还要能点进详情、点底部按钮新增报修。
怎么想:用 van-cell-group 循环 repairList,wx:key="id";每个分组绑定 bind:tap="goDetail",底部工具栏绑定 bind:tap="addRepair"。
怎么做:保留模板代码:
xml
<authorization>
<block wx:if="{{!isEmpty}}">
<scroll-view show-scrollbar="{{false}}" enhanced scroll-y>
<view class="repairs">
<view class="repairs-title">我的报修</view>
<view class="repairs-list">
<van-cell-group
wx:for="{{repairList}}"
wx:key="id"
border="{{false}}"
mark:id="{{item.id}}"
bind:tap="goDetail"
>
<van-cell size="large" title="{{item.houseInfo}}">
<text wx:if="{{item.status === 0}}" class="tag cancel">已取消</text>
<text wx:if="{{item.status === 1}}" class="tag info">受理中</text>
<text wx:if="{{item.status === 2}}" class="tag success">上门中</text>
<text wx:if="{{item.status === 3}}" class="tag complete">已完成</text>
</van-cell>
<van-cell title="报修项目" border="{{false}}" value="{{item.repairItemName}}" />
<van-cell title="预约时间" border="{{false}}" value="{{item.appointment}}" />
<van-cell title="电话号码" border="{{false}}" value="{{item.mobile}}" />
</van-cell-group>
</view>
</view>
</scroll-view>
<view class="toolbar" bind:tap="addRepair">
<text class="enjoy-icon icon-repair"></text>
<text class="button-text">在线报修</text>
</view>
</block>
<view wx:else class="blank">
您还没有报修记录,请点击
<navigator hover-class="none" class="link" url="/repair_pkg/pages/form/index">添加</navigator>
</view>
</authorization>易错点:模板里已经绑定了 goDetail 和 addRepair,页面逻辑中要补齐对应方法,否则点击无响应;wx:key 要用唯一 id,不要用索引。调试时按转写稿思路:先看是否发出 /repair 请求,再看返回 code,再看 rows 是否进入 repairList,最后检查空状态和状态标签。提交报修成功后转写稿使用跳转到列表页,因此列表页 onLoad 立即请求是合理的调试入口。
先明确报修详情页要解决什么问题
报修详情页不是孤立页面,它通常从报修列表点击某条记录进入。列表页只知道自己点了“哪一条”,详情页则需要根据这条记录的报修 ID 去服务端获取完整数据。因此这里有两个核心动作:第一,在页面加载时拿到地址参数中的 id;第二,用这个 id 调详情接口,把返回的报修信息渲染到页面。
为什么要强调“地址参数”?因为详情页不能把某一条报修数据写死。列表中的每条记录都有自己的 ID,跳转时把 ID 带过来,详情页才能复用同一个页面展示不同记录。怎么想?先看页面生命周期 onLoad,它接收页面参数;怎么做?在 onLoad({ id }) 中解构出 ID,再调用 getRepairDetail(id)。易错点是:不要直接假设 id 一定存在,参数缺失时要给出提示并终止请求。
javascript
// repair_pkg/pages/detail/index.js
Page({
data: {
repairDetail: {}
},
onLoad({ id }) {
// 获取报修详情的数据
this.getRepairDetail(id)
},
// 报修详情接口
async getRepairDetail(id) {
if (!id) return wx.utils.toast('参数有误!')
// 调用接口
const { code, data: repairDetail } = await wx.http.get('/repair/' + id)
// 检测接口是否调用成功
if (code !== 10000) return wx.utils.toast()
// 渲染数据
this.setData({ repairDetail })
},
})调详情接口时先校验参数,再判断业务状态码
为什么需要先判断 id?因为详情接口路径是 /repair/ + id,如果 id 为空,请求路径就没有意义,既浪费一次请求,也不利于排查问题。怎么想?把“参数校验”放在请求之前,把“接口业务状态码校验”放在请求之后。怎么做?if (!id) return wx.utils.toast('参数有误!') 先兜底;请求返回后解构 code 和 data,只有 code === 10000 才 setData。易错点有两个:一是把 data 重命名为 repairDetail 后,渲染时仍要使用 repairDetail;二是接口失败时不要继续渲染,否则页面可能显示空数据或旧数据。转写稿中演示过从报修列表点记录进入详情,详情要展示报修的详细信息和当前状态,这里的接口调用就是数据来源。
用状态字段驱动详情页展示
详情页除了展示房屋信息、维修项目、手机号、预约日期、问题描述、附件外,还要展示当前报修状态。笔记中用 repairDetail.status 判断:0 已取消、1 受理中、2 上门中、3 已完成。为什么需要状态标签?用户进入详情后最关心“现在处理到哪一步”。怎么想?状态是数据,不要用多个布尔变量重复描述;直接用 status 做条件渲染。怎么做?在 van-cell 中并列写多个 wx:if,每个状态对应不同 class。易错点是:wx:if="{{repairDetail.status === 0}}" 是严格等于,接口若返回字符串 "0" 就不会命中,需要确认接口字段类型;另外初始 repairDetail 是 {},首次渲染时状态标签不会出现,这是正常的,数据回来后会更新。
只有“上门中”才展示地图
笔记特别提醒:当报修状态为上门中,即 status 值为 2 时,才展示地图组件。为什么?因为“上门中”阶段才更需要位置/路线信息,其他状态展示地图意义不大。怎么想?把地图区域包在 wx:if="{{repairDetail.status === 2}}" 的容器里。怎么做?外层 .polyline 条件渲染,内部放 map,并设置 scale、latitude、longitude。易错点是:笔记中的 map 使用了 latitude 和 longitude,但示例 setData 只设置了 repairDetail,实际项目中要确认详情数据里是否包含经纬度,或是否在页面中另行赋值;如果没有经纬度,地图可能不显示或定位异常。不要无条件渲染地图,否则不符合状态条件。
底部操作区要按状态控制“取消报修”
底部工具栏有“修改信息”和“取消报修”。笔记要求:只有在线报修状态为受理中,即 status === 1 时,才允许取消报修。为什么?因为已取消、上门中、已完成再取消都不合理,业务上应限制入口。怎么想?把“取消报修”按钮用 wx:if="{{repairDetail.status === 1}}" 控制;“修改信息”按钮绑定 bind:tap="editRepair"。怎么做?按笔记保留结构即可。易错点是:editRepair 方法需要在页面逻辑中实现,笔记只给渲染代码,不能误以为只写模板就能完成修改;另外不要用 status == 1 代替严格判断,避免类型不一致导致按钮显示错误。
xml
<scroll-view scroll-y enhanced show-scrollbar="{{false}}">
<view class="repair-detail">
<view wx:if="{{repairDetail.status === 2}}" class="polyline">
<map
style="width: 100%; height: 100%;"
scale="15"
latitude="{{latitude}}"
longitude="{{longitude}}"
></map>
</view>
<van-cell-group border="{{false}}" title="房屋信息">
<van-cell title="{{repairDetail.houseInfo}}">
<text wx:if="{{repairDetail.status === 0}}" class="tag cancel">已取消</text>
<text wx:if="{{repairDetail.status === 1}}" class="tag info">受理中</text>
<text wx:if="{{repairDetail.status === 2}}" class="tag success">上门中</text>
<text wx:if="{{repairDetail.status === 3}}" class="tag complete">已完成</text>
</van-cell>
</van-cell-group>
<van-cell-group title="报修信息" border="{{false}}">
<van-cell title-width="200rpx" title="维修项目" value="{{repairDetail.repairItemName}}" />
<van-cell title-width="200rpx" title="手机号码" value="{{repairDetail.mobile}}" />
<van-cell title-width="200rpx" title="预约日期" value="{{repairDetail.appointment}}" />
<van-cell title="问题描述" label="{{repairDetail.description}}" />
</van-cell-group>
<view class="attachment">
<view class="title">问题附件</view>
<scroll-view scroll-x>
<image wx:for="{{repairDetail.attachment}}" wx:key="id" src="{{item.url}}"></image>
</scroll-view>
</view>
</view>
</scroll-view>
<view class="toolbar">
<view bind:tap="editRepair" class="button-text active">修改信息</view>
<view wx:if="{{repairDetail.status === 1}}" class="button-text">取消报修</view>
</view>调试时按“参数—接口—渲染—状态”顺序排查
调试详情页时,先确认从列表跳转时是否带上了 id,再看 onLoad 是否收到;然后看请求路径是否为 /repair/具体ID,返回 code 是否为 10000;最后看 repairDetail 是否被 setData 更新。若页面空白,优先检查 id 和接口返回;若状态标签或取消按钮不出现,优先检查 status 的类型和值;若地图不显示,优先检查 status 是否为 2 以及 latitude、longitude 是否有值。转写稿中强调详情页要展示报修详细信息和当前状态,并可修改、取消报修,因此模板中的状态判断和底部操作条件都要与业务规则一致,不能只做静态页面。
路线规划要解决什么问题
路线规划的目标是:在地图上把两个点之间的行进路线画出来。笔记把它拆成两部分:小程序 map 组件负责“显示地图、标记点、路线”,腾讯位置服务负责“算出路线经过的经纬度点”。
为什么要这样拆?因为 map 只负责渲染,不会替你计算路线;位置服务只返回坐标数据,不会直接显示。怎么想?先把地图和标记点显示出来,再把位置服务返回的坐标点交给 polyline。怎么做?先掌握 map 的 latitude、longitude、scale、markers、polyline,再调用路线规划方法。易错点是:不要把“地图中心点”和“标记点”混为一谈,也不要把路线坐标直接塞给 map。
先用 map 组件把地图和标记点渲染出来
latitude 指定地图中心点纬度,longitude 指定地图中心点经度,scale 指定初始缩放比例,取值范围 3–20。markers 是地图上的标记点,polyline 是地图上的路线。
为什么需要 markers?因为用户要知道起点、终点或关键位置在哪里。怎么想?每个标记点至少要有唯一 id、经纬度和尺寸;需要自定义图标时用 iconPath。怎么做?笔记示例:
javascript
// repqir_pkg/pages/detail/index.js
Page({
data: {
markers: [
{
id: 1,
latitude: 40.22077,
longitude: 116.23128,
width: 24,
height: 30,
},
{
id: 2,
latitude: 40.225857999999995,
longitude: 116.23246699999999,
iconPath: '/static/images/marker.png',
width: 40,
height: 40,
},
],
}
})易错点:笔记明确说“每个标记点必须要指定 ID 属性,否则会有错误产生”;iconPath、width/height 要按实际图片和尺寸配置。转写稿里反复强调“先保存代码测试、看事件对象/返回数据里到底有什么”,这里同样适用:先把 markers 渲染出来,再处理路线,不要一次混在一起排错。
用 polyline 画出路线
polyline 用来展示两点间的行进路线,它需要的是“很多个坐标点组成的线”。为什么不能只给起点终点?因为真实道路不是直线,必须由位置服务算出一串路径点。怎么想?先拿到 points 数组,再交给 polyline,同时设置颜色和线宽。怎么做?笔记在成功回调里组装:
javascript
polyline: [
{points, color: '#5591af', width: 4},
]易错点:polyline 的 points 必须是 { latitude, longitude } 形式;颜色、宽度是显示配置,不要和坐标数据混在一起。
调用腾讯位置服务做路线规划
为什么需要位置服务?因为路线坐标不是手写的,而是由路线规划接口计算。怎么想?调用 qqMap.direction,指定出行方式 mode、起点 from、终点 to,在 success 里处理返回结果。怎么做?笔记代码:
javascript
// repqir_pkg/pages/detail/index.js
Page({
data: {},
onLoad() {
// 生成路线
this.getPolyline()
},
// 调用位置服务(路线规划)
getPolyline() {
qqMap.direction({
mode: 'bicycling',
from: '40.227978,116.22998',
to: '40.22077,116.23128',
success: ({ result }) => {
const coors = result.routes[0].polyline
const points = []
//坐标解压(返回的点串坐标,通过前向差分进行压缩)
for (let i = 2; i < coors.length; i++) {
coors[i] = Number(coors[i - 2]) + Number(coors[i]) / 1000000
}
// 获取经纬度
for (let i = 0; i < coors.length; i += 2) {
points.push({ latitude: coors[i], longitude: coors[i + 1] })
}
// 渲染数据
this.setData({
latitude: points[30].latitude,
longitude: points[30].longitude,
polyline: [
{points, color: '#5591af', width: 4},
],
})
},
})
},
})易错点:result.routes[0].polyline 要先确认存在;mode、from、to 的格式要和文档一致;points[30] 依赖返回点数量,调试时先打印 points.length,避免越界。
坐标解压与二次加工
为什么返回坐标还要处理?笔记说明:计算出来的坐标点是经过压缩的,要按官方指定方式解压,才能得到真正坐标点;为了适应小程序地图组件,还要二次加工成 { latitude, longitude }。怎么想?先做前向差分解压,再两两一组转成对象。怎么做?就是上面两个循环:第一个循环从 i = 2 开始累加还原,第二个循环步长为 2 组装经纬度。易错点:解压循环从 2 开始、除以 1000000、Number 转换都不能漏;i += 2 时不要误取 coors[i + 1] 越界。笔记也提醒:这些处理是腾讯位置服务规定好的,开发者按官方文档实现即可。
调试与易错点
转写稿没有单独演示路线规划,但其中反复出现的调试习惯可以迁移:先 log 看返回结构,再 setData 渲染;接口或回调里先根据返回结果判断成功与否;名字、参数、路径要逐一核对,转写稿里就出现过方法名写成 get、参数名写错导致失败的情况。路线规划同样要这样排:先确认 qqMap.direction 是否成功,再确认 polyline 是否存在,再确认解压后的 points 数量和首尾坐标,最后才看地图显示。
还要注意笔记中的说明:课堂项目简化了起点和终点,真实企业项目中起点、终点通常由接口提供;这不影响本知识点掌握。把地图渲染、标记点、路线数据、坐标解压四件事分开调试,比一次性排错更稳。
课件图示








代码示例
一、在线报修表单(房屋 / 维修项目 / 预约时间 / 附件 / 校验 / 提交)
xml
<!-- repair_pkg/pages/form/index.wxml -->
<authorization>
<!-- 选择报修房屋 -->
<van-cell-group border="{{false}}" title="报修房屋">
<van-cell
value="{{houseName || '请选择报修房屋'}}"
value-class="{{houseName && 'active-cell'}}"
bind:click="openHouseLayer"
is-link
border="{{false}}"
/>
</van-cell-group>
<!-- 选择维修项目 -->
<van-cell
title-width="100"
title="维修项目"
value-class="{{repairItemName && 'active-cell'}}"
bind:click="openRepairLayer"
value="{{repairItemName || '请选择维修项目'}}"
is-link
/>
<!-- 选择预约时间 -->
<van-cell
title-width="100"
title="预约时间"
value-class="{{appointment && 'active-cell'}}"
bind:click="openDateLayer"
value="{{appointment || '请选择预约时间'}}"
is-link
/>
<!-- 联系电话、问题描述 -->
<van-field model:value="{{mobile}}" label="联系电话" placeholder="请填写联系电话" />
<van-field model:value="{{description}}" label="问题描述" type="textarea" placeholder="请填写问题描述" />
<!-- 上传附件 -->
<van-uploader
bind:after-read="uploadPicture"
max-count="6"
preview-size="100"
file-list="{{attachment}}"
/>
<view class="toolbar">
<view class="button-text" bind:tap="submitForm">提交报修</view>
</view>
<!-- 房屋列表弹层 -->
<van-action-sheet
bind:close="closeHouseLayer"
bind:cancel="closeHouseLayer"
bind:select="selectHouseInfo"
round
show="{{houseLayerVisible}}"
actions="{{houseList}}"
cancel-text="取消"
title="选择报修房屋"
/>
<!-- 维修项目弹层 -->
<van-action-sheet
bind:close="closeRepairLayer"
bind:cancel="closeRepairLayer"
bind:select="selectRepairItemInfo"
round
show="{{repairLayerVisible}}"
actions="{{repairItem}}"
cancel-text="取消"
title="选择维修项目"
/>
<!-- 日期选择弹层 -->
<van-popup bind:close="closeDateLayer" round show="{{dateLayerVisible}}" position="bottom">
<van-datetime-picker
bind:cancel="closeDateLayer"
bind:confirm="selectDateInfo"
type="date"
value="{{currentDate}}"
min-date="{{currentDate}}"
/>
</van-popup>
</authorization>javascript
// repair_pkg/pages/form/index.js
// 导入表单验证插件
import wxValidate from 'wechat-validate'
Page({
behaviors: [wxValidate],
data: {
houseList: [],
repairItem: [],
houseId: '',
houseName: '',
repairItemId: '',
repairItemName: '',
mobile: '',
appointment: '',
description: '',
attachment: [],
// 三个弹层的显示状态
houseLayerVisible: false,
repairLayerVisible: false,
dateLayerVisible: false,
currentDate: Date.now(),
},
onLoad() {
// 获取房屋列表、维修项目
this.getHouseList()
this.getRepairItem()
},
// 获取房屋列表(必须是通过审核的房屋)
async getHouseList() {
const { code, data: houseList } = await wx.http.get('/house')
if (code !== 10000) return wx.utils.toast()
this.setData({ houseList })
},
// 获取维修项目
async getRepairItem() {
const { code, data: repairItem } = await wx.http.get('/repairItem')
if (code !== 10000) return wx.utils.toast()
this.setData({ repairItem })
},
// 弹层显示 / 隐藏
openHouseLayer() { this.setData({ houseLayerVisible: true }) },
closeHouseLayer() { this.setData({ houseLayerVisible: false }) },
openRepairLayer() { this.setData({ repairLayerVisible: true }) },
closeRepairLayer() { this.setData({ repairLayerVisible: false }) },
openDateLayer() { this.setData({ dateLayerVisible: true }) },
closeDateLayer() { this.setData({ dateLayerVisible: false }) },
// 获取用户选择的房屋
selectHouseInfo(ev) {
this.setData({
houseId: ev.detail.id,
houseName: ev.detail.name,
})
},
// 获取用户选择的维修项目
selectRepairItemInfo(ev) {
this.setData({
repairItemId: ev.detail.id,
repairItemName: ev.detail.name,
})
},
// 获取用户选择的预约日期
selectDateInfo(ev) {
this.setData({
appointment: wx.utils.dataFormat(ev.detail),
dateLayerVisible: false,
})
},
// 上传文件
uploadPicture(ev) {
const { file } = ev.detail
wx.uploadFile({
url: wx.http.baseURL + '/upload',
filePath: file.url,
name: 'file',
header: {
Authorization: 'Bearer ' + getApp().token,
},
success: (result) => {
// 处理返回的 json 数据
const data = JSON.parse(result.data)
if (data.code !== 10000) return wx.utils.toast('文件上传失败!')
// 先获取原来已经上传的图片
const { attachment } = this.data
// 追加新上传的图片
attachment.push(data.data)
this.setData({ attachment })
},
})
},
rules: {
houseId: [{ required: true, message: '请选择报修房屋!' }],
repairItemId: [{ required: true, message: '请选择维修的项目!' }],
mobile: [
{ required: true, message: '请填写手机号码!' },
{ pattern: /^1[3-8]\d{9}$/, message: '请填写正确的手机号码!' },
],
appointment: [{ required: true, message: '请选择预约日期!' }],
description: [{ required: true, message: '请填写问题描述!' }],
},
async submitForm() {
// 验证表单数据
if (!this.validate()) return
// 提取接口需要的数据
const { houseId, repairItemId, mobile, appointment, description, attachment } = this.data
const { code } = await wx.http.post('/repair', {
houseId,
repairItemId,
mobile,
appointment,
description,
attachment,
})
if (code !== 10000) return wx.utils.toast('在线报修失败!')
// 跳转到报修列表页面
wx.redirectTo({ url: '/repair_pkg/pages/list/index' })
},
})要点解释
van-action-sheet的actions绑定数组数据,数组元素必须含name字段,否则列表项不显示文字;show为true才显示弹层。- 三个弹层共用一套"显示/隐藏"套路:点击 cell →
setData({ xxxVisible: true });select/cancel/close三种事件里把对应变量置为false。 select事件通过ev.detail拿到用户选中项(含id、name),分别存入houseId/houseName、repairItemId/repairItemName。value-class="{{houseName && 'active-cell'}}"是短路运算:未选择时值为空串,不加类名(浅色);选择后才加active-cell变深色。value里的||提供占位提示文字,这是易错点(很多人写成固定类名导致初始就是深色)。van-datetime-picker的min-date="{{currentDate}}"(Date.now())把可选范围限制为当天及以后;confirm事件ev.detail是时间戳,必须用wx.utils.dataFormat格式化成年-月-日再展示。van-uploader中file-list用于回显已上传图片、max-count="6"限制数量(满 6 张后上传按钮自动隐藏)、preview-size="100"控制预览区宽高。wx.uploadFile返回的result.data是 JSON 字符串,必须JSON.parse后判断code;上传成功要把data.data(含url)push进attachment再setData才回显。behaviors: [wxValidate]+rules配合this.validate()使用;附件不是必填项,因此不在rules中定义。- 提交时字段名必须与接口文档一致(如
repairItemId,写错成别的名字会报"参数不存在");成功后用wx.redirectTo跳列表页,且提交前要把attachment的示例默认图清空成空数组。
二、报修列表(含空状态与四种状态标签)
xml
<!-- repair_pkg/pages/list/index.wxml -->
<authorization>
<block wx:if="{{!isEmpty}}">
<scroll-view show-scrollbar="{{false}}" enhanced scroll-y>
<view class="repairs">
<view class="repairs-title">我的报修</view>
<view class="repairs-list">
<van-cell-group
wx:for="{{repairList}}"
wx:key="id"
border="{{false}}"
mark:id="{{item.id}}"
bind:tap="goDetail"
>
<van-cell size="large" title="{{item.houseInfo}}">
<text wx:if="{{item.status === 0}}" class="tag cancel">已取消</text>
<text wx:if="{{item.status === 1}}" class="tag info">受理中</text>
<text wx:if="{{item.status === 2}}" class="tag success">上门中</text>
<text wx:if="{{item.status === 3}}" class="tag complete">已完成</text>
</van-cell>
<van-cell title="报修项目" border="{{false}}" value="{{item.repairItemName}}" />
<van-cell title="预约时间" border="{{false}}" value="{{item.appointment}}" />
<van-cell title="电话号码" border="{{false}}" value="{{item.mobile}}" />
</van-cell-group>
</view>
</view>
</scroll-view>
<view class="toolbar" bind:tap="addRepair">
<text class="enjoy-icon icon-repair"></text>
<text class="button-text">在线报修</text>
</view>
</block>
<view wx:else class="blank">
您还没有报修记录,请点击
<navigator hover-class="none" class="link" url="/repair_pkg/pages/form/index">添加</navigator>
</view>
</authorization>javascript
// repair_pkg/pages/list/index.js
Page({
data: {
repairList: [],
isEmpty: true,
},
onLoad() {
// 获取报修列表数据
this.getRepairList()
},
// 报修列表接口
async getRepairList() {
const {
code,
data: { rows: repairList },
} = await wx.http.get('/repair', { current: 1, pageSize: 10 })
if (code !== 10000) return wx.utils.toast()
this.setData({
repairList,
isEmpty: repairList.length === 0,
})
},
})要点解释
- 接口返回结构是
data.rows,解构时要用data: { rows: repairList }改名,别直接写data,否则渲染的是分页对象而不是数组。 isEmpty必须由接口返回结果计算(repairList.length === 0),不能用wx:if="{{repairList}}",空数组在模板里是"真值",会导致空白页面。- 状态与类名对应关系:
0→cancel(已取消)、1→info(受理中)、2→success(上门中)、3→complete(已完成),用四个wx:if分别渲染。 wx:key="id"与mark:id配合,方便后续在列表项上做定位/更新;整组绑定bind:tap="goDetail"跳详情。
三、报修详情 + 路线规划
xml
<!-- repair_pkg/pages/detail/index.wxml -->
<scroll-view scroll-y enhanced show-scrollbar="{{false}}">
<view class="repair-detail">
<!-- 仅"上门中"才显示地图与路线 -->
<view wx:if="{{repairDetail.status === 2}}" class="polyline">
<map
style="width: 100%; height: 100%;"
scale="15"
latitude="{{latitude}}"
longitude="{{longitude}}"
markers="{{markers}}"
polyline="{{polyline}}"
></map>
</view>
<van-cell-group border="{{false}}" title="房屋信息">
<van-cell title="{{repairDetail.houseInfo}}">
<text wx:if="{{repairDetail.status === 0}}" class="tag cancel">已取消</text>
<text wx:if="{{repairDetail.status === 1}}" class="tag info">受理中</text>
<text wx:if="{{repairDetail.status === 2}}" class="tag success">上门中</text>
<text wx:if="{{repairDetail.status === 3}}" class="tag complete">已完成</text>
</van-cell>
</van-cell-group>
<van-cell-group title="报修信息" border="{{false}}">
<van-cell title-width="200rpx" title="维修项目" value="{{repairDetail.repairItemName}}" />
<van-cell title-width="200rpx" title="手机号码" value="{{repairDetail.mobile}}" />
<van-cell title-width="200rpx" title="预约日期" value="{{repairDetail.appointment}}" />
<van-cell title="问题描述" label="{{repairDetail.description}}" />
</van-cell-group>
<view class="attachment">
<view class="title">问题附件</view>
<scroll-view scroll-x>
<image wx:for="{{repairDetail.attachment}}" wx:key="id" src="{{item.url}}"></image>
</scroll-view>
</view>
</view>
</scroll-view>
<view class="toolbar">
<view bind:tap="editRepair" class="button-text active">修改信息</view>
<view wx:if="{{repairDetail.status === 1}}" class="button-text">取消报修</view>
</view>javascript
// repair_pkg/pages/detail/index.js
Page({
data: {
repairDetail: {},
markers: [
{
id: 1,
latitude: 40.22077,
longitude: 116.23128,
width: 24,
height: 30,
},
{
id: 2,
latitude: 40.225857999999995,
longitude: 116.23246699999999,
iconPath: '/static/images/marker.png',
width: 40,
height: 40,
},
],
},
onLoad({ id }) {
// 获取报修详情的数据
this.getRepairDetail(id)
// 生成路线
this.getPolyline()
},
// 报修详情接口
async getRepairDetail(id) {
if (!id) return wx.utils.toast('参数有误!')
const { code, data: repairDetail } = await wx.http.get('/repair/' + id)
if (code !== 10000) return wx.utils.toast()
this.setData({ repairDetail })
},
// 调用位置服务(路线规划)
getPolyline() {
qqMap.direction({
mode: 'bicycling',
from: '40.227978,116.22998',
to: '40.22077,116.23128',
success: ({ result }) => {
const coors = result.routes[0].polyline
const points = []
// 坐标解压(返回的点串坐标,通过前向差分进行压缩)
for (let i = 2; i < coors.length; i++) {
coors[i] = Number(coors[i - 2]) + Number(coors[i]) / 1000000
}
// 获取经纬度
for (let i = 0; i < coors.length; i += 2) {
points.push({ latitude: coors[i], longitude: coors[i + 1] })
}
// 渲染数据
this.setData({
latitude: points[30].latitude,
longitude: points[30].longitude,
polyline: [{ points, color: '#5591af', width: 4 }],
})
},
})
},
})要点解释
- 详情 ID 来自地址参数,
onLoad({ id })直接解构;没有id时提前return并提示,避免发出/repair/undefined这样的请求。 - 地图与"取消报修"按钮都有条件:地图仅在
status === 2(上门中)展示,取消按钮仅在status === 1(受理中)展示,用wx:if控制。 markers中每个标记点必须指定id,否则会报错;iconPath可自定义图标,width/height控制尺寸。- 路线坐标是压缩过的,必须按官方方式做前向差分解压:
coors[i] = Number(coors[i-2]) + Number(coors[i]) / 1000000,再从下标 0 开始两两取latitude/longitude,这是最容易出错的地方。 - 解压后再二次加工成小程序
map组件需要的polyline: [{ points, color, width }];地图中心点取路线中间某点(如points[30])的经纬度。 scale取值范围 3–20,课程中用 15 观察效果较合适。
常见错误
弹层打开后列表空白 现象:点"请选择报修房屋"能弹出弹层,但里面没有任何选项。 原因:
van-action-sheet只认每项里的name字段作为展示文案,接口字段名与它对不上时就渲染不出来。 修法:渲染前先console.log看数组结构,必要时 map 成{ name: item.xxx, ...item };同时给空数组补一个"暂无可选数据"的兜底。选中后文字颜色不变,或初始状态就已是深色 现象:选了房屋,文字还是灰的;或者一进页面提示语就显示成选中色。 原因:
value-class写成了固定值,或条件表达式漏了判断。 修法:value-class="{{houseName ? 'active-cell' : ''}}",用三元而不是短路拼接,条件成立与否一目了然。图片上传成功但页面没有新预览图 现象:接口返回 10000,页面仍只有旧图;连续上传后只保留一张。 原因:
push之后忘了重新setData;或追加进去的对象没有url字段。 修法:const attachment = [...this.data.attachment, data.data]后this.setData({ attachment }),并确认追加项含url。上传接口 401,或
success里取code得到 undefined 现象:图片传不上去,控制台报鉴权失败;或接口其实成功了却走进失败分支。 原因:header里没带 token 或漏了Bearer前缀(含空格);result.data是 JSON 字符串,不是对象。 修法:header: { Authorization: 'Bearer ' + getApp().token },取数据前先JSON.parse(result.data)。预约日期显示成一串数字 现象:确认日期后单元格里是
1699999999999这类数字。 原因:datetimepicker的confirm事件detail是时间戳,不是格式化字符串。 修法:统一走wx.utils.dataFormat,实现时注意getMonth() + 1与个位数补零。表单校验"永远通过"或"永远不通过" 现象:必填项没填也能提交;或者全填完了仍提示请选择报修房屋。 原因:
behaviors里没注册wxValidate;或rules的键名与data字段名不一致;或if (!this.validate()) return判断写反。 修法:三处逐一核对——注册、键名同名、返回值取反后 return。提交接口报参数错误,或列表里查不到刚提交的记录 现象:校验全过,提交却失败;或提交成功但字段对不上。 原因:提交时字段名与文档不一致(如
repairItemCode/repairItemId混用),或解构时漏字段。 修法:提交前把 payload 打出来,逐字段与文档比对;解构写全六个字段。详情页地图/路线不显示 现象:状态为上门中,但地图区域空白或报错。 原因:
markers缺id;polyline直接用了未解压的坐标串;status用了字符串'2'与数字比较。 修法:marker 必带id,坐标按官方前向差分方式解压后再组装points,状态判断保持数字类型。
练习
弹层健壮性:给"选择房屋""选择维修项目"两个弹层补上空态与失败态——接口返回空数组时弹层内给出提示;并在打开弹层时若数据为空则自动重新拉取一次,说明这样做的必要性。
上传增强:为
van-uploader增加删除图片(bind:delete)与上传中的 loading 状态;上传失败时把该项从file-list中移除并 toast。思考:为什么失败时不能只弹一个提示就结束?提交防重与回填:提交按钮加 loading/disabled,避免连点产生重复报修单;再从详情页"修改信息"跳回表单,用
getRepairDetail回填全部字段(含附件),验证修改流程可复用同一套表单。列表分页:给报修列表加上拉加载更多与下拉刷新,
current递增、pageSize固定;说明为什么current不能像现在这样写死在onLoad里。路线规划降级:把
getPolyline的起点终点从写死改成由报修数据驱动,并处理direction失败或无路线的场景——只渲染markers不渲染polyline,保证地图区域不空白。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 享+生活-在线报修(组件交互) | 12:01 |
| 2 | 享+生活-在线报修(房屋列表) | 07:04 |
| 3 | 享+生活-在线报修(维修项目) | 05:09 |
| 4 | 享+生活-在线报修(选择房屋和维修项目) | 11:14 |
| 5 | 享+生活-在线报修(选择预约时间) | 13:30 |
| 6 | 享+生活-在线报修(上传附件) | 14:59 |
| 7 | 享+生活-在线报修(表单验证) | 10:08 |
| 8 | 享+生活-在线报修(提交表单) | 08:26 |
小结
- 表单页的数据流是单向的:组件事件 → 回调 →
setData→ 视图。任何"没反应"的 bug,排查顺序都应是"事件是否触发 → 回调是否执行 → 数据是否落到 data → 视图是否绑定",而不是先怀疑样式。 - 校验的时机在提交动作内,校验规则的键名必须与 data 字段严格同名。键名错位不会报错,只会静默放行,这类问题最难靠肉眼发现。
- 接口契约优先于本地命名习惯。字段名、必填项、返回结构在写第一行代码时就对齐文档,能省掉绝大部分联调时间。
- 列表页面必须同时设计"有数据"和"无数据"两套视图,空态不是锦上添花,而是功能的一部分。
- 状态值应当驱动 UI:状态文案、类名、按钮显隐、地图展示都从
status派生,避免同一套判断散落在多处,改一处漏三处。 - 异步操作要显式覆盖三态(进行中 / 成功 / 失败)。只写成功路径的功能,在真实网络下等同于不可用。
- 演示用的初始数据(如默认附件)必须在联调前清空,否则会污染真实数据,且故障现象与真实原因相距很远。
- 第三方能力(Vant 组件、位置服务)以文档为准,事件参数结构、坐标解压这类"规定动作"不要凭经验猜,猜错的代价通常是一整轮调试。