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
Council 2026-06-30 18:37:10 +08:00
parent 7db0b8707c
commit 21ba9b7c42
3 changed files with 154 additions and 7 deletions

View File

@ -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 时,均应采用组件内部状态隔离模式

View File

@ -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);
},

View File

@ -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 subpackageiOS subpackage
// evaluate App.vue#onLaunch getApp()
// globalData <script> evaluation Vue
// options o.functions 访 records/index.vueverify/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];