fix: 触控组件审查修复(道具图标逐项渲染/连线配对去重/拖拽排序坐标判定/命中区取真实 rect/done 守卫)

This commit is contained in:
2026-08-13 14:59:52 +08:00
parent 5d309d45b7
commit 5c4335f5e5
5 changed files with 81 additions and 80 deletions
@@ -1,6 +1,6 @@
<template>
<view class="drag-place">
<view class="target" :style="{ background: over ? '#ffe0c2' : '#f8f2e8' }" :class="{ win: winDrop }">
<view class="target" :class="{ win: winDrop }" :style="{ background: over ? '#ffe0c2' : '#f8f2e8' }">
<text class="target-text">{{ over ? '放这里!' : '把道具拖到这里' }}</text>
</view>
<view class="items">
@@ -27,7 +27,7 @@ import { playSound } from '../../utils/sound.js'
export default {
name: 'DragPlace',
props: { config: { type: String, default: '' } },
data() { return { dragIdx: -1, over: false, retries: 0, done: false, winDrop: false } },
data() { return { dragIdx: -1, over: false, retries: 0, done: false, winDrop: false, target: null } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'drag_place', items: [], answer: 0 } }
@@ -36,22 +36,20 @@ export default {
},
methods: {
propVisual,
targetRect() {
// 目标框位置(H5:用元素坐标近似,touch 事件 y < 200rpx 视为命中)
const sys = uni.getSystemInfoSync()
return sys.windowHeight * 0.28
},
onStart(e, i) {
if (this.done) return
this.dragIdx = i
// 拿起时量一次目标框实际位置;touchmove 里纯坐标比较,避免逐帧调系统 API
uni.createSelectorQuery().in(this).select('.target').boundingClientRect(rect => {
this.target = rect || null
}).exec()
},
onMove(e) {
if (this.dragIdx === -1) return
const t = e.touches && e.touches[0]
if (!t) return
// 手指 y 在屏幕上方 28% 内视为拖到目标框
const sys = uni.getSystemInfoSync()
this.over = t.clientY < sys.windowHeight * 0.28
const r = this.target
if (!t || !r) return
this.over = t.clientX >= r.left && t.clientX <= r.right && t.clientY >= r.top && t.clientY <= r.bottom
},
onEnd(it, i) {
if (this.done || this.dragIdx === -1) return
@@ -77,20 +75,14 @@ export default {
</script>
<style scoped>
.target { height: 220rpx; border-radius: 28rpx; border: 4rpx dashed #d5c4ae; display: flex; align-items: center; justify-content: center; margin-bottom: 48rpx; }
.target { height: 220rpx; border-radius: 28rpx; border: 4rpx dashed #d5c4ae; display: flex; align-items: center; justify-content: center; margin-bottom: 48rpx; transition: transform .12s ease, box-shadow .12s ease; }
.target.win { transform: scale(1.05); border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.target-text { color: #a08c74; font-size: 30rpx; }
.items { display: flex; gap: 24rpx; justify-content: center; }
.item { display: flex; flex-direction: column; align-items: center; background: #fff; border-radius: 20rpx; padding: 20rpx 24rpx; }
.item { display: flex; flex-direction: column; align-items: center; background: #fff; border-radius: 20rpx; padding: 20rpx 24rpx; transition: transform .12s ease; }
.item:active { transform: scale(.92); }
.item.dragging { opacity: .5; transform: scale(1.05); }
.item-icon { font-size: 52rpx; margin-bottom: 8rpx; }
.item-name { font-size: 26rpx; color: #5b4636; }
.item, .target { transition: transform .12s ease, box-shadow .12s ease; }
.item:active { transform: scale(.92); }
.target.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.item.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.05); } }
</style>
+10 -12
View File
@@ -24,7 +24,7 @@ import { playSound } from '../../utils/sound.js'
export default {
name: 'FindSpot',
props: { config: { type: String, default: '' } },
data() { return { retries: 0, found: false, foundIdx: -1 } },
data() { return { retries: 0, found: false, done: false, foundIdx: -1 } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'find_spot', items: [], answer: 0 } }
@@ -43,16 +43,20 @@ export default {
return spots[i % spots.length]
},
tap(it, i) {
if (this.found) return
if (this.found || this.done) return
if (it.id === this.cfg.answer) {
this.found = true
this.done = true
this.foundIdx = i
playSound('good')
this.$emit('done', { success: true })
} else {
this.retries++
playSound('bad')
if (this.retries >= 2) this.$emit('done', { success: false })
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
@@ -62,16 +66,10 @@ export default {
<style scoped>
.scene { position: relative; height: 420rpx; border-radius: 28rpx; margin-bottom: 24rpx; }
.scene-emoji { position: absolute; right: 20rpx; top: 10rpx; font-size: 120rpx; opacity: .35; }
.spot { position: absolute; width: 100rpx; height: 100rpx; border-radius: 24rpx; background: #fff; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.18); display: flex; align-items: center; justify-content: center; }
.spot { position: absolute; width: 100rpx; height: 100rpx; border-radius: 24rpx; background: #fff; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.18); display: flex; align-items: center; justify-content: center; transition: transform .12s ease; }
.spot:active { transform: scale(.92); }
.spot.win { transform: scale(1.08); border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.spot-icon { font-size: 52rpx; }
.hint { text-align: center; color: #a08c74; }
.spot { transition: transform .12s ease, box-shadow .12s ease; }
.spot:active { transform: scale(.92); }
.spot.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.spot.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
</style>
@@ -59,8 +59,9 @@ export default {
propVisual,
personVisual,
lineY(i) {
// 每条线固定插在人物与道具列之间,按匹配顺序错开
return 60 + i * 110 + '%'
// 在 12%-88% 区间按索引线性插值,避免固定步长在 i≥1 时越出视口
const n = Math.max(this.persons.length - 1, 1)
return 12 + (i / n) * 76 + '%'
},
tapPerson(p) {
if (this.done) return
@@ -76,15 +77,21 @@ export default {
if (!this.selPerson || !this.selItem) return
const pair = [this.selPerson, this.selItem]
const ans = this.cfg.pairs || []
// 已配对的组合重复点选不重复计数(否则同对可反复刷到 done)
if (this.matches.some(([p, i]) => p === pair[0] && i === pair[1])) {
this.selPerson = 0
this.selItem = 0
return
}
const correct = ans.some(([p, i]) => p === pair[0] && i === pair[1])
if (correct) {
this.matches.push(pair)
this.winPair = true
this.selPerson = 0
this.selItem = 0
playSound('good')
if (this.matches.length === this.persons.length && this.matches.length === ans.length) {
if (this.matches.length === ans.length) {
this.done = true
this.winPair = true
playSound('good')
this.$emit('done', { success: true })
}
} else {
@@ -107,18 +114,12 @@ export default {
.board { display: flex; justify-content: space-between; align-items: stretch; }
.col { display: flex; flex-direction: column; gap: 24rpx; width: 36%; }
.lines { width: 24%; position: relative; min-height: 400rpx; }
.node { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 16rpx 20rpx; }
.node { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 16rpx 20rpx; transition: transform .12s ease; }
.node:active { transform: scale(.95); }
.node.selected { border: 4rpx solid #ff9a3d; }
.node.win { border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.node-avatar { width: 64rpx; height: 64rpx; border-radius: 50%; color: #fff; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; }
.node-icon { font-size: 44rpx; margin-right: 16rpx; }
.node-name { font-size: 28rpx; color: #5b4636; }
.node { transition: transform .12s ease, box-shadow .12s ease; }
.node:active { transform: scale(.92); }
.node.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.node.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
@keyframes win-pulse { 0% { transform: scale(.95); } 50% { transform: scale(1.08); } 100% { transform: scale(1); } }
</style>
+14 -13
View File
@@ -9,7 +9,7 @@
:class="{ picked: picked === i, win: winIdx === i, wrong: wrongIdx === i }"
@click="pick(it, i)"
>
<view class="item-icon" :style="{ background: visual.color }">{{ visual.emoji }}</view>
<view class="item-icon" :style="{ background: '#fff0e5' }">{{ propVisual(it.name).emoji }}</view>
<view class="item-name">{{ it.name }}</view>
</view>
</view>
@@ -25,21 +25,23 @@ export default {
name: 'PropPick',
props: { config: { type: String, default: '' } },
data() {
return { picked: -1, wrongIdx: -1, winIdx: -1, retries: 0 }
return { picked: -1, wrongIdx: -1, winIdx: -1, retries: 0, done: false }
},
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'prop_pick', items: [], answer: 0 } }
},
items() { return this.cfg.items || [] },
visual() { return propVisual(this.items[this.picked] ? this.items[this.picked].name : '') }
items() { return this.cfg.items || [] }
},
methods: {
propVisual,
pick(it, i) {
if (this.done) return
if (it.id === this.cfg.answer) {
this.picked = i
this.winIdx = i
this.wrongIdx = -1
this.done = true
playSound('good')
this.$emit('done', { success: true })
} else {
@@ -47,7 +49,10 @@ export default {
this.wrongIdx = i
this.picked = -1
playSound('bad')
if (this.retries >= 2) this.$emit('done', { success: false })
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
@@ -56,19 +61,15 @@ export default {
<style scoped>
.items { display: flex; gap: 24rpx; flex-wrap: wrap; justify-content: center; }
.item { display: flex; flex-direction: column; align-items: center; background: #fff; border-radius: 20rpx; padding: 24rpx 28rpx; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.08); }
.item { display: flex; flex-direction: column; align-items: center; background: #fff; border-radius: 20rpx; padding: 24rpx 28rpx; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.08); transition: transform .12s ease, box-shadow .12s ease; }
.item:active { transform: scale(.92); }
.item.picked { border: 4rpx solid #ff9a3d; }
.item.win { transform: scale(1.08); border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.item.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
.item-icon { width: 88rpx; height: 88rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; font-size: 48rpx; margin-bottom: 12rpx; }
.item-name { font-size: 28rpx; color: #5b4636; font-weight: 600; }
.hint { text-align: center; color: #a08c74; margin-bottom: 28rpx; }
.wrong-tip { text-align: center; color: #ff6b6b; margin-top: 24rpx; }
.item { transition: transform .12s ease, box-shadow .12s ease; }
.item:active { transform: scale(.92); }
.item.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.item.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
</style>
+27 -18
View File
@@ -1,13 +1,13 @@
<template>
<view class="step-sort">
<!-- touchmove 挂容器touch 事件的 target 恒为 touchstart 元素须按手指坐标判定目标槽位 -->
<view class="step-sort" @touchmove.prevent="onMove($event)">
<view class="hint">按住道具拖一拖按顺序排好</view>
<view
v-for="(it, i) in items"
:key="it.id"
class="slot"
:class="{ over: overIdx === i, dragging: dragIdx === i, win: allWin, wrong: wrong }"
:class="{ over: overIdx === i, dragging: dragIdx === i, win: allWin }"
@touchstart="onStart($event, i)"
@touchmove.prevent="onMove($event, i)"
@touchend="onEnd"
>
<view class="order-no">{{ i + 1 }}</view>
@@ -25,7 +25,7 @@ import { playSound } from '../../utils/sound.js'
export default {
name: 'StepSort',
props: { config: { type: String, default: '' } },
data() { return { order: [], dragIdx: -1, overIdx: -1, retries: 0, wrong: false, done: false, allWin: false } },
data() { return { order: [], dragIdx: -1, overIdx: -1, retries: 0, wrong: false, done: false, allWin: false, rects: [] } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'step_sort', items: [], answer_order: [] } }
@@ -48,12 +48,27 @@ export default {
},
methods: {
propVisual,
onStart(e, i) { this.dragIdx = i },
onMove(e, i) {
if (this.dragIdx === -1 || this.dragIdx === i) return
this.overIdx = i
onStart(e, i) {
if (this.done) return
this.dragIdx = i
// 拿起时量一次所有槽位位置,touchmove 按坐标判定
uni.createSelectorQuery().in(this).selectAll('.slot').boundingClientRect(rects => {
this.rects = rects || []
}).exec()
},
onMove(e) {
if (this.dragIdx === -1) return
const t = e.touches && e.touches[0]
if (!t || !this.rects.length) return
let idx = -1
for (let i = 0; i < this.rects.length; i++) {
const r = this.rects[i]
if (t.clientY >= r.top && t.clientY <= r.bottom) { idx = i; break }
}
this.overIdx = idx
},
onEnd() {
if (this.done) return
if (this.dragIdx === -1 || this.overIdx === -1) { this.dragIdx = -1; this.overIdx = -1; return }
const arr = this.order.slice()
;[arr[this.dragIdx], arr[this.overIdx]] = [arr[this.overIdx], arr[this.dragIdx]]
@@ -88,20 +103,14 @@ export default {
<style scoped>
.hint { text-align: center; color: #a08c74; margin-bottom: 28rpx; }
.slot { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.08); }
.slot { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.08); transition: transform .12s ease; }
.slot:active { transform: scale(.98); }
.slot.over { border: 4rpx solid #ff9a3d; }
.slot.dragging { opacity: .6; }
.slot.win { border: 4rpx solid #ffb347; animation: win-pulse .5s ease; }
.order-no { width: 56rpx; height: 56rpx; border-radius: 50%; background: #ff9a3d; color: #fff; font-weight: 800; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; }
.slot-icon { font-size: 44rpx; margin-right: 20rpx; }
.slot-name { font-size: 30rpx; color: #5b4636; font-weight: 600; }
.wrong-tip { text-align: center; color: #ff6b6b; margin-top: 20rpx; }
.slot { transition: transform .12s ease, box-shadow .12s ease; }
.slot:active { transform: scale(.92); }
.slot.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.slot.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
@keyframes win-pulse { 0% { transform: scale(.96); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }
</style>