微信小程序机票预订源码解析:从航班查询到订单支付
2026/9/15 15:15:58 网站建设 项目流程

简介:面向小程序开发学习者与移动端爱好者,这份源码以“东航订票”为业务场景,完整呈现一款航班预订小程序的工程实现,可用作课程设计、毕业设计或日常练手的参考项目。包内共157个文件,涵盖wxml页面结构、wxss页面样式、js业务逻辑、json配置及less样式源文件,并配套大量png图片与少量jpg、gif素材,整体压缩包仅2.66MB,轻便易下载。从内容预览看,项目中包含加载动画、装饰图片、个人证件照等视觉素材,说明对界面细节亦有处理。目前已有120人学习下载,适合希望了解小程序目录规范、订票功能拆分、前端交互与配置写法的开发者。通过阅读源码,可快速掌握小程序从页面搭建到逻辑联调的基本路径,为独立开发同类项目打下基础。

1. 东航订机票小程序源码:先搞清楚这个 zip 包的边界

这个“东航订机票.zip”不是传统网页项目,而是一份微信小程序原生源码包。解压后你会看到一堆 jpg 和 gif:huabian、xfj、xfz、xxl、jpp 这些命名基本是按界面模块来的,downIcon 和 upIcon 是排序箭头,reviseIcon 是修改按钮。项目结构很典型:pages 目录放页面,utils 放请求封装,image 放静态资源,根目录有 app.js、app.json 和 .gitignore。适合刚入行移动开发的人拿来做机票预订流程的起步模板,也适合老手快速搭一个带城市选择、航班列表、订单确认的 demo。要注意的是,这类源码把很多数据写死在 page 里,直接跑能通,但要接真实服务得先拆数据层。

2. 航班查询页:城市选择、日期选择与查询参数组装

机票查询页是整个源码里逻辑最密的一页。定位、选日期、选乘机人、发起查询,每一步都有坑。下面按常见实现一层层拆。

2.1 城市选择器的数据结构和联动逻辑

机票查询页的门面就是两个机场选择器。多数小程序源码用的是原生picker组件,而不是自己写半屏弹层。原生 picker 的优点是省事,缺点是样式不能完全自定义。先给出一份城市数据模型:

// utils/cities.js const cities = [ { code: 'SHA', name: '上海', airport: '虹桥', hot: true, letter: 'S' }, { code: 'PEK', name: '北京', airport: '首都', hot: true, letter: 'B' }, { code: 'CAN', name: '广州', airport: '白云', hot: true, letter: 'G' } ] function filterCities(keyword) { if (!keyword) return cities return cities.filter(item => item.name.includes(keyword) || item.airport.includes(keyword)) } module.exports = { cities, filterCities }

这里把code设计成机场三字码而不是城市名,因为后端接口认的是三字码。很多 demo 源码直接拿中文名去请求,遇到上海虹桥和上海浦东时会分不清。hot字段用于在弹层里显示“热门城市”分组,letter字段是给右侧拼音索引用的。如果去掉这个字段,做排序会麻烦很多。

联动校验是常见遗漏点。如果不做,用户可以选择同一个城市作为出发和到达,生成查询请求时后端毫无意外会返回错误。所以要在bindChange里校验:

Page({ data: { cities: cities, from: null, to: null }, onFromChange(e) { const idx = Number(e.detail.value) const from = this.data.cities[idx] const to = this.data.to if (to && from.code === to.code) { wx.showToast({ title: '出发和到达不能相同', icon: 'none' }) return } this.setData({ from }) } })

这里e.detail.value是 picker 选中项的下标,不是城市对象,要先转成数字再从数组取对象,否则会拿到undefinedwx.showToasticon: 'none'是为了避免默认的 success 图标误导用户。注意在onToChange里也需要同样反向校验,不要只写一半。

原生 picker 有几个参数在源码里经常被写错,列一个表:

