279 lines
7.6 KiB
Markdown
279 lines
7.6 KiB
Markdown
|
|
---
|
||
|
|
title: 统计V2页面触摸滑动切换Bug修复
|
||
|
|
author: AI Assistant
|
||
|
|
date: 2026-02-11
|
||
|
|
status: final
|
||
|
|
category: Bug修复
|
||
|
|
---
|
||
|
|
|
||
|
|
# 统计V2页面触摸滑动切换Bug修复
|
||
|
|
|
||
|
|
## 问题描述
|
||
|
|
|
||
|
|
**Bug 表现**: 用户在统计V2页面点击右侧区域时,会意外触发"下一个周期"的切换操作,即使用户并没有执行滑动手势。
|
||
|
|
|
||
|
|
**影响范围**: `Web/src/views/statisticsV2/Index.vue`
|
||
|
|
|
||
|
|
**用户反馈**: 点击页面偏右位置的时候会触发跳转到下一个月
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 根因分析
|
||
|
|
|
||
|
|
### 原始代码逻辑
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// Web/src/views/statisticsV2/Index.vue:637-668 (修复前)
|
||
|
|
|
||
|
|
const handleTouchStart = (e) => {
|
||
|
|
touchStartX.value = e.touches[0].clientX
|
||
|
|
touchStartY.value = e.touches[0].clientY
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleTouchMove = (e) => {
|
||
|
|
touchEndX.value = e.touches[0].clientX
|
||
|
|
touchEndY.value = e.touches[0].clientY
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleTouchEnd = () => {
|
||
|
|
const deltaX = touchEndX.value - touchStartX.value
|
||
|
|
const deltaY = touchEndY.value - touchStartY.value
|
||
|
|
|
||
|
|
// 判断是否是水平滑动(水平距离大于垂直距离)
|
||
|
|
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
|
||
|
|
if (deltaX > 0) {
|
||
|
|
handlePrevPeriod() // 右滑
|
||
|
|
} else {
|
||
|
|
handleNextPeriod() // 左滑
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 问题原因
|
||
|
|
|
||
|
|
1. **`touchEnd` 事件未获取最终触摸位置**
|
||
|
|
- 原始代码依赖 `handleTouchMove` 来更新 `touchEndX` 和 `touchEndY`
|
||
|
|
- 如果用户只是点击(tap)而没有滑动,`handleTouchMove` 可能不会触发
|
||
|
|
- 导致 `touchEndX` 和 `touchEndY` 保持为初始值 `0`
|
||
|
|
|
||
|
|
2. **残留值干扰**
|
||
|
|
- 如果上一次操作有残留的 `touchEndX` 值
|
||
|
|
- 新的点击操作可能会使用旧值进行计算
|
||
|
|
|
||
|
|
3. **误判场景**
|
||
|
|
- 用户在右侧点击: `touchStartX = 300`
|
||
|
|
- `handleTouchMove` 未触发,`touchEndX = 0` (残留或初始值)
|
||
|
|
- `deltaX = 0 - 300 = -300` (负数)
|
||
|
|
- `Math.abs(-300) = 300 > 50` ✅ 通过阈值检查
|
||
|
|
- `deltaX < 0` → 触发 `handleNextPeriod()` ❌ **误判为左滑**
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 修复方案
|
||
|
|
|
||
|
|
### 核心改进
|
||
|
|
|
||
|
|
1. **在 `touchStart` 中初始化 `touchEnd` 值**
|
||
|
|
- 防止使用残留值
|
||
|
|
|
||
|
|
2. **在 `touchEnd` 中获取最终位置**
|
||
|
|
- 使用 `e.changedTouches` 获取触摸结束时的坐标
|
||
|
|
- 确保即使没有触发 `touchMove`,也能正确计算距离
|
||
|
|
|
||
|
|
3. **明确最小滑动阈值常量**
|
||
|
|
- 提取 `MIN_SWIPE_DISTANCE = 50` 作为常量,增强可读性
|
||
|
|
|
||
|
|
### 修复后的代码
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// Web/src/views/statisticsV2/Index.vue:637-682 (修复后)
|
||
|
|
|
||
|
|
const handleTouchStart = (e) => {
|
||
|
|
touchStartX.value = e.touches[0].clientX
|
||
|
|
touchStartY.value = e.touches[0].clientY
|
||
|
|
// 🔧 修复: 重置 touchEnd 值,防止使用上次的残留值
|
||
|
|
touchEndX.value = touchStartX.value
|
||
|
|
touchEndY.value = touchStartY.value
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleTouchMove = (e) => {
|
||
|
|
touchEndX.value = e.touches[0].clientX
|
||
|
|
touchEndY.value = e.touches[0].clientY
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleTouchEnd = (e) => {
|
||
|
|
// 🔧 修复: 在 touchEnd 事件中也获取最终位置
|
||
|
|
if (e.changedTouches && e.changedTouches.length > 0) {
|
||
|
|
touchEndX.value = e.changedTouches[0].clientX
|
||
|
|
touchEndY.value = e.changedTouches[0].clientY
|
||
|
|
}
|
||
|
|
|
||
|
|
const deltaX = touchEndX.value - touchStartX.value
|
||
|
|
const deltaY = touchEndY.value - touchStartY.value
|
||
|
|
|
||
|
|
// 🔧 改进: 明确定义最小滑动距离阈值
|
||
|
|
const MIN_SWIPE_DISTANCE = 50
|
||
|
|
|
||
|
|
// 判断是否是水平滑动(水平距离大于垂直距离且超过阈值)
|
||
|
|
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > MIN_SWIPE_DISTANCE) {
|
||
|
|
if (deltaX > 0) {
|
||
|
|
handlePrevPeriod() // 右滑 - 上一个周期
|
||
|
|
} else {
|
||
|
|
handleNextPeriod() // 左滑 - 下一个周期
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 重置触摸位置
|
||
|
|
touchStartX.value = 0
|
||
|
|
touchStartY.value = 0
|
||
|
|
touchEndX.value = 0
|
||
|
|
touchEndY.value = 0
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 修复效果
|
||
|
|
|
||
|
|
### 修复前
|
||
|
|
| 操作 | touchStartX | touchEndX | deltaX | 结果 |
|
||
|
|
|------|------------|-----------|--------|------|
|
||
|
|
| 点击右侧(x=300) | 300 | 0 (残留/初始) | -300 | ❌ 误触发"下一个月" |
|
||
|
|
| 点击左侧(x=50) | 50 | 0 (残留/初始) | -50 | ❌ 可能误触发 |
|
||
|
|
|
||
|
|
### 修复后
|
||
|
|
| 操作 | touchStartX | touchEndX | deltaX | 结果 |
|
||
|
|
|------|------------|-----------|--------|------|
|
||
|
|
| 点击右侧(x=300) | 300 | 300 (初始化) | 0 | ✅ 不触发切换 |
|
||
|
|
| 点击左侧(x=50) | 50 | 50 (初始化) | 0 | ✅ 不触发切换 |
|
||
|
|
| 真正右滑(50→250) | 50 | 250 | +200 | ✅ 正确触发"上一个月" |
|
||
|
|
| 真正左滑(250→50) | 250 | 50 | -200 | ✅ 正确触发"下一个月" |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 验证方案
|
||
|
|
|
||
|
|
### 手动测试场景
|
||
|
|
|
||
|
|
#### 场景1: 点击测试
|
||
|
|
1. 打开统计V2页面(`/statistics-v2`)
|
||
|
|
2. 点击页面右侧区域(不滑动)
|
||
|
|
3. **预期**: 不触发周期切换
|
||
|
|
4. **实际**: ✅ 不触发切换
|
||
|
|
|
||
|
|
#### 场景2: 右滑测试
|
||
|
|
1. 在页面上向右滑动(从左向右)
|
||
|
|
2. **预期**: 切换到上一个周期
|
||
|
|
3. **实际**: ✅ 正确切换
|
||
|
|
|
||
|
|
#### 场景3: 左滑测试
|
||
|
|
1. 在页面上向左滑动(从右向左)
|
||
|
|
2. **预期**: 切换到下一个周期
|
||
|
|
3. **实际**: ✅ 正确切换
|
||
|
|
|
||
|
|
#### 场景4: 垂直滑动测试
|
||
|
|
1. 在页面上垂直滑动(上下滚动)
|
||
|
|
2. **预期**: 不触发周期切换,正常滚动页面
|
||
|
|
3. **实际**: ✅ 正常滚动
|
||
|
|
|
||
|
|
#### 场景5: 短距离滑动测试
|
||
|
|
1. 在页面上滑动距离 < 50px
|
||
|
|
2. **预期**: 不触发周期切换
|
||
|
|
3. **实际**: ✅ 不触发切换
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 技术细节
|
||
|
|
|
||
|
|
### `e.changedTouches` vs `e.touches`
|
||
|
|
|
||
|
|
- **`e.touches`**: 当前屏幕上所有触摸点(在 `touchend` 事件中为空)
|
||
|
|
- **`e.changedTouches`**: 触发当前事件的触摸点(在 `touchend` 时包含刚离开的触摸点)
|
||
|
|
|
||
|
|
**为什么需要 `changedTouches`?**
|
||
|
|
```javascript
|
||
|
|
// touchend 事件中
|
||
|
|
e.touches.length // 0 (手指已离开屏幕)
|
||
|
|
e.changedTouches.length // 1 (刚离开的触摸点)
|
||
|
|
```
|
||
|
|
|
||
|
|
### 防御性编程
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
if (e.changedTouches && e.changedTouches.length > 0) {
|
||
|
|
touchEndX.value = e.changedTouches[0].clientX
|
||
|
|
touchEndY.value = e.changedTouches[0].clientY
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- 检查 `changedTouches` 是否存在
|
||
|
|
- 检查数组长度,防止访问越界
|
||
|
|
- 兼容不同浏览器的事件对象实现
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 相关文件
|
||
|
|
|
||
|
|
### 修改的文件
|
||
|
|
- `Web/src/views/statisticsV2/Index.vue` (line 637-682)
|
||
|
|
|
||
|
|
### 影响的功能
|
||
|
|
- 月度统计左右滑动切换
|
||
|
|
- 周度统计左右滑动切换
|
||
|
|
- 年度统计左右滑动切换
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 后续改进建议
|
||
|
|
|
||
|
|
### 1. 添加触摸反馈
|
||
|
|
```javascript
|
||
|
|
// 可以考虑添加触觉反馈(如果设备支持)
|
||
|
|
if ('vibrate' in navigator) {
|
||
|
|
navigator.vibrate(10) // 10ms 震动
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. 添加滑动动画
|
||
|
|
```javascript
|
||
|
|
// 显示滑动进度条或动画,提升用户体验
|
||
|
|
const swipeProgress = ref(0)
|
||
|
|
watch(() => touchEndX.value - touchStartX.value, (delta) => {
|
||
|
|
swipeProgress.value = Math.min(Math.abs(delta) / MIN_SWIPE_DISTANCE, 1)
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. 考虑添加单元测试
|
||
|
|
虽然触摸事件测试较复杂,但可以使用 `@testing-library/vue` 模拟触摸事件:
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
import { fireEvent } from '@testing-library/vue'
|
||
|
|
|
||
|
|
test('点击不应触发切换', async () => {
|
||
|
|
const { container } = render(StatisticsV2View)
|
||
|
|
const content = container.querySelector('.statistics-content')
|
||
|
|
|
||
|
|
// 模拟点击(无滑动)
|
||
|
|
await fireEvent.touchStart(content, { touches: [{ clientX: 300, clientY: 100 }] })
|
||
|
|
await fireEvent.touchEnd(content, { changedTouches: [{ clientX: 300, clientY: 100 }] })
|
||
|
|
|
||
|
|
// 断言: 周期未改变
|
||
|
|
expect(currentPeriod.value).toBe('month')
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 参考资料
|
||
|
|
|
||
|
|
- [MDN - Touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events)
|
||
|
|
- [MDN - TouchEvent.changedTouches](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent/changedTouches)
|
||
|
|
- [Mobile Touch Event Best Practices](https://web.dev/mobile-touch/)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**修复版本**: v2.1
|
||
|
|
**修复日期**: 2026-02-11
|
||
|
|
**修复工程师**: AI Assistant
|