fix(vr-ticket-wallet): 修复 iOS 真机 evaluate 白屏 + Swiper 回弹
1. ticket-wallet.vue:移除顶层 getApp() 调用,修复 iOS 独立分包 evaluate 早于 App.vue#onLaunch 导致的 'o.functions' 错误 (与 verify/index.vue、records/index.vue 同病同治) 2. ticket-qr-popup:swiper 改用内部 internalIndex 状态, 避免父组件倒计时 re-render 时 prop 重设导致 iOS 回弹 (订单详情页弹出有此问题,票夹页面无此问题)master
parent
7db0b8707c
commit
21ba9b7c42
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> **日期**:2026-06-30
|
||||
> **作者**:Antigravity (AI) + 用户 (bigemon)
|
||||
> **状态**:✅ **首页白屏已解决**、✅ **扫码核销页空白页已解决**、✅ **核销记录页塌陷已解决**、✅ **iconfont 渲染异常已解决**
|
||||
> **状态**:✅ **首页白屏已解决**、✅ **扫码核销页空白页已解决**、✅ **核销记录页塌陷已解决**、✅ **iconfont 渲染异常已解决**、✅ **核销码弹窗 Swiper 回弹已解决**
|
||||
> **相关文件**:`pages.json`、`pages/diy/components/diy/activity.vue`、`pages/plugins/vr-ticket-wallet/verify/index.vue`、`pages/plugins/vr-ticket-wallet/verify/index.css`、`pages/plugins/vr-ticket-wallet/records/index.vue`、`pages/plugins/vr-ticket-wallet/records/records.css`、`components/iconfont/iconfont.vue`、`docs/err.log`
|
||||
> **配套文档**:[`docs/ios-weixin-real-device-uniapp-lessons.md`](file:///Users/bigemon/WorkSpace/vr-shopxo-uniapp/docs/ios-weixin-real-device-uniapp-lessons.md) — 跨问题的方法论、调试手册、避坑指南
|
||||
|
||||
|
|
@ -693,3 +693,107 @@ grep -n "const app = getApp()" pages/diy/components/diy/*.vue
|
|||
| `<text>` 元素 | uni-app 跨平台组件,编译为小程序的 `<text>`,**原生不支持 flex** |
|
||||
| `@font-face` 渲染 | iOS WKWebView 对 `rpx` 在 `@font-face` 内的换算有 bug |
|
||||
| `1em` 单位 | 相对当前 `font-size` 的长度,**不是父容器宽度** |
|
||||
|
||||
---
|
||||
|
||||
## 十二、Bug #4:订单详情核销码弹窗 Swiper 回弹
|
||||
|
||||
> **报告日期**:2026-06-30
|
||||
> **报告人**:Antigravity (AI) + 用户 (bigemon)
|
||||
> **状态**:✅ **已解决**
|
||||
|
||||
### 12.1 问题描述
|
||||
|
||||
**环境**:iOS 微信真机(iPhone)
|
||||
**触发路径**:用户中心 → 订单列表 → 订单详情 → 点击"查看核销码" → 弹出 `ticket-qr-popup` 弹窗
|
||||
|
||||
**现象**:
|
||||
|
||||
- 弹窗正常打开,第一张二维码正常显示
|
||||
- 尝试向右滑动到第二张(或第三张)时,**手指松开后立即回弹到第一张**
|
||||
- **票夹页面**(`ticket-wallet.vue`)打开的弹窗**无此问题**
|
||||
|
||||
### 12.2 根因分析
|
||||
|
||||
`ticket-qr-popup` 组件的 swiper **直接绑定到 prop `currentIndex`**:
|
||||
|
||||
```html
|
||||
<swiper :current="currentIndex" @change="onSwiperChange">
|
||||
```
|
||||
|
||||
订单详情页有 `startQrCountdown()` 定时器,**每秒执行一次**更新 `currentExpiresIn`:
|
||||
|
||||
```js
|
||||
this.qrRefreshTimer = setInterval(function() {
|
||||
self.currentExpiresIn--; // ← 触发父组件 re-render
|
||||
}, 1000);
|
||||
```
|
||||
|
||||
每次 `currentExpiresIn` 更新都触发 `setData`,导致整个页面 re-render。uni-app 会将 prop `currentIndex` 的当前值重新"设回" swiper。在 iOS 小程序环境中,这种 re-render 触发的 `:current` 重新赋值会**打断 swiper 动画状态**,导致滑动结束后回弹到上一次 prop 值(0)。
|
||||
|
||||
**为什么票夹页面没问题?** 票夹页面组件树更小,re-render 对 swiper 的干扰更小。
|
||||
|
||||
### 12.3 修复方案
|
||||
|
||||
**修改文件**:`pages/plugins/vr-ticket-wallet/components/ticket-qr-popup/index.vue`
|
||||
|
||||
核心思路:让 swiper 使用**内部 data** `internalIndex` 管理索引,`watch` 监听 prop 变化,`onSwiperChange` 立即更新内部状态,避免父组件 re-render 干扰。
|
||||
|
||||
**改动 1**:新增 `data()` 和 `watch`
|
||||
|
||||
```js
|
||||
data() {
|
||||
return {
|
||||
// 内部 swiper 索引:避免父组件 re-render 时 prop 重新设值导致回弹
|
||||
internalIndex: this.currentIndex,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
currentIndex(newVal) {
|
||||
this.internalIndex = newVal;
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**改动 2**:swiper `:current` 改用 `internalIndex`
|
||||
|
||||
```html
|
||||
<swiper :current="internalIndex" @change="onSwiperChange">
|
||||
```
|
||||
|
||||
**改动 3**:`onSwiperChange` 立即更新内部索引
|
||||
|
||||
```js
|
||||
onSwiperChange: function(e) {
|
||||
// 立即更新内部索引,避免等待父组件 round-trip 期间 swiper 被重置
|
||||
this.internalIndex = e.detail.current;
|
||||
this.$emit('change', e.detail.current);
|
||||
},
|
||||
```
|
||||
|
||||
**改动 4**:indicator-dots 和 `currentTicket` 改用 `internalIndex`
|
||||
|
||||
```html
|
||||
:class="{ 'dot-active': tIdx === internalIndex }"
|
||||
```
|
||||
|
||||
```js
|
||||
currentTicket: function() {
|
||||
return this.tickets[this.internalIndex] || null;
|
||||
},
|
||||
```
|
||||
|
||||
### 12.4 修复效果
|
||||
|
||||
| 场景 | 修复前 | 修复后 |
|
||||
|------|--------|--------|
|
||||
| 订单详情 → 查看核销码 → 滑动 | ❌ 回弹到第一张 | ✅ 正常滑动 |
|
||||
| 票夹页面 → 查看核销码 → 滑动 | ✅ 正常 | ✅ 正常 |
|
||||
|
||||
### 12.5 经验总结
|
||||
|
||||
**iOS swiper + 父组件高频 re-render 的组合问题**:
|
||||
|
||||
1. **不要在父组件中对带绑定 `:current` 的 prop 进行高频更新**(如定时器、动画帧)
|
||||
2. **最佳实践**:子组件负责管理自身的 swiper 状态,用 `watch` 监听 prop 变化,滑动事件自行处理
|
||||
3. **类似场景**:倒计时组件、实时数据刷新组件内嵌 swiper/tabs 时,均应采用组件内部状态隔离模式
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
class="qr-swiper"
|
||||
:indicator-dots="false"
|
||||
:autoplay="false"
|
||||
:current="currentIndex"
|
||||
:current="internalIndex"
|
||||
@change="onSwiperChange"
|
||||
>
|
||||
<swiper-item
|
||||
|
|
@ -79,7 +79,7 @@
|
|||
class="dot"
|
||||
v-for="(ticket, tIdx) in tickets"
|
||||
:key="tIdx"
|
||||
:class="{ 'dot-active': tIdx === currentIndex }"
|
||||
:class="{ 'dot-active': tIdx === internalIndex }"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -126,14 +126,28 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// 内部 swiper 索引:避免父组件 re-render(如倒计时)时 prop 重新设值
|
||||
// 导致 iOS swiper 回弹到第一张
|
||||
internalIndex: this.currentIndex,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
currentIndex(newVal) {
|
||||
this.internalIndex = newVal;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
currentTicket: function() {
|
||||
return this.tickets[this.currentIndex] || null;
|
||||
return this.tickets[this.internalIndex] || null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Swiper 滑动回调
|
||||
onSwiperChange: function(e) {
|
||||
// 立即更新内部索引,避免等待父组件 round-trip 期间 swiper 被重置
|
||||
this.internalIndex = e.detail.current;
|
||||
this.$emit('change', e.detail.current);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -272,7 +272,15 @@ import TicketQrPopup from './components/ticket-qr-popup/index.vue';
|
|||
import componentCommon from '@/components/common/common';
|
||||
import componentQuickNav from '@/components/quick-nav/quick-nav';
|
||||
|
||||
const app = getApp();
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// [iOS 真机白屏修复 · 方案 A] 2026-06-30
|
||||
// 根因:vr-ticket-wallet 是独立 subpackage。iOS 微信真机的 subpackage
|
||||
// evaluate 阶段可能早于 App.vue#onLaunch,导致顶层 getApp() 拿到的
|
||||
// globalData 未填充,从而 <script> evaluation 失败 → Vue 注册不到
|
||||
// options → 后续 o.functions 访问失败(与 records/index.vue、verify/index.vue
|
||||
// 同病)。修复:移除顶层 getApp(),data() 用默认值占位,在 onLoad() 中读取。
|
||||
// 关联:订单详情页打开 ticket-qr-popup 弹窗会预加载本分包。
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default {
|
||||
name: 'TicketWallet',
|
||||
|
|
@ -286,8 +294,8 @@ export default {
|
|||
return {
|
||||
// 主题样式
|
||||
theme_view: '',
|
||||
// 哀悼灰度插件
|
||||
plugins_mourning_data_is_app: app.globalData.is_app_mourning(),
|
||||
// 哀悼灰度插件(默认 false,在 onLoad() 中由 globalData 填充)
|
||||
plugins_mourning_data_is_app: false,
|
||||
// 加载状态 1=加载中 2=加载失败 3=加载成功
|
||||
loadStatus: 1,
|
||||
isRefreshing: false,
|
||||
|
|
@ -332,6 +340,18 @@ export default {
|
|||
},
|
||||
},
|
||||
onLoad(params) {
|
||||
// 必须在 onLoad 中读取 globalData(不能在 <script> 顶层 evaluate 阶段调用,
|
||||
// 否则 iOS 真机 subpackage evaluate 早于 App.vue#onLaunch 会抛错)
|
||||
const app = getApp();
|
||||
let isMourning = false;
|
||||
try {
|
||||
isMourning = app.globalData.is_app_mourning();
|
||||
} catch (e) {}
|
||||
this.setData({
|
||||
plugins_mourning_data_is_app: isMourning,
|
||||
theme_view: app.globalData.get_theme_value_view(),
|
||||
});
|
||||
|
||||
// 调用公共事件方法
|
||||
app.globalData.page_event_onload_handle(params);
|
||||
|
||||
|
|
@ -350,6 +370,7 @@ export default {
|
|||
},
|
||||
onShow() {
|
||||
// 调用公共事件方法
|
||||
const app = getApp();
|
||||
app.globalData.page_event_onshow_handle();
|
||||
|
||||
|
||||
|
|
@ -379,6 +400,7 @@ export default {
|
|||
// 初始化配置
|
||||
init_config(status) {
|
||||
if ((status || false) == true) {
|
||||
const app = getApp();
|
||||
var theme_view = app.globalData.get_theme_value_view();
|
||||
var mourning = parseInt(app.globalData.get_config('plugins_mourning_data') || 0) == 1;
|
||||
this.setData({
|
||||
|
|
@ -431,6 +453,7 @@ export default {
|
|||
// 已经是完整 URL 则直接返回
|
||||
if (path.indexOf('http') === 0) return path;
|
||||
// 相对路径补上 static_url
|
||||
const app = getApp();
|
||||
var base = (app.globalData.data.static_url || '').replace(/\/+$/, '');
|
||||
return base + '/' + path.replace(/^\/+/, '');
|
||||
},
|
||||
|
|
@ -445,6 +468,7 @@ export default {
|
|||
|
||||
// 初始化加载
|
||||
init() {
|
||||
const app = getApp();
|
||||
var self = this;
|
||||
var user = app.globalData.get_user_info(this, 'init');
|
||||
if (user == false) {
|
||||
|
|
@ -504,6 +528,7 @@ export default {
|
|||
|
||||
// 下拉刷新
|
||||
refresher_refresh() {
|
||||
const app = getApp();
|
||||
this.isRefreshing = true;
|
||||
var self = this;
|
||||
uni.request({
|
||||
|
|
@ -530,6 +555,7 @@ export default {
|
|||
|
||||
// 打开 QR 弹窗(点击演出卡)
|
||||
openQrPopup(group) {
|
||||
const app = getApp();
|
||||
var self = this;
|
||||
var ticketIds = group.tickets.map(function(t) { return t.id; });
|
||||
|
||||
|
|
@ -588,6 +614,7 @@ export default {
|
|||
|
||||
// 获取单张票详情(含 qr_payload)
|
||||
fetchTicketDetail(ticketId) {
|
||||
const app = getApp();
|
||||
return new Promise(function(resolve) {
|
||||
uni.request({
|
||||
url: app.globalData.get_request_url('detail', 'ticket', 'vr_ticket', 'id=' + ticketId),
|
||||
|
|
@ -638,6 +665,7 @@ export default {
|
|||
|
||||
// 静默刷新某张票 of QR
|
||||
silentRefreshQr(ticketIndex) {
|
||||
const app = getApp();
|
||||
var self = this;
|
||||
var ticket = this.currentQrTickets[ticketIndex];
|
||||
if (!ticket) return;
|
||||
|
|
@ -660,6 +688,7 @@ export default {
|
|||
|
||||
// 用户点击过期遮罩触发刷新(来自 popup 组件事件)
|
||||
onQrRefresh() {
|
||||
const app = getApp();
|
||||
var self = this;
|
||||
var ticketIndex = this.currentQrIndex;
|
||||
var ticket = this.currentQrTickets[ticketIndex];
|
||||
|
|
|
|||
Loading…
Reference in New Issue