属性类型作用常见坑点
rangeArray数据源必须是数组,对象数组要配range-key
range-keyString对象数组时显示的字段name而不是code,否则显示三字码
valueNumber当前选中项下标不传默认 0,重置时容易掩盖真实选中项
bindchangeEventHandler选择变化时的回调只在用户操作时触发,代码 setData 不会触发

如果后续要改成自绘城市弹层,思路是用一个全屏蒙层加scroll-view,右侧固定索引,点击索引项时计算偏移量滚动到对应字母分组。这样做交互会更接近携程等票务应用,但需要自己处理scroll-into-view的 id 冲突,还要适配 iPhone 底部安全区。对于当前源码来说,先用原生 picker 把流程跑通更合理,后续再根据埋点数据决定是否重做。

2.2 日期选择:为什么不用原生 picker 而要自绘日历头部

机票列表页的日期切换通常是横向滚动的 7 天栏,而不是弹出一个巨大日历。原生pickermode="date"虽然能选日期,但要标注“低价”或“已售罄”就非常难,所以源码里一般是在scroll-view里渲染一排日期卡片。核心代码是生成一个周日期数组:

// utils/date.js function buildWeekDates(baseDate) { const arr = [] for (let i = 0; i < 7; i++) { const d = new Date(baseDate) d.setDate(d.getDate() + i) arr.push({ label: `${d.getMonth() + 1}/${d.getDate()}`, date: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`, price: 0, disabled: i === 0 }) } return arr } module.exports = { buildWeekDates }

label用于展示,date是发给后端的参数。这里用padStart补齐月和日,保证日期字符串是yyyy-MM-dd格式。很多后端接口对日期格式敏感,如果漏了补零,2025-7-12025-07-01可能被当成不同参数,轻则查不到数据,重则 500。disabled这里写的是i === 0,表示当天不能选,因为航司的当日航班在查询接口中往往有截止时间。

日期栏的 WXML 结构大概是:

<scroll-view scroll-x="true" class="date-bar"> <view wx:for="{{weekDates}}" wx:key="date" class="date-item {{selectedDate === item.date ? 'active' : ''}} {{item.disabled ? 'disabled' : ''}}" bindtap="onDateTap">onDateTap(e) { const date = e.currentTarget.dataset.date if (this.data.selectedDate === date) return this.setData({ selectedDate: date }) this.getFlights(date) }

2.3 查询参数的组装与防抖

查询参数最好收敛到一个函数里,不要在页面里到处拼对象。机票查询常见参数有出发三字码、到达三字码、日期、成人数量、儿童数量、舱位:

function buildQuery(from, to, date, passengers) { if (!from || !to) return null if (from.code === to.code) return null if (!date) return null return { depCode: from.code, arrCode: to.code, date: date, adultCount: passengers.adult, childCount: passengers.child, cabin: 'Y' } }

cabin: 'Y'表示经济舱默认。如果后续要支持头等舱和商务舱,在这个函数里加一个cabinType参数就行。乘客数量一般用stepper组件,点击+-e.detail.value是数字,但如果你直接放进datasetData,要注意类型是否被input转成字符串。可以加一句Number(e.detail.value)强行转换。

搜索按钮的防抖处理:

let searchTimer = null function handleSearch() { if (searchTimer) clearTimeout(searchTimer) searchTimer = setTimeout(() => { wx.navigateTo({ url: `/pages/flight/list?depCode=${from.code}&arrCode=${to.code}&date=${date}` }) }, 300) }

300 毫秒的延时可以过滤掉连续点击,又不会让用户觉得卡顿。这里没有直接发请求,是因为航班列表页通常是独立页面,查询参数通过 URL 传过去,再在onLoad里读取并请求航班接口。URL 中三字码都是字母,不需要编码,但为了稳妥,可以对dateencodeURIComponent,防止日期字符串里的横杠被某些容器转义。

3. 航班列表渲染与筛选:价格排序、余票状态和 loading 态

航班列表页是用户停留时间最长的页面,也是判断这个源码值不值得改的关键点。

3.1 列表项的数据模型与 WXML 绑定

