hookehuyr

feat(离线模式): 添加弱网检测和离线预约码功能

实现弱网检测机制,当网络不可用时自动切换至离线模式
新增离线预约码页面和组件,支持本地缓存和二维码生成
添加网络状态监听和预约码数据预加载功能
更新文档说明离线模式的使用流程和实现细节
......@@ -15,6 +15,7 @@ declare module 'vue' {
NutFormItem: typeof import('@nutui/nutui-taro')['FormItem']
NutInput: typeof import('@nutui/nutui-taro')['Input']
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
OfflineQrCode: typeof import('./src/components/offlineQrCode.vue')['default']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
......
1. 如果判断是没有网络或者长时间无法加载出页面, 判定进入弱网模式.
2. 如果是弱网模式, 用户打开一个新页面, 文字描述提示用户网络不好, 进入离线模式, 中间有一个圆形按钮文字是预约码, 用户点击进入新的离线版预约版页面.
3. 新做一个离线版预约码组件 仿照/components/qrCode.vue, 但是功能需要调整, 因为是离线状态, 所以刷新功能是要删除掉的, 对应qrcodeStatusAPI接口查询二维码相关功能也要删除掉, 不会显示二维码的状态, billPersonAPI接口相关功能也要删除掉, 因为不会有payId传进来. 也不会有轮询的功能. 也不会有查看预约记录的显示, 当有网情况下进入时就需要把qrcodeListAPI接口数据保存到本地, 如果判断是没有网络或者长时间无法加载出页面就要跳转到, 一个结构和内容类似于bookingCode的离线版新页面, 因为没有网络不能使用'/admin?m=srv&a=get_qrcode&key=' + item.qr_code的方式获取二维码,需要找一个生成二维码的库本地生成二维码.
4. 现在接口没有数据, 先帮我mock数据, 数据格式和qrcodeListAPI接口返回的格式一致.
2. 关键功能模块实现(微信小程序示例)
以下是核心代码实现,基于微信小程序的 wx.getNetworkType 检测网络、wx.setStorageSync 本地存储、自有后端同步的方案:
```javascript
// pages/verify/verify.js
// 下面是主要思路和实现, 需要修改的是扫描成功之后需要跳转到另一个页面显示核销成功/失败的提示
// 1. 调用 scanCodeVerify 方法开始扫码核销
// 2. 扫码成功后,调用 handleVerify 方法处理核销逻辑
// 3. 在 handleVerify 方法中,先检测网络状态
// 4. 如果有网,实时校验核销码并调用后端核销接口
// 5. 如果无网,先校验本地存储中是否有该核销码的记录
// 6. 如果有记录,说明之前核销过,提示用户已核销
// 7. 如果无记录,提示用户无网络,无法核销
// 8. 核销成功后,同步更新本地记录(可选)
Page({
/**
* 扫码核销核心方法
*/
scanCodeVerify() {
// 1. 调起微信扫码API
wx.scanCode({
onlyFromCamera: true, // 仅允许从相机扫码
scanType: ['qrCode'], // 仅识别二维码
success: (res) => {
const verifyCode = res.result; // 扫码获取的核销码
this.handleVerify(verifyCode);
},
fail: (err) => {
wx.showToast({ title: '扫码失败,请重试', icon: 'none' });
console.error('扫码失败:', err);
}
});
},
/**
* 处理核销逻辑(核心)
* @param {string} verifyCode 核销码
*/
handleVerify(verifyCode) {
wx.showLoading({ title: '核销中...' });
// 第一步:检测网络状态
wx.getNetworkType({
success: (networkRes) => {
const networkType = networkRes.networkType;
// 判断是否有网(wifi/4g/5g为有网,none/unknown为无网)
if (['wifi', '4g', '5g', '3g', '2g'].includes(networkType)) {
// 有网场景:实时校验+核销
this.verifyOnline(verifyCode);
} else {
// 无网场景:本地校验+离线核销
this.verifyOffline(verifyCode);
}
},
fail: () => {
// 网络检测失败,默认走离线逻辑
this.verifyOffline(verifyCode);
wx.hideLoading();
}
});
},
/**
* 有网核销逻辑
* @param {string} verifyCode 核销码
*/
verifyOnline(verifyCode) {
wx.request({
url: 'https://你的服务器域名/api/verify', // 后端核销接口
method: 'POST',
data: { verifyCode },
success: (res) => {
wx.hideLoading();
if (res.data.code === 200) {
wx.showToast({ title: '核销成功', icon: 'success' });
// 同步本地预约状态(可选)
this.updateLocalVerifyStatus(verifyCode, true);
} else {
wx.showToast({ title: res.data.msg || '核销失败', icon: 'none' });
}
},
fail: (err) => {
wx.hideLoading();
// 网络请求失败,降级到离线核销
wx.showToast({ title: '网络不稳定,将使用离线核销', icon: 'none' });
setTimeout(() => {
this.verifyOffline(verifyCode);
}, 1500);
console.error('在线核销失败:', err);
}
});
},
/**
* 无网核销逻辑
* @param {string} verifyCode 核销码
*/
verifyOffline(verifyCode) {
wx.hideLoading();
// 1. 读取本地缓存的预约数据(需提前同步到本地)
const localAppointments = wx.getStorageSync('localAppointments') || [];
// 2. 本地校验核销码是否有效
const targetAppointment = localAppointments.find(item => item.verifyCode === verifyCode);
if (!targetAppointment) {
wx.showToast({ title: '未找到该预约记录', icon: 'none' });
return;
}
if (targetAppointment.isVerified) {
wx.showToast({ title: '该预约已核销', icon: 'none' });
return;
}
// 3. 本地核销:更新本地状态+记录离线操作
targetAppointment.isVerified = true;
const offlineVerifyRecords = wx.getStorageSync('offlineVerifyRecords') || [];
offlineVerifyRecords.push({
verifyCode,
appointmentId: targetAppointment.id,
verifyTime: new Date().getTime(), // 核销时间戳
status: 'pending' // 待同步
});
// 4. 保存本地修改
wx.setStorageSync('localAppointments', localAppointments);
wx.setStorageSync('offlineVerifyRecords', offlineVerifyRecords);
wx.showToast({ title: '离线核销成功,网络恢复后自动同步', icon: 'success' });
},
/**
* 网络恢复后同步离线核销记录
*/
syncOfflineRecords() {
// 监听网络状态变化
wx.onNetworkStatusChange((res) => {
if (res.isConnected) {
const offlineRecords = wx.getStorageSync('offlineVerifyRecords') || [];
if (offlineRecords.length === 0) return;
// 批量同步离线记录到服务器
wx.request({
url: 'https://你的服务器域名/api/syncOfflineVerify',
method: 'POST',
data: { records: offlineRecords },
success: (res) => {
if (res.data.code === 200) {
// 同步成功:清空离线记录
wx.setStorageSync('offlineVerifyRecords', []);
wx.showToast({ title: '离线核销记录已同步', icon: 'success' });
}
},
fail: (err) => {
console.error('离线记录同步失败:', err);
}
});
}
});
},
/**
* 提前同步预约数据到本地(页面加载时执行)
*/
syncAppointmentsToLocal() {
// 页面初始化/小程序启动时,拉取预约数据到本地
wx.request({
url: 'https://你的服务器域名/api/getAppointments',
success: (res) => {
if (res.data.code === 200) {
wx.setStorageSync('localAppointments', res.data.data);
}
},
fail: () => {
// 网络失败则使用上次缓存的本地数据
console.log('使用本地缓存的预约数据');
}
});
},
/**
* 更新本地核销状态(辅助方法)
*/
updateLocalVerifyStatus(verifyCode, isVerified) {
const localAppointments = wx.getStorageSync('localAppointments') || [];
const index = localAppointments.findIndex(item => item.verifyCode === verifyCode);
if (index > -1) {
localAppointments[index].isVerified = isVerified;
wx.setStorageSync('localAppointments', localAppointments);
}
},
/**
* 页面加载时初始化
*/
onLoad(options) {
// 1. 同步预约数据到本地
this.syncAppointmentsToLocal();
// 2. 监听网络状态,同步离线记录
this.syncOfflineRecords();
// 3. 主动检查一次是否有未同步的离线记录
wx.getNetworkType({
success: (res) => {
if (['wifi', '4g', '5g'].includes(res.networkType)) {
this.syncOfflineRecords();
}
}
});
}
});
```
3. 配套设计要点
- 本地数据预处理
- 小程序启动 / 核销页面加载时,主动拉取当前门店 / 账号下的待核销预约数据,缓存到本地(wx.setStorageSync 或 wx.setStorage);
- 本地数据需包含:预约 ID、核销码、用户信息、预约时间、核销状态等核心字段,避免冗余。
- 防重复核销设计
- 本地记录核销码的核销状态(isVerified),即使无网也能避免重复核销;
- 服务器端需做最终校验,同步离线记录时若发现已核销,返回提示并更新本地状态。
- 数据安全与容错
- 本地存储的核销记录需加简单加密(如 base64 + 时间戳),防止篡改;
- 离线记录同步时,服务器需校验核销码有效性、时间范围(如预约有效期),避免无效核销;
- 小程序退出 / 重启后,保留本地缓存(微信小程序本地缓存默认长期有效,可设置过期时间)。
- 用户体验优化
- 无网核销时,明确提示 “离线核销成功,网络恢复后自动同步”;
- 网络恢复后自动同步,无需用户手动操作;
- 提供 “离线记录查询” 入口,方便查看未同步的核销记录。
总结
核心逻辑:无网时依赖本地缓存的预约数据完成核销,记录离线操作;有网时实时核销,网络恢复后自动同步离线记录;
关键保障:提前同步待核销数据到本地、本地标记核销状态防重复、服务器端做最终校验;
体验优化:明确的网络状态提示、自动同步机制,减少用户感知网络差异。
......@@ -56,6 +56,7 @@
"axios-miniprogram": "^2.7.2",
"dayjs": "^1.11.19",
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"qs": "^6.14.1",
"taro-plugin-pinia": "^1.0.0",
"vue": "^3.3.0",
......
......@@ -68,6 +68,9 @@ importers:
pinia:
specifier: ^3.0.3
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
qrcode:
specifier: ^1.5.4
version: 1.5.4
qs:
specifier: ^6.14.1
version: 6.14.1
......@@ -2751,6 +2754,9 @@ packages:
resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==}
engines: {node: '>= 10'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
......@@ -3076,6 +3082,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
......@@ -3186,6 +3196,9 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dingtalk-jsapi@2.15.6:
resolution: {integrity: sha512-804mFz2AFV/H9ysmo7dLqMjSGOQgREsgQIuep+Xg+yNQeQtnUOYntElEzlB798Sj/691e4mMKz9mtQ7v9qdjuA==}
......@@ -3617,6 +3630,10 @@ packages:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
......@@ -4512,6 +4529,10 @@ packages:
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
engines: {node: '>=6'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
......@@ -4912,6 +4933,10 @@ packages:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
......@@ -5084,6 +5109,10 @@ packages:
platform@1.3.6:
resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
......@@ -5611,6 +5640,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
......@@ -5734,6 +5768,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
......@@ -5928,6 +5965,9 @@ packages:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
......@@ -6655,6 +6695,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
......@@ -6715,6 +6758,9 @@ packages:
xxhashjs@0.2.2:
resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
......@@ -6727,10 +6773,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
......@@ -9724,6 +9778,12 @@ snapshots:
cli-width@3.0.0: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@7.0.4:
dependencies:
string-width: 4.2.3
......@@ -10065,6 +10125,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decimal.js@10.6.0: {}
decode-uri-component@0.2.2: {}
......@@ -10171,6 +10233,8 @@ snapshots:
didyoumean@1.2.2: {}
dijkstrajs@1.0.3: {}
dingtalk-jsapi@2.15.6:
dependencies:
promise-polyfill: 7.1.2
......@@ -10811,6 +10875,11 @@ snapshots:
dependencies:
locate-path: 3.0.0
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
......@@ -11776,6 +11845,10 @@ snapshots:
p-locate: 3.0.0
path-exists: 3.0.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
......@@ -12149,6 +12222,10 @@ snapshots:
dependencies:
p-limit: 2.3.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
......@@ -12297,6 +12374,8 @@ snapshots:
platform@1.3.6: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6):
......@@ -12858,6 +12937,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.14.1:
dependencies:
side-channel: 1.1.0
......@@ -13004,6 +13089,8 @@ snapshots:
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
requires-port@1.0.0: {}
resolve-from@4.0.0: {}
......@@ -13225,6 +13312,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
......@@ -14102,6 +14191,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7
......@@ -14156,14 +14247,35 @@ snapshots:
dependencies:
cuint: 0.2.2
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yaml@2.8.2: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@20.2.9: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@16.2.0:
dependencies:
cliui: 7.0.4
......
......@@ -24,6 +24,8 @@ export default {
'pages/visitorList/index',
'pages/volunteerLogin/index',
'pages/verificationResult/index',
'pages/weakNetwork/index',
'pages/offlineBookingCode/index',
],
subpackages: [ // 配置在tabBar中的页面不能分包写到subpackages中去
{
......
/*
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-28 11:04:17
* @FilePath: /myApp/src/app.js
* @LastEditTime: 2026-01-07 20:54:02
* @FilePath: /xyxBooking-weapp/src/app.js
* @Description: 文件描述
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './app.less'
import { saveCurrentPagePath, needAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect'
import Taro from '@tarojs/taro'
import { qrcodeListAPI } from '@/api/index'
import { formatDatetime } from '@/utils/tools'
const formatGroup = (data) => {
let lastPayId = null;
for (let i = 0; i < data.length; i++) {
if (data[i].pay_id !== lastPayId) {
data[i].sort = 1;
lastPayId = data[i].pay_id;
} else {
data[i].sort = 0;
}
}
return data;
}
const App = createApp({
// 对应 onLaunch
......@@ -25,12 +41,54 @@ const App = createApp({
saveCurrentPagePath(full_path)
}
const preloadQrData = async () => {
try {
const { code, data } = await qrcodeListAPI();
if (code && data) {
data.forEach(item => {
item.qr_code_url = '/admin?m=srv&a=get_qrcode&key=' + item.qr_code;
item.datetime = formatDatetime({ begin_time: item.begin_time, end_time: item.end_time })
item.sort = 0;
});
const validData = data.filter(item => item.qr_code !== '');
if (validData.length > 0) {
const processed = formatGroup(validData);
Taro.setStorageSync('OFFLINE_QR_DATA', processed);
} else {
Taro.removeStorageSync('OFFLINE_QR_DATA');
}
}
} catch (e) {
console.error('Preload QR failed', e);
}
};
const checkNetworkAndPreload = () => {
Taro.getNetworkType({
success: (res) => {
const isConnected = ['wifi', '4g', '5g', '3g'].includes(res.networkType);
if (isConnected) {
preloadQrData();
}
}
});
};
Taro.onNetworkStatusChange((res) => {
if (res.isConnected) {
preloadQrData();
}
});
checkNetworkAndPreload();
if (!needAuth()) return
if (path === 'pages/auth/index') return
try {
await silentAuth()
checkNetworkAndPreload();
} catch (error) {
navigateToAuth(full_path || undefined)
}
......
<template>
<view class="qr-code-page">
<view v-if="userList.length" class="show-qrcode">
<view class="qrcode-content">
<view class="user-info">{{ userinfo.name }}&nbsp;{{ userinfo.id }}</view>
<view class="user-qrcode">
<view class="left" @tap="prevCode">
<image :src="icon_1" />
</view>
<view class="center">
<!-- 离线模式直接显示生成的 base64 图片 -->
<image :src="currentQrCodeUrl" mode="aspectFit" />
<!-- 离线模式不显示状态覆盖层,因为无法获取最新状态 -->
</view>
<view class="right" @tap="nextCode">
<image :src="icon_2" />
</view>
</view>
<view style="color: red; margin-top: 32rpx;">{{ userinfo.datetime }}</view>
<view style="color: #999; font-size: 24rpx; margin-top: 10rpx;">(离线模式)</view>
</view>
<view class="user-list">
<view
@tap="selectUser(index)"
v-for="(item, index) in userList"
:key="index"
:class="[
'user-item',
select_index === index ? 'checked' : '',
userList.length > 1 && item.sort ? 'border' : '',
]">
{{ item.name }}
</view>
</view>
</view>
<view v-else class="no-qrcode">
<image :src="icon_3" style="width: 320rpx; height: 320rpx;" />
<view class="no-qrcode-title">本地无缓存预约记录</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import Taro from '@tarojs/taro'
import QRCode from 'qrcode'
import icon_1 from '@/assets/images/左1@2x.png'
import icon_2 from '@/assets/images/右1@2x.png'
import icon_3 from '@/assets/images/暂无1@2x.png'
const props = defineProps({
list: {
type: Array,
default: () => []
}
});
const select_index = ref(0);
const userList = ref([]);
const qrCodeImages = ref({}); // 存储生成的二维码图片 base64
const prevCode = () => {
select_index.value = select_index.value - 1;
if (select_index.value < 0) {
select_index.value = userList.value.length - 1;
}
};
const nextCode = () => {
select_index.value = select_index.value + 1;
if (select_index.value > userList.value.length - 1) {
select_index.value = 0;
}
};
function replaceMiddleCharacters(inputString) {
if (!inputString || inputString.length < 15) {
return inputString;
}
const start = Math.floor((inputString.length - 8) / 2);
const end = start + 8;
const replacement = '*'.repeat(8);
return inputString.substring(0, start) + replacement + inputString.substring(end);
}
const formatId = (id) => replaceMiddleCharacters(id);
const userinfo = computed(() => {
return {
name: userList.value[select_index.value]?.name,
id: formatId(userList.value[select_index.value]?.id_number),
datetime: userList.value[select_index.value]?.datetime,
};
});
const currentQrCodeUrl = computed(() => {
const key = userList.value[select_index.value]?.qr_code;
return qrCodeImages.value[key] || '';
})
const selectUser = (index) => {
select_index.value = index;
}
const generateQrCodes = () => {
for (const item of userList.value) {
if (item.qr_code && !qrCodeImages.value[item.qr_code]) {
try {
// 使用 create + SVG 手动生成,避免 Taro 中 Canvas 依赖问题
const qr = QRCode.create(item.qr_code, { errorCorrectionLevel: 'M' });
const size = qr.modules.size;
let d = '';
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
if (qr.modules.get(col, row)) {
d += `M${col},${row}h1v1h-1z`;
}
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}"><path d="${d}" fill="#000"/></svg>`;
// 转 Base64
const buffer = new ArrayBuffer(svg.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < svg.length; i++) {
view[i] = svg.charCodeAt(i);
}
const b64 = Taro.arrayBufferToBase64(buffer);
qrCodeImages.value[item.qr_code] = `data:image/svg+xml;base64,${b64}`;
} catch (err) {
console.error('QR Gen Error', err);
}
}
}
}
onMounted(() => {
if (props.list && props.list.length > 0) {
userList.value = props.list;
generateQrCodes();
}
});
watch(() => props.list, (newVal) => {
if (newVal && newVal.length > 0) {
userList.value = newVal;
generateQrCodes();
}
}, { deep: true });
</script>
<style lang="less">
.qr-code-page {
.qrcode-content {
padding: 32rpx 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #FFF;
border-radius: 16rpx;
box-shadow: 0 0 29rpx 0 rgba(106,106,106,0.27);
.user-info {
color: #A6A6A6;
font-size: 37rpx;
margin-top: 16rpx;
margin-bottom: 16rpx;
}
.user-qrcode {
display: flex;
align-items: center;
.left {
image {
width: 56rpx; height: 56rpx; margin-right: 16rpx;
}
}
.center {
border: 2rpx solid #D1D1D1;
border-radius: 40rpx;
padding: 16rpx;
position: relative;
image {
width: 480rpx; height: 480rpx;
}
}
.right {
image {
width: 56rpx; height: 56rpx;
margin-left: 16rpx;
}
}
}
}
.user-list {
display: flex;
padding: 32rpx;
align-items: center;
flex-wrap: wrap;
.user-item {
position: relative;
padding: 8rpx 16rpx;
border: 2rpx solid #A67939;
margin: 8rpx;
border-radius: 10rpx;
color: #A67939;
&.checked {
color: #FFF;
background-color: #A67939;
}
&.border {
margin-right: 16rpx;
&::after {
position: absolute;
right: -16rpx;
top: calc(50% - 16rpx);
content: '';
height: 32rpx;
border-right: 2rpx solid #A67939;
}
}
}
}
.no-qrcode {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 32rpx;
.no-qrcode-title {
color: #A67939;
font-size: 34rpx;
}
}
}
</style>
<!--
* @Date: 2024-01-16 10:06:47
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-12-26 11:15:45
* @LastEditTime: 2026-01-07 20:54:13
* @FilePath: /xyxBooking-weapp/src/components/qrCode.vue
* @Description: 预约码卡组件
-->
......@@ -168,22 +168,31 @@ const formatGroup = (data) => {
const init = async () => {
if (!props.type) {
const { code, data } = await qrcodeListAPI();
if (code) {
data.forEach(item => {
item.qr_code_url = '/admin?m=srv&a=get_qrcode&key=' + item.qr_code;
item.datetime = formatDatetime({ begin_time: item.begin_time, end_time: item.end_time })
item.sort = 0;
});
// 剔除qr_code为空的二维码
const validData = data.filter(item => item.qr_code !== '');
try {
const { code, data } = await qrcodeListAPI();
if (validData.length > 0) {
userList.value = formatGroup(validData);
refreshBtn();
} else {
userList.value = [];
}
if (code) {
data.forEach(item => {
item.qr_code_url = '/admin?m=srv&a=get_qrcode&key=' + item.qr_code;
item.datetime = formatDatetime({ begin_time: item.begin_time, end_time: item.end_time })
item.sort = 0;
});
// 剔除qr_code为空的二维码
const validData = data.filter(item => item.qr_code !== '');
if (validData.length > 0) {
userList.value = formatGroup(validData);
// 缓存数据供离线模式使用
Taro.setStorageSync('OFFLINE_QR_DATA', userList.value);
refreshBtn();
} else {
userList.value = [];
// 清空缓存
Taro.removeStorageSync('OFFLINE_QR_DATA');
}
}
} catch (err) {
console.error('Fetch QR List Failed:', err);
}
} else {
if (props.payId) {
......@@ -198,7 +207,7 @@ const init = async () => {
// 这里暂且不做处理,如果没有 datetime 就不显示
});
const validData = data.filter(item => item.qr_code !== '');
if (validData.length > 0) {
if (validData.length > 0) {
userList.value = validData;
refreshBtn();
} else {
......@@ -215,7 +224,7 @@ onMounted(() => {
const poll = async () => {
if (userList.value.length && useStatus.value === STATUS_CODE.SUCCESS) {
if (userList.value[select_index.value]) {
if (userList.value[select_index.value]) {
const { code, data } = await qrcodeStatusAPI({ qr_code: userList.value[select_index.value].qr_code });
if (code) {
useStatus.value = data.status;
......
<!--
* @Date: 2024-01-16 10:06:47
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-01-30 17:48:42
* @LastEditTime: 2026-01-07 21:08:16
* @FilePath: /xyxBooking-weapp/src/pages/bookingCode/index.vue
* @Description: 文件描述
-->
......@@ -34,7 +34,7 @@
<script setup>
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import Taro, { useDidShow } from '@tarojs/taro'
import qrCode from '@/components/qrCode';
import { IconFont } from '@nutui/icons-vue-taro'
import icon_3 from '@/assets/images/首页01@2x.png'
......@@ -44,6 +44,17 @@ import { useGo } from '@/hooks/useGo'
const go = useGo();
useDidShow(() => {
Taro.getNetworkType({
success: (res) => {
const isConnected = ['wifi', '4g', '5g', '3g'].includes(res.networkType);
if (!isConnected) {
go('/pages/weakNetwork/index');
}
}
});
})
const toMy = () => { // 跳转到我的
go('/pages/me/index');
}
......
<template>
<view class="offline-booking-code-page">
<view class="header-tip">
<IconFont name="tips" size="15" color="#A67939" />
<text>您当前处于离线模式,仅展示本地缓存的预约码</text>
</view>
<view style="padding: 32rpx;">
<offlineQrCode :list="qrList"></offlineQrCode>
<view class="warning">
<view>
温馨提示
</view>
<view style="margin-top: 16rpx;">一人一码,扫码或识别身份证成功后进入</view>
</view>
</view>
<view class="action-area">
<button class="home-btn" @tap="toHome">返回首页</button>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import Taro from '@tarojs/taro'
import offlineQrCode from '@/components/offlineQrCode';
import { IconFont } from '@nutui/icons-vue-taro'
import { useGo } from '@/hooks/useGo'
const go = useGo();
const qrList = ref([]);
const toHome = () => {
Taro.reLaunch({ url: '/pages/index/index' });
}
// Mock Data as per requirement
const getMockData = () => {
return [
{
name: '测试用户1',
id_number: '110101199003078888',
qr_code: 'OFFLINE_MOCK_QR_001',
datetime: '2026-01-08 08:30-10:30',
sort: 0
},
{
name: '测试用户2',
id_number: '110101199205126666',
qr_code: 'OFFLINE_MOCK_QR_002',
datetime: '2026-01-08 08:30-10:30',
sort: 0
}
];
}
onMounted(() => {
try {
const cachedData = Taro.getStorageSync('OFFLINE_QR_DATA');
if (cachedData && cachedData.length > 0) {
qrList.value = cachedData;
} else {
// Requirement 4: Mock data if no data
console.log('No cached data found, using mock data');
qrList.value = getMockData();
}
} catch (e) {
console.error('Read storage failed', e);
qrList.value = getMockData();
}
});
</script>
<style lang="less">
.offline-booking-code-page {
position: relative;
min-height: 100vh;
background-color: #F6F6F6;
.header-tip {
background-color: #FEF8E8;
color: #A67939;
padding: 20rpx 32rpx;
font-size: 26rpx;
display: flex;
align-items: center;
text {
margin-left: 10rpx;
}
}
.warning {
text-align: center;
color: #A67939;
margin-top: 32rpx;
}
.action-area {
position: fixed;
bottom: 60rpx;
left: 0;
width: 100%;
display: flex;
justify-content: center;
.home-btn {
width: 600rpx;
height: 88rpx;
line-height: 88rpx;
background: #fff;
color: #A67939;
border: 2rpx solid #A67939;
border-radius: 44rpx;
font-size: 32rpx;
}
}
}
</style>
<template>
<view class="weak-network-page">
<view class="content">
<view class="icon-wrapper">
<IconFont name="mask-close" size="120" color="#ccc" />
</view>
<view class="title">网络连接不畅</view>
<view class="desc">当前网络信号较弱,已自动为您切换至离线模式</view>
<view class="offline-entry" @tap="toOfflineCode">
<view class="circle-btn">
<text>预约码</text>
</view>
</view>
<view class="sub-action" @tap="retry">
<text>尝试刷新重试</text>
</view>
</view>
</view>
</template>
<script setup>
import Taro from '@tarojs/taro'
import { IconFont } from '@nutui/icons-vue-taro'
import { useGo } from '@/hooks/useGo'
const go = useGo();
const toOfflineCode = () => {
go('/pages/offlineBookingCode/index');
}
const retry = () => {
// 尝试重新加载当前页或者是返回上一页重试
// 这里简单做成返回首页
Taro.reLaunch({ url: '/pages/index/index' });
}
</script>
<style lang="less">
.weak-network-page {
min-height: 100vh;
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.content {
display: flex;
flex-direction: column;
align-items: center;
margin-top: -100rpx;
.icon-wrapper {
margin-bottom: 40rpx;
}
.title {
font-size: 40rpx;
color: #333;
font-weight: bold;
margin-bottom: 20rpx;
}
.desc {
font-size: 28rpx;
color: #999;
margin-bottom: 80rpx;
text-align: center;
padding: 0 60rpx;
}
.offline-entry {
margin-bottom: 60rpx;
.circle-btn {
width: 240rpx;
height: 240rpx;
border-radius: 50%;
background: linear-gradient(135deg, #A67939 0%, #C69C5C 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 30rpx rgba(166, 121, 57, 0.4);
text {
color: #fff;
font-size: 40rpx;
font-weight: bold;
letter-spacing: 2rpx;
}
&:active {
transform: scale(0.95);
}
}
}
.sub-action {
padding: 20rpx;
text {
color: #A67939;
font-size: 28rpx;
text-decoration: underline;
}
}
}
}
</style>