Implemented multiple layers of protection to prevent iOS Safari from zooming when users quickly tap the score adjustment buttons:
1. Enhanced touch event handling in modify-score.vue:
- Changed from touchend to touchstart for immediate response
- Added .stop.prevent modifiers to all touch events (touchstart, touchmove, touchend, touchcancel)
- Added noop() handlers to absorb unwanted events
- Replaced time-based debouncing with isProcessing flag using requestAnimationFrame
- Ensured all child elements have pointer-events: none
2. Comprehensive index.html protection:
- Added iOS-specific meta tags (apple-mobile-web-app-capable, format-detection)
- Enhanced CSS with touch-action: pan-y for scrolling while preventing zoom
- Implemented 7-layer JavaScript protection:
* Layer 1: Intercept rapid touchstart events with counter
* Layer 2: Block touchend events within 300ms
* Layer 3: Completely disable dblclick events
* Layer 4: Prevent gesture events (gesturestart/change/end)
* Layer 5: Use Pointer Events API for additional blocking
* Layer 6: Filter rapid click events
* Layer 7: Add capture-phase listeners to buttons
- All event listeners use { passive: false, capture: true } for maximum control
This multi-layered approach addresses the root cause: iOS Safari triggers zoom at the browser level before JavaScript can normally intercept it. By using capture phase and preventing events at multiple stages, we ensure the zoom behavior is blocked.
Generated with Claude Code
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
676 lines
14 KiB
Vue
676 lines
14 KiB
Vue
<template>
|
||
<view class="container">
|
||
<!-- 自定义导航栏 -->
|
||
<view class="nav-bar">
|
||
<view class="nav-left" @click="goBack">
|
||
<text class="back-icon">‹</text>
|
||
</view>
|
||
<view class="nav-title">修改评分</view>
|
||
<view class="nav-right">
|
||
<view class="icon-menu">···</view>
|
||
<view class="icon-close">⊗</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 选手信息 -->
|
||
<view class="player-info-section">
|
||
<view class="player-header">
|
||
<view class="player-name">{{ athleteInfo.name }}</view>
|
||
<view class="total-score-label">
|
||
<text class="label-text">总分:</text>
|
||
<text class="score-value">{{ athleteInfo.totalScore }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="player-details">
|
||
<view class="detail-item">身份证:{{ athleteInfo.idCard }}</view>
|
||
<view class="detail-item">队伍:{{ athleteInfo.team }}</view>
|
||
<view class="detail-item">编号:{{ athleteInfo.number }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 评委评分统计 -->
|
||
<view class="judges-section">
|
||
<view class="section-title">共有{{ judgeScores.length }}位评委完成评分</view>
|
||
<view class="judges-scores">
|
||
<view
|
||
class="judge-score-item"
|
||
v-for="judge in judgeScores"
|
||
:key="judge.judgeId"
|
||
>
|
||
<text class="judge-name">{{ judge.judgeName }}:</text>
|
||
<text class="judge-score">{{ judge.score }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 修改总分区域 -->
|
||
<view class="modify-section">
|
||
<view class="modify-header">
|
||
<text class="modify-label">修改总分(+-0.005分)</text>
|
||
</view>
|
||
|
||
<view class="score-control">
|
||
<view
|
||
class="control-btn decrease"
|
||
@touchstart.stop.prevent="handleDecrease"
|
||
@touchmove.stop.prevent="noop"
|
||
@touchend.stop.prevent="noop"
|
||
@touchcancel.stop.prevent="noop"
|
||
@click.stop.prevent="noop"
|
||
>
|
||
<text class="btn-symbol">-</text>
|
||
<text class="btn-value">-0.001</text>
|
||
</view>
|
||
|
||
<view class="score-display">
|
||
<text class="current-score">{{ currentScore.toFixed(3) }}</text>
|
||
<text class="no-modify-text">可不改</text>
|
||
</view>
|
||
|
||
<view
|
||
class="control-btn increase"
|
||
@touchstart.stop.prevent="handleIncrease"
|
||
@touchmove.stop.prevent="noop"
|
||
@touchend.stop.prevent="noop"
|
||
@touchcancel.stop.prevent="noop"
|
||
@click.stop.prevent="noop"
|
||
>
|
||
<text class="btn-symbol">+</text>
|
||
<text class="btn-value">+0.001</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- <view class="modify-tip">
|
||
裁判长修改:保留3位小数点,超过上限或下限时,按钮置灰
|
||
</view> -->
|
||
</view>
|
||
|
||
<!-- 备注 -->
|
||
<view class="note-section">
|
||
<view class="note-label">
|
||
<text>备注:</text>
|
||
</view>
|
||
<view class="note-input-wrapper">
|
||
<textarea
|
||
class="note-input"
|
||
placeholder="请输入修改备注"
|
||
v-model="note"
|
||
maxlength="200"
|
||
/>
|
||
<text class="optional-text">可不填</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 修改按钮 -->
|
||
<button class="modify-btn" @click="handleModify">修改</button>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import dataAdapter from '@/utils/dataAdapter.js'
|
||
import config from '@/config/env.config.js'
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
athleteInfo: {
|
||
athleteId: '',
|
||
name: '',
|
||
idCard: '',
|
||
team: '',
|
||
number: '',
|
||
totalScore: 0
|
||
},
|
||
judgeScores: [],
|
||
modification: null,
|
||
modifierId: '',
|
||
currentScore: 8.000,
|
||
originalScore: 8.000,
|
||
note: '',
|
||
minScore: 5.0,
|
||
maxScore: 10.0,
|
||
isProcessing: false
|
||
}
|
||
},
|
||
|
||
async onLoad() {
|
||
// 获取全局数据
|
||
const app = getApp()
|
||
const globalData = app.globalData || {}
|
||
|
||
// 获取当前选手信息(从 score-list-multi 页面传递)
|
||
const currentAthlete = globalData.currentAthlete || {}
|
||
|
||
// 获取裁判长ID
|
||
this.modifierId = globalData.judgeId
|
||
|
||
// 调试信息
|
||
if (config.debug) {
|
||
console.log('修改评分页加载:', {
|
||
athleteId: currentAthlete.athleteId,
|
||
modifierId: this.modifierId
|
||
})
|
||
}
|
||
|
||
// 加载选手评分详情
|
||
if (currentAthlete.athleteId) {
|
||
await this.loadScoreDetail(currentAthlete.athleteId)
|
||
}
|
||
},
|
||
|
||
methods: {
|
||
async loadScoreDetail(athleteId) {
|
||
try {
|
||
uni.showLoading({
|
||
title: '加载中...',
|
||
mask: true
|
||
})
|
||
|
||
// 🔥 关键改动:使用 dataAdapter 获取评分详情
|
||
// Mock模式:调用 mock/score.js 的 getScoreDetail 函数
|
||
// API模式:调用 api/score.js 的 getScoreDetail 函数(GET /api/mini/score/detail/{athleteId})
|
||
const response = await dataAdapter.getData('getScoreDetail', {
|
||
athleteId: athleteId
|
||
})
|
||
|
||
uni.hideLoading()
|
||
|
||
// 保存选手信息和评分详情
|
||
this.athleteInfo = response.data.athleteInfo || {}
|
||
this.judgeScores = response.data.judgeScores || []
|
||
this.modification = response.data.modification || null
|
||
|
||
// 设置初始分数
|
||
this.originalScore = this.athleteInfo.totalScore || 8.000
|
||
this.currentScore = this.originalScore
|
||
|
||
// 如果之前已修改过,加载修改后的分数
|
||
if (this.modification && this.modification.modifiedScore) {
|
||
this.currentScore = this.modification.modifiedScore
|
||
}
|
||
|
||
// 调试信息
|
||
if (config.debug) {
|
||
console.log('评分详情加载成功:', {
|
||
athlete: this.athleteInfo,
|
||
judges: this.judgeScores.length,
|
||
originalScore: this.originalScore,
|
||
currentScore: this.currentScore,
|
||
modification: this.modification
|
||
})
|
||
}
|
||
|
||
} catch (error) {
|
||
uni.hideLoading()
|
||
console.error('加载评分详情失败:', error)
|
||
uni.showToast({
|
||
title: error.message || '加载失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
},
|
||
|
||
goBack() {
|
||
uni.navigateBack()
|
||
},
|
||
|
||
// 空操作函数,用于阻止事件
|
||
noop() {
|
||
// 什么都不做
|
||
},
|
||
|
||
handleDecrease(e) {
|
||
// 防止重复处理
|
||
if (this.isProcessing) {
|
||
return
|
||
}
|
||
|
||
this.isProcessing = true
|
||
|
||
// 执行减分逻辑
|
||
this.decreaseScore()
|
||
|
||
// 使用 requestAnimationFrame 确保在下一帧重置状态
|
||
requestAnimationFrame(() => {
|
||
this.isProcessing = false
|
||
})
|
||
},
|
||
|
||
handleIncrease(e) {
|
||
// 防止重复处理
|
||
if (this.isProcessing) {
|
||
return
|
||
}
|
||
|
||
this.isProcessing = true
|
||
|
||
// 执行加分逻辑
|
||
this.increaseScore()
|
||
|
||
// 使用 requestAnimationFrame 确保在下一帧重置状态
|
||
requestAnimationFrame(() => {
|
||
this.isProcessing = false
|
||
})
|
||
},
|
||
|
||
decreaseScore() {
|
||
if (this.currentScore > this.minScore) {
|
||
this.currentScore = parseFloat((this.currentScore - 0.001).toFixed(3))
|
||
}
|
||
},
|
||
|
||
increaseScore() {
|
||
if (this.currentScore < this.maxScore) {
|
||
this.currentScore = parseFloat((this.currentScore + 0.001).toFixed(3))
|
||
}
|
||
},
|
||
|
||
async handleModify() {
|
||
// 验证评分范围
|
||
if (this.currentScore < this.minScore || this.currentScore > this.maxScore) {
|
||
uni.showToast({
|
||
title: `评分必须在${this.minScore}-${this.maxScore}分之间`,
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 检查是否有修改
|
||
if (this.currentScore === this.originalScore && !this.note) {
|
||
uni.showToast({
|
||
title: '请修改分数或填写备注',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
uni.showLoading({
|
||
title: '提交中...',
|
||
mask: true
|
||
})
|
||
|
||
// 🔥 关键改动:使用 dataAdapter 修改评分
|
||
// Mock模式:调用 mock/score.js 的 modifyScore 函数
|
||
// API模式:调用 api/score.js 的 modifyScore 函数(PUT /api/mini/score/modify)
|
||
const response = await dataAdapter.getData('modifyScore', {
|
||
athleteId: this.athleteInfo.athleteId,
|
||
modifierId: this.modifierId,
|
||
modifiedScore: this.currentScore,
|
||
note: this.note
|
||
})
|
||
|
||
uni.hideLoading()
|
||
|
||
// 调试信息
|
||
if (config.debug) {
|
||
console.log('修改评分成功:', {
|
||
athleteId: this.athleteInfo.athleteId,
|
||
originalScore: this.originalScore,
|
||
modifiedScore: this.currentScore,
|
||
note: this.note,
|
||
response: response
|
||
})
|
||
}
|
||
|
||
// 显示成功提示
|
||
uni.showToast({
|
||
title: '修改成功',
|
||
icon: 'success',
|
||
duration: 1500
|
||
})
|
||
|
||
// 返回上一页
|
||
setTimeout(() => {
|
||
uni.navigateBack()
|
||
}, 1500)
|
||
|
||
} catch (error) {
|
||
uni.hideLoading()
|
||
console.error('修改评分失败:', error)
|
||
|
||
uni.showToast({
|
||
title: error.message || '修改失败,请重试',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.container {
|
||
min-height: 100vh;
|
||
background-color: #F5F5F5;
|
||
padding-bottom: 40rpx;
|
||
}
|
||
|
||
/* 导航栏 */
|
||
.nav-bar {
|
||
height: 90rpx;
|
||
background: linear-gradient(135deg, #1B7C5E 0%, #2A9D7E 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
padding: 0 30rpx;
|
||
}
|
||
|
||
.nav-left {
|
||
position: absolute;
|
||
left: 30rpx;
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.back-icon {
|
||
font-size: 60rpx;
|
||
color: #FFFFFF;
|
||
font-weight: 300;
|
||
line-height: 1;
|
||
}
|
||
|
||
.nav-title {
|
||
font-size: 36rpx;
|
||
font-weight: 600;
|
||
color: #FFFFFF;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
|
||
.nav-right {
|
||
position: absolute;
|
||
right: 30rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 30rpx;
|
||
}
|
||
|
||
.icon-menu,
|
||
.icon-close {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
background-color: rgba(255, 255, 255, 0.25);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 32rpx;
|
||
color: #FFFFFF;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* 选手信息 */
|
||
.player-info-section {
|
||
margin: 30rpx;
|
||
background-color: #FFFFFF;
|
||
border-radius: 16rpx;
|
||
padding: 30rpx;
|
||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.player-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.player-name {
|
||
font-size: 34rpx;
|
||
font-weight: 600;
|
||
color: #333333;
|
||
}
|
||
|
||
.total-score-label {
|
||
display: flex;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.label-text {
|
||
font-size: 26rpx;
|
||
color: #666666;
|
||
}
|
||
|
||
.score-value {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #333333;
|
||
margin-left: 8rpx;
|
||
}
|
||
|
||
.player-details {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.detail-item {
|
||
font-size: 26rpx;
|
||
color: #666666;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* 评委评分统计 */
|
||
.judges-section {
|
||
margin: 30rpx;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 30rpx;
|
||
font-weight: 600;
|
||
color: #333333;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.judges-scores {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.judge-score-item {
|
||
font-size: 26rpx;
|
||
color: #333333;
|
||
}
|
||
|
||
.judge-name {
|
||
color: #666666;
|
||
}
|
||
|
||
.judge-score {
|
||
color: #333333;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 修改总分区域 */
|
||
.modify-section {
|
||
margin: 30rpx;
|
||
background-color: #FFFFFF;
|
||
border-radius: 16rpx;
|
||
padding: 40rpx 30rpx;
|
||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.modify-header {
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.modify-label {
|
||
font-size: 28rpx;
|
||
color: #666666;
|
||
}
|
||
|
||
.score-control {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.control-btn {
|
||
/* 关键:完全禁用所有触摸行为 */
|
||
touch-action: none !important;
|
||
-webkit-tap-highlight-color: transparent !important;
|
||
-webkit-touch-callout: none !important;
|
||
-webkit-user-select: none !important;
|
||
-moz-user-select: none !important;
|
||
-ms-user-select: none !important;
|
||
user-select: none !important;
|
||
/* 防止长按菜单 */
|
||
-webkit-touch-callout: none !important;
|
||
/* 防止文本选择 */
|
||
pointer-events: auto !important;
|
||
|
||
width: 140rpx;
|
||
height: 140rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: #F5F5F5;
|
||
border-radius: 12rpx;
|
||
cursor: pointer;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.control-btn::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: transparent;
|
||
z-index: 1;
|
||
}
|
||
|
||
.control-btn.decrease {
|
||
background-color: #FFE5E5;
|
||
}
|
||
|
||
.control-btn.increase {
|
||
background-color: #E5F5F0;
|
||
}
|
||
|
||
.btn-symbol {
|
||
font-size: 48rpx;
|
||
font-weight: 300;
|
||
pointer-events: none !important;
|
||
user-select: none !important;
|
||
-webkit-user-select: none !important;
|
||
}
|
||
|
||
.control-btn.decrease .btn-symbol {
|
||
color: #FF4D6A;
|
||
}
|
||
|
||
.control-btn.increase .btn-symbol {
|
||
color: #1B7C5E;
|
||
}
|
||
|
||
.btn-value {
|
||
font-size: 24rpx;
|
||
margin-top: 8rpx;
|
||
pointer-events: none !important;
|
||
user-select: none !important;
|
||
-webkit-user-select: none !important;
|
||
}
|
||
|
||
.control-btn.decrease .btn-value {
|
||
color: #FF4D6A;
|
||
}
|
||
|
||
.control-btn.increase .btn-value {
|
||
color: #1B7C5E;
|
||
}
|
||
|
||
.score-display {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.current-score {
|
||
font-size: 60rpx;
|
||
font-weight: 600;
|
||
color: #1B7C5E;
|
||
}
|
||
|
||
.no-modify-text {
|
||
font-size: 24rpx;
|
||
color: #FF4D6A;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.modify-tip {
|
||
font-size: 24rpx;
|
||
color: #FF4D6A;
|
||
line-height: 1.6;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 备注 */
|
||
.note-section {
|
||
margin: 30rpx;
|
||
}
|
||
|
||
.note-label {
|
||
font-size: 28rpx;
|
||
color: #333333;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.note-input-wrapper {
|
||
background-color: #FFFFFF;
|
||
border-radius: 16rpx;
|
||
padding: 30rpx;
|
||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
||
position: relative;
|
||
}
|
||
|
||
.note-input {
|
||
width: 100%;
|
||
min-height: 120rpx;
|
||
font-size: 28rpx;
|
||
color: #333333;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.note-input::placeholder {
|
||
color: #CCCCCC;
|
||
}
|
||
|
||
.optional-text {
|
||
position: absolute;
|
||
right: 30rpx;
|
||
bottom: 30rpx;
|
||
font-size: 24rpx;
|
||
color: #FF4D6A;
|
||
}
|
||
|
||
/* 修改按钮 */
|
||
.modify-btn {
|
||
margin: 30rpx;
|
||
height: 90rpx;
|
||
background: linear-gradient(135deg, #1B7C5E 0%, #2A9D7E 100%);
|
||
border-radius: 16rpx;
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #FFFFFF;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 8rpx 20rpx rgba(27, 124, 94, 0.3);
|
||
}
|
||
|
||
.modify-btn:active {
|
||
opacity: 0.9;
|
||
}
|
||
</style>
|