航班列表页拿到接口数据后,第一件事不是直接渲染,而是把字段名标准化。后端可能返回airlineName,也可能返回airline,如果源码是前端 demo,通常把数据写死在 js 里:

// pages/flight/list.js const flights = [ { flightNo: 'MU5101', airline: '东方航空', depTime: '08:00', arrTime: '10:30', depAirport: '虹桥T2', arrAirport: '首都T2', price: 1280, discount: 0.6, remaining: 5 } ]

remaining是余票数,discount是折扣。需要谨慎处理的是价格单位:后端接口经常返回“分”,前端展示时除以 100,下单时再传回“分”。如果两边单位不一致,会出现展示价格和扣款价格不一样的问题。zip 里的源码如果写死了整数价格,说明它只是 UI 演示,接真实接口时要先确认单位。

列表 WXML 的循环渲染:

<view class="flight-card" wx:for="{{displayFlights}}" wx:key="flightNo"> <view class="time-row"> <text>{{item.depTime}}</text> <text class="arrow">→</text> <text>{{item.arrTime}}</text> </view> <view class="airport-row"> <text>{{item.depAirport}}</text> <text>{{item.arrAirport}}</text> </view> <view class="price-row"> <text class="price">¥{{item.price}}</text> <text class="remaining" wx:if="{{item.remaining > 0}}">余票{{item.remaining}}</text> <text class="sold-out" wx:else>售罄</text> </view> </view>

wx:key不要用index,因为在排序和筛选后列表位置会变化,用 index 做 key 会导致小程序的 diff 算法错误,最明显的表现是图片闪烁和输入框状态错乱。flightNo在这个场景里是唯一的,所以用它做 key。wx:else是依附在wx:if上的,中间不能有其他节点,否则不生效。

3.2 价格排序和条件筛选的边界

排序按钮在源码里对应 upIcon 和 downIcon 两张图片,点击后切换排序方向。排序逻辑:

function sortByPrice(list, order) { const clone = [...list] clone.sort((a, b) => { if (order === 'asc') return a.price - b.price return b.price - a.price }) return clone }

这里使用[...list]复制是因为Array.prototype.sort会原地修改数组。直接对this.data.flights排序会改变原始数据,后续切换筛选条件时数据已经乱了。order用字符串asc/desc比用布尔值更可读,也方便从页面 URL 参数中带上。

筛选条件允许多选时,使用 filter 组合更合适:

const source = this.data.flights const filtered = source.filter(item => { const timeOk = !this.data.timeRange || (item.depTime >= this.data.timeRange[0] && item.depTime <= this.data.timeRange[1]) const airlineOk = !this.data.airline || item.airline === this.data.airline return timeOk && airlineOk })

!this.data.timeRange表示“没有选择这个筛选项时返回全部”。如果筛选项很多,可以把这个判断抽成配置数组,不要写一长串 if。注意item.depTime如果是字符串,比较的是字典序,"08:00""8:00"表现不同。后端返回时间字段时最好统一补零。

表格里整理一下数组方法的行为差异:

方法是否修改原数组返回值在这个场景中的用途
sort原数组引用要先[...list]复制再排序
filter新数组组合筛选条件
map新数组给列表项加selected标记

3.3 loading.gif 的正确打开方式

zip 里的loading.gif通常被放在首屏,发请求时显示,回来时隐藏。很多人会在success回调里关 loading,这会导致请求失败时 loading 一直转,所以正确做法是放在complete里:

this.setData({ loading: true }) wx.request({ url: 'https://example.com/api/flights', data: query, success: res => { this.setData({ displayFlights: res.data.data.flights }) }, fail: () => { wx.showToast({ title: '网络异常', icon: 'none' }) }, complete: () => { this.setData({ loading: false }) } })

WXML 中可以这样控制:

<image wx:if="{{loading}}" src="/image/loading.gif" class="loading-icon" /> <view wx:else class="flight-list">...</view>

