预约类小程序里的时间选择器,通常并不是一个静态列表。以家政上门、门店预约、设备租赁为例,每个时间段都有独立库存,用户打开选择器的那一刻,已经约满的时间应该直接被禁用,而不是点击后弹一个“库存不足”的提示。微信小程序原生 <picker> 组件支持通过 range 动态调整可选列表,但它没有单项 disabled 的配置入口。常见的折中做法是把库存为 0 的时间从 range 中剔除,用户虽然选不到,但也看不到这些时段原本被占满,交互上缺少“满员”反馈。要实现真正意义上的禁用态,比较稳妥的方案是自定义 picker 弹层,用数据中的 disabled 字段驱动样式和点击行为。

本文会拆解一套可落地的实现思路:组件如何接收库存数据,如何把 stock 转换为 disabled 状态,WXML 如何渲染置灰项,点击时如何拦截,以及库存实时变化后如何避免用户选中已失效的时间。代码以微信小程序自定义组件为例,原生 <picker> 的局限也会一并说明。
原生picker的局限与两种可选方案
小程序内置的 <picker> 在 mode 为 time 或 multiSelector 时,只能通过 range 或 range-key 控制当前展示的数据源。range 可以动态替换,比如库存请求回来后,把 stock 大于 0 的时间塞进 range,不可选时间直接不要。这个方案实现成本最低,但缺陷很明显:用户完全不知道某些时间被隐藏了,尤其当可用时间不连续时,列表会显得跳跃。例如 09:00、11:00、14:00 可用,中间的 10:00、13:00 不见了,用户会以为系统只开放这些时刻,而不是因为约满。
另一种做法是放弃原生弹层,自己写一个底部弹出的选择面板。面板中完整渲染所有时间段,对库存不足的项附加 disabled 样式,并在点击事件里做拦截。这样做的好处是信息完整,用户可以直观看到哪些时间段已约满,后续也方便扩展“余量仅剩 1”这样的提示。代价是需要多写一个组件的 WXML、WXSS 和交互逻辑,但对预约类业务来说,这个投入是值得的。
还有一种混合思路:继续使用原生 <picker>,但在旁边增加一个图例或提示,告知用户被隐藏的时间是约满状态。但这仍然不能解决“不可选时间不可见”的问题,而且多列联动时也容易出错。因此,下文重点介绍自定义 picker 弹层的实现。
库存数据如何转化为禁用状态
库存接口一般返回类似 [{ "time": "09:00", "stock": 0 }, { "time": "10:00", "stock": 3 }] 的结构。组件内部不建议直接把这份数据放进 WXML 做判断,因为 WXML 里的表达式能力有限,写太复杂的逻辑容易埋坑。比较清晰的做法是,在组件收到 stockList 后,立即映射出一份新的 timeOptions 数组,每项包含 label、value、stock 和 disabled。disabled 的判定条件通常为 stock 小于等于 0,如果业务上还需要预留库存,比如至少保留 1 个名额给现场客户,那么判定条件可以写成 stock 小于等于预留数。
这个映射过程要放在 properties 的 observer 里,确保父页面每次 setData 更新库存列表时,组件都能自动重新计算。组件被创建时也要主动执行一次,否则首屏可能拿不到已经传入的初始库存。映射函数最好保持纯净,只做数据转换,不掺杂界面提示逻辑,这样后续维护会省很多事。
另一个容易忽略的点是:如果当前已经有一个选中的时间,但新的库存数据里这个时间被置为禁用,组件不能只是把选项变灰,还必须清空选中值并提示用户。否则用户提交订单时,可能拿着一个已经约满的时间去下单,后端再拒绝就显得体验很差。
Component({
properties: {
stockList: {
type: Array,
value: [],
observer: function (newVal) {
this.refreshTimeOptions(newVal);
}
},
selectedTime: {
type: String,
value: ''
}
},
data: {
timeOptions: [],
showPicker: false
},
lifetimes: {
attached: function () {
this.refreshTimeOptions(this.data.stockList);
}
},
methods: {
refreshTimeOptions: function (stockList) {
var timeOptions = (stockList || []).map(function (item) {
return {
label: item.time,
value: item.time,
stock: item.stock,
disabled: item.stock <= 0
};
});
var selectedTime = this.data.selectedTime || this.properties.selectedTime;
if (selectedTime) {
var selectedItem = timeOptions.find(function (item) {
return item.value === selectedTime && item.disabled;
});
if (selectedItem) {
this.setData({ selectedTime: '' });
wx.showToast({ title: '所选时段库存已用完', icon: 'none' });
}
}
this.setData({ timeOptions: timeOptions });
},
openPicker: function () {
this.setData({ showPicker: true });
},
closePicker: function () {
this.setData({ showPicker: false });
},
noop: function () {},
onSelectTime: function (e) {
var index = e.currentTarget.dataset.index;
var item = this.data.timeOptions[index];
if (!item || item.disabled) {
wx.showToast({ title: '该时段不可选', icon: 'none' });
return;
}
this.setData({ showPicker: false });
this.triggerEvent('change', { value: item.value, label: item.label, stock: item.stock });
}
}
});
自定义picker组件的结构、样式与交互
组件的 WXML 结构分为三层:半透明遮罩、底部弹出面板、时间项列表。遮罩负责关闭弹层,面板需要阻止点击穿透,时间项则根据 disabled 动态追加样式。这里不建议使用 button 标签来做时间项,因为 button 自带各种默认样式,重置起来麻烦。直接用 <view> 配合 bindtap 即可,在事件回调里判断 disabled,这样禁用的视觉样式和点击拦截可以统一在 JS 中处理。
下面是一个简化版结构,重点在 class 绑定和 data-index 传参。每个时间项会同时显示时段文字,如果该时段约满,可以额外显示“约满”的提示标签,增强信息可读性。
<view class="picker-mask" wx:if="{{showPicker}}" bindtap="closePicker">
<view class="picker-panel" catchtap="noop">
<view class="picker-title">请选择时间段</view>
<view class="time-list">
<view
wx:for="{{timeOptions}}"
wx:key="value"
class="time-item {{item.disabled ? 'disabled' : ''}}"
data-index="{{index}}"
bindtap="onSelectTime"
>
<text>{{item.label}}</text>
<text wx:if="{{item.disabled}}" class="disabled-tip">约满</text>
</view>
</view>
</view>
</view>
样式上,禁用态通常用浅灰背景、浅色文字,同时保持足够对比度,避免用户产生误触。弹层底部要适配 iPhone 等设备的安全区,可以用 env(safe-area-inset-bottom) 处理。时间项宽度建议用百分比加 flex-wrap,不要写死 rpx,这样不同屏幕宽度下不会出现最后一列溢出。
.picker-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.picker-panel {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
.time-list {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
}
.time-item {
width: 28%;
margin: 12rpx 2.5%;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f7f8fa;
border-radius: 12rpx;
font-size: 28rpx;
color: #333333;
}
.time-item.disabled {
background: #f2f3f5;
color: #c0c4cc;
}
.disabled-tip {
margin-left: 8rpx;
font-size: 20rpx;
color: #c0c4cc;
}
交互逻辑里最重要的一步是 onSelectTime 的拦截。拿到当前项后先判断 disabled,如果为 true,直接 toast 提示并返回,不执行任何选中或关闭弹层的动作。对于使用 catchtap 代替 bindtap 的场景,拦截逻辑仍然要保留,因为样式上灰色并不代表事件一定被阻断,只有 JS 里的判断才是最终兜底。
实时库存更新与边界场景处理
库存数据通常不会只在页面加载时请求一次。高峰期需要轮询,比如每 30 秒拉取一次;如果后端支持 WebSocket,也可以由服务端推送库存变化。无论哪种方式,父页面只需要把新的 stockList 通过 setData 更新给自定义组件,组件内部的 observer 就会触发重新计算,已经有用户停留的时间选择器也能自动刷新禁用状态。
页面侧可以使用定时器来做基础轮询,如果没有 WebSocket 条件。轮询间隔不宜太短,否则小程序请求会被频繁占用,也容易触发并发限制。一般 30 秒或 60 秒已经能满足多数预约场景。获取到新列表后,直接 setData 替换整个 stockList 即可,不用手动去调组件里的方法,observer 会自动响应。
Page({
data: {
stockList: [],
selectedTimeLabel: ''
},
onLoad: function () {
this.fetchStockList();
this.startPolling();
},
fetchStockList: function () {
var self = this;
wx.request({
url: 'https://api.ipipp.com/getTimeStock',
success: function (res) {
self.setData({ stockList: res.data.list });
}
});
},
startPolling: function () {
var self = this;
this.timer = setInterval(function () {
self.fetchStockList();
}, 30000);
},
onUnload: function () {
clearInterval(this.timer);
},
onTimeChange: function (e) {
this.setData({ selectedTimeLabel: e.detail.label });
}
});
边界场景也要提前设计好。比如库存接口返回空数组或请求失败时,组件不能把所有时间直接置灰,否则用户会以为全时段约满。此时可以保留上一份可用数据,并给出轻提示。再比如用户停留在弹层中,库存刚好被抢完,组件清空选中值并 toast 后,用户可能还想换个时间,弹层最好不要自动关闭,让用户继续选择。这些细节虽然小,但会直接影响预约流程的完成率。
整体来看,自定义 picker 动态禁用时间选择范围的核心不在于 UI 有多复杂,而在于把库存状态提前映射成 disabled 字段,并保证每次库存更新都能驱动组件重新计算。只要数据转换、样式绑定、点击拦截和实时刷新这四个环节处理清楚,原生 <picker> 缺失的单项禁用能力就能补上,预约类小程序的交互也会更顺畅。