loading初始值为true,这样页面跳转后有一张 gif 顶着,等数据回来再替换成列表。如果图片放在image目录下,路径要写成/image/loading.gif,不要用相对路径../../image/loading.gif,后者在分包或自定义 tabBar 时容易失效。另外,gif 在部分 Android 机型上会掉帧,如果体验不 OK,可以换成wx.showLoading的 loading 动画,它的显示层级更高,会盖在导航栏下面。

还有一个常见需求是下拉刷新。在app.json的页面配置里打开"enablePullDownRefresh": true,然后页面里实现onPullDownRefresh。注意在请求完成后要手动调wx.stopPullDownRefresh(),否则刷新动画会一直转。刷新期间和 loading 动画同时出现会乱,一般做法是:下拉刷新时只显示原生动画,首次进入页面时才显示loading.gif。可以加一个isFirstLoad标志区分。

3.4 上拉加载更多与空状态处理

航班列表会做分页,否则一次返回几十上百条数据,渲染性能会很差。分页需要维护pagehasMore两个字段,onReachBottom是页面滚动到底部时自动触发的,不需要自己监听 scroll:

onReachBottom() { if (!this.data.hasMore || this.data.loading) return const page = this.data.page + 1 this.fetchFlights(page) }

fetchFlights成功后,新数据要用concat追加到旧的displayFlights后面,而不是直接替换:

this.setData({ displayFlights: this.data.displayFlights.concat(res.data.data.list), page: page, hasMore: res.data.data.list.length > 0 })

hasMorefalse时要显示“没有更多了”,否则会一直触底请求。空数据也要单独处理,不能只显示一个空白页面。常见做法是加一个<view class="empty">暂无航班</view>,并且设置empty状态,避免和 loading 同时出现。

4. 登录与下单流程:code 换 token、订单参数与支付预留位

订票小程序真正卡人的不是展示页,而是登录和下单这一段。源码往往只做了模拟数据,接真实接口时全要重写。

4.1 从 wx.login 到 token 的关键细节

有的源码在进入首页时就弹wx.getUserProfile,这是很多老项目的坏习惯。机票预订场景只有在下单前才需要登录,所以应当把登录动作放在点击“立即预订”按钮时。登录链路分三步:小程序端wx.login拿 code,后端用 code 向微信接口换 openid 和 session_key,再把 openid 映射为自己系统的用户并返回 token。

// utils/auth.js function login() { return new Promise((resolve, reject) => { wx.login({ success: async res => { if (!res.code) return reject(new Error('login fail')) try { const { token } = await request.post('/auth/login', { code: res.code }) wx.setStorageSync('token', token) resolve(token) } catch (err) { reject(err) } }, fail: reject }) }) }

注意res.code有效期只有 5 分钟且只能使用一次,拿到后必须立即发给后端。后端换取 openid 时需要的 appid 和 secret 只能放在服务器环境变量里,绝对不要写进小程序代码。zip 源码里如果看到appid: "your appid"这种占位符,直接替换成自己的,secret不要动。

登录态过期处理是另一个高频问题。接口返回 401 时,很多源码只弹一个“请重新登录”的 toast,但正确的做法是:在请求拦截器里统一判断,如果 401 就清掉旧 token,重新调wx.login,并把失败请求放进队列重放。demo 阶段可以先只做一步,保证 token 失效时能跳回登录流程即可。

4.2 订单确认页的参数收集与校验

订单确认页要收集乘机人姓名、证件号、手机号、优惠券 id,选乘机人时还会复用城市选择器的思路,弹一个底部 sheet。先写一个证件校验函数:

function validateIdCard(id) { const reg = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}(\d|X)$/ return reg.test(String(id).toUpperCase()) }

这个正则只判断格式,不判断校验位。真实项目里还要算前 17 位的加权因子,不然伪造号码也能通过。如果源码里没有校验逻辑,建议至少把空格和中间的·去干净再做匹配。手机号校验用/^1[3-9]\d{9}$/,不要写得太宽,避免后端收到一堆脏数据。

订单参数在提交前统一组织:

字段类型说明
flightNostring航班号,用于锁定舱位
segDatestring乘机日期,yyyy-MM-dd
passengersarray乘客数组,元素是{name, idCard}
contactNamestring联系人
contactPhonestring手机号
couponIdstring优惠券 id,可空
remarkstring订单备注,可空

提交时给按钮加一个submitting状态,防止重复点击:

const order = buildOrder(this.data) if (!order) return this.setData({ submitting: true }) request.post('/order/create', order) .then(res => { wx.navigateTo({ url: `/pages/order/detail?id=${res.data.orderId}` }) }) .catch(() => { wx.showToast({ title: '创建失败', icon: 'none' }) }) .finally(() => { this.setData({ submitting: false }) })

.finally在微信开发者工具的基础库中已经支持,不用像老代码那样在 success 和 fail 里重复写setData。如果项目运行的微信基础库版本较旧,可以改用complete回调。按钮disabled属性也要绑定submitting,不然用户双击还是能触发。

4.3 为什么不直接放一个完整的 wx.requestPayment

很多订单支付的源码只做到创建订单,支付是空着的。原因很现实:wx.requestPayment需要后端先调用微信支付下单接口生成prepay_id,再对timeStampnonceStrpackagesignTypepaySign做签名,其中paySign的生成需要商户 API 密钥。没有真实商户号,前端就算写了这个 API 也会报requestPayment:fail。所以源码里通常只是预留了支付按钮的位置。

如果之后要接支付,基本流程是:

wx.requestPayment({ timeStamp: payData.timeStamp, nonceStr: payData.nonceStr, package: payData.package, signType: 'MD5', paySign: payData.paySign, success: () => { wx.redirectTo({ url: `/pages/result/success?orderId=${orderId}` }) }, fail: err => { if (err.errMsg.includes('cancel')) return wx.showToast({ title: '支付失败', icon: 'none' }) } })

payData全部来自/order/create返回结果,不要在前端自行拼接。signType不一定都是 MD5,如果后端配置了 RSA 或 HMAC-SHA256,要跟着改。err.errMsg里包含cancel时是用户主动取消,不要当成支付失败弹 toast,否则用户会在支付面板里反复看到“支付失败”弹窗。另外,wx.redirectTo会关闭当前支付确认页,保证用户不能返回重复支付。

5. zip 转 demo 的最后一公里:导入工具、图片处理和备案备注

资源包拿到手,真正跑起来只需要几步,但每一步都有对应的报错处理。

5.1 从 zip 导入微信开发者工具

先把 zip 解压到纯英文目录,项目文件夹名不要带空格和中文。打开微信开发者工具,选择“小程序”,点“导入”,目录选中到包含app.json的那一层。如果报错app.json: File not found,多半是选错了目录,选中了外层文件夹。如果没有 AppID,可以选择测试号,但wx.requestPaymentwx.getUserProfile等接口会受限。zip 源码里的project.config.json如果带了 appid,那也是别人的,要改成自己的再上传。

5.2 图片资源去重与 CDN 化

zip 里的 jpg 资源体积通常不小,小程序主包有 2MB 限制,所以最好把静态图片转成 CDN URL。downIcon.jpgupIcon.jpg这类箭头图标可以换成 iconfont 或 SVG,不要用位图。gif 只保留一张 loading 图,其他动效能用 CSS 实现的就不要放图片。.gitignore里至少要包含node_modules/project.private.config.json,后者是本机配置,提交到仓库会和队友互相覆盖。

5.3 小程序备案备注信息怎么填

备案是上线前绕不开的一步。这个源码的备注可以参考:本小程序用于展示航班查询与机票预订流程,页面包含航班信息查询、价格展示、订单信息填写,不涉及虚拟商品支付与用户生成内容。注意不要写“测试”“demo”这类词,审核人员会认为不是正式服务。如果图片素材用的是东航相关 logo,备案提交前要确认是否有授权,否则换成自己拍摄或可商用的素材再上线。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询