Files
zyt/TUICallKit-Vue3/training/pages/camera-grip-ring.vue
T
longandCursor 729d2a084d feat(training): 新增握力环 2.0 摄像头手势识别页
用前置摄像头 + VisionKit/ROI 管线驱动开合计数,复用跟练舞台与连击粒子,并加固结束收尾避免末段卡死无响应。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:31:31 +08:00

1723 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<!-- 自定义导航对齐微信小程序胶囊 -->
<view class="nav" :style="navStyle">
<view class="nav-row" :style="navRowStyle">
<view class="nav-back" @click="onBack">
<text class="nav-back-icon"></text>
</view>
<view class="nav-center">
<text class="nav-title">握力环训练 2.0</text>
<view class="nav-status">
<view class="nav-dot" :class="trackingQuality" />
<text class="nav-status-text">{{ trackingLabel }}</text>
</view>
</view>
<view class="nav-capsule-spacer" :style="{ width: capsuleSpacerPx + 'px' }" />
</view>
</view>
<!-- 实时指标 -->
<view class="metrics-wrap">
<view class="metrics">
<view class="metric">
<text class="metric-value">{{ validReps }}</text>
<text class="metric-label">有效握紧</text>
</view>
<view class="metric">
<text class="metric-value">{{ accuracyText }}</text>
<text class="metric-label">准确率</text>
</view>
<view class="metric metric--combo">
<text class="metric-value metric-value--combo">连击 x{{ combo }}</text>
<text class="metric-label">当前连击</text>
</view>
<view class="metric">
<text class="metric-value">{{ elapsedText }}</text>
<text class="metric-label">训练时间</text>
</view>
</view>
</view>
<!-- 中间画布相机/动画 + 反馈层 -->
<view class="stage" :class="'cap-' + captureState">
<!-- #ifdef MP-WEIXIN -->
<camera
v-if="cameraReady"
id="gripCamera"
class="stage-camera"
device-position="front"
flash="off"
frame-size="small"
@initdone="onCameraInitDone"
@error="onCameraError"
/>
<!-- #endif -->
<!-- 动画模式示范图跟练 + 粒子特效识别仍走相机帧 -->
<view v-if="cameraReady && stageMode === 'anim'" class="stage-anim">
<view class="ambient-glow" />
<CrushCanvas v-if="crushEnabled" :crush-signal="crushSignal" />
<view class="ring-container" :class="['sq-' + energyLevel, { idle: demoIdle }]">
<view class="beat-halo" />
<view class="video-ring">
<image
class="demo-video"
:src="TUTORIAL_POSTER_URL"
mode="aspectFill"
/>
<view class="video-ring-border" />
</view>
</view>
</view>
<view v-if="!cameraReady || cameraError" class="stage-fallback">
<text class="stage-fallback-text">{{ sessionFinished ? '本轮训练已完成' : cameraHint }}</text>
<view
v-if="sessionFinished"
class="stage-fallback-btn"
@click="onRestart"
>
<text class="stage-fallback-btn-text">再练一次</text>
</view>
<view v-else-if="!cameraReady && !sessionPlaying" class="stage-fallback-btn" @click="startCameraSession">
<text class="stage-fallback-btn-text">开启摄像头训练</text>
</view>
</view>
<view v-if="cameraReady && stageMode === 'camera'" class="stage-wash" />
<!-- 画布四角捕捉状态框 -->
<view v-if="cameraReady && !sessionFinished" class="capture-frame">
<view class="corner corner--tl" />
<view class="corner corner--tr" />
<view class="corner corner--bl" />
<view class="corner corner--br" />
</view>
<!-- 捕捉状态标签 -->
<view v-if="cameraReady && !sessionFinished" class="capture-pill">
<view class="capture-dot" />
<text class="capture-pill-text">{{ captureLabel }}</text>
</view>
<!-- 动画/实景切换 -->
<view v-if="cameraReady && !sessionFinished" class="mode-toggle" @click="toggleStageMode">
<text class="mode-toggle-text">{{ stageMode === 'anim' ? '看实景' : '看动画' }}</text>
</view>
<view v-if="!sessionFinished" class="stage-hud">
<text class="hit-title" :class="{ active: showHitFeedback }">{{ headlineText }}</text>
<view v-if="combo >= 2" :key="comboKey" class="combo-float">
<view class="combo-pill">
<text class="combo-pill-text">连击 x{{ combo }}</text>
<view class="combo-drain">
<view class="combo-drain-fill" />
</view>
</view>
<view class="speed-line speed-line--1" />
<view class="speed-line speed-line--2" />
<view class="speed-line speed-line--3" />
</view>
<view v-if="combo >= 3" class="combo-ribbon">
<text class="combo-ribbon-line">连击 x{{ combo }}</text>
<text class="combo-ribbon-sub">太棒了</text>
</view>
<view v-if="showComboBreak" class="combo-break">
<text class="combo-break-text">连击中断</text>
</view>
<view v-if="showReward" class="reward-wrap">
<view class="reward-glow" />
<text class="reward-burst">+{{ lastReward }}</text>
</view>
</view>
<!-- 庆祝层横幅 + 撒花 -->
<view v-if="celebrateVisible" :key="'c' + celebrateKey" class="celebrate" :class="celebrateKind">
<view class="celebrate-banner">
<text class="celebrate-banner-text">{{ celebrateText }}</text>
</view>
<view v-for="n in 6" :key="n" class="confetti" :class="'confetti-' + n" />
</view>
</view>
<!-- 底部目标与操作始终可点结束用强制收尾 -->
<view class="footer">
<view class="goal-row">
<text class="goal-text">任务进度 {{ validReps }}/{{ targetReps }}</text>
<text class="remain-text">{{ sessionFinished ? '已完成' : `剩余 ${remainText}` }}</text>
</view>
<view class="goal-track">
<view class="goal-fill" :style="{ width: goalProgress * 100 + '%' }" />
</view>
<view class="actions">
<view
v-if="!sessionFinished"
class="action action--pause"
@click.stop="onTogglePause"
>
<text class="action-icon">{{ sessionPlaying && !sessionPaused ? '❚❚' : '▶' }}</text>
<text class="action-text">{{ !sessionPlaying ? '开始' : sessionPaused ? '继续' : '暂停' }}</text>
</view>
<view
v-if="!sessionFinished"
class="action action--end"
@click.stop="onEndTraining"
>
<text class="action-icon"></text>
<text class="action-text">结束训练</text>
</view>
<view
v-if="sessionFinished"
class="action action--pause"
@click.stop="onRestart"
>
<text class="action-text">再练一次</text>
</view>
<view
v-if="sessionFinished"
class="action action--end"
@click.stop="onBack"
>
<text class="action-text">返回</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import CrushCanvas from './components/crush-canvas.vue'
import { createGesturePipeline } from '../motion/gesture-pipeline'
import type { GesturePhase, GestureSource, TrackingQuality } from '../motion/types'
// 与旧握力环页共用封面(跟练动画主体;训练中不叠播网络视频,避免与 camera 抢原生线程)
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/202605291030231aed12802.jpg'
const TARGET_REPS = 30
const TARGET_SECONDS = 300
const statusBarPx = ref(20)
const navRowPx = ref(44)
const capsuleSpacerPx = ref(96)
const cameraReady = ref(false)
const cameraInited = ref(false)
const cameraError = ref('')
const sessionPlaying = ref(false)
const sessionPaused = ref(false)
const sessionFinished = ref(false)
const sessionEnding = ref(false)
const trackingQuality = ref<TrackingQuality>('lost')
const gesturePhase = ref<GesturePhase>('UNTRACKED')
const energyLevel = ref(0)
const pipelineSource = ref<GestureSource>('none')
const frameFps = ref(0)
const validReps = ref(0)
const attemptReps = ref(0)
const combo = ref(0)
const bestCombo = ref(0)
const elapsedSeconds = ref(0)
const lastReward = ref(1)
const showReward = ref(false)
const showHitFeedback = ref(false)
/** 画布模式:默认动画演示,可切实景 */
const stageMode = ref<'anim' | 'camera'>('anim')
/** 连击:时限内完成下一次计连击,超时或脱离画面则中断 */
const COMBO_WINDOW_MS = 6000
const comboKey = ref(0)
const showComboBreak = ref(false)
/** 庆祝层(横幅 + 撒花) */
const celebrateVisible = ref(false)
const celebrateKind = ref<'combo' | 'progress' | 'finish'>('combo')
const celebrateText = ref('')
const celebrateKey = ref(0)
/** 捏碎粒子:连击达标才触发,随进度换款,末段减负 */
const crushEnabled = ref(false)
const crushSignal = ref<{ type: 'egg' | 'walnut' | 'can' | 'balloon'; nonce: number; intensity?: number } | null>(null)
type CrushType = 'egg' | 'walnut' | 'can' | 'balloon'
let lastCrushTs = 0
const targetReps = TARGET_REPS
let timerId: ReturnType<typeof setInterval> | null = null
let rewardTimer: ReturnType<typeof setTimeout> | null = null
let comboBreakTimer: ReturnType<typeof setTimeout> | null = null
let comboBreakHideTimer: ReturnType<typeof setTimeout> | null = null
let celebrateTimer: ReturnType<typeof setTimeout> | null = null
let teardownTimer: ReturnType<typeof setTimeout> | null = null
let lastRepTs = 0
let startingPipeline = false
let endModalShown = false
let isLeaving = false
let cleanedUp = false
const pipeline = createGesturePipeline({
onObservation(obs) {
if (sessionEnding.value || sessionFinished.value || !sessionPlaying.value) return
if (gesturePhase.value !== obs.phase) {
gesturePhase.value = obs.phase
if (obs.phase === 'UNTRACKED' && combo.value > 0) breakCombo()
}
// 能量环降频:只在档位变化时更新
const nextLevel = Math.min(4, Math.round(obs.compression * 4))
if (energyLevel.value !== nextLevel) energyLevel.value = nextLevel
if (trackingQuality.value !== obs.trackingQuality) {
trackingQuality.value = obs.trackingQuality
}
// 源/fps 很少变,降低写入
if (pipelineSource.value !== obs.source) {
pipelineSource.value = obs.source
}
const hit = obs.phase === 'CLOSED_CONFIRMED' || obs.phase === 'CLOSING'
if (showHitFeedback.value !== hit) showHitFeedback.value = hit
},
onRepCompleted() {
onRepCompleted()
},
onWarning(message) {
uni.showToast({ title: message.slice(0, 40), icon: 'none', duration: 2200 })
},
onError(message) {
cameraError.value = message
trackingQuality.value = 'lost'
uni.showToast({ title: message, icon: 'none', duration: 2500 })
},
})
const navStyle = computed(() => ({
paddingTop: `${statusBarPx.value}px`,
}))
const navRowStyle = computed(() => ({
height: `${navRowPx.value}px`,
}))
const trackingLabel = computed(() => {
if (!cameraReady.value) return '待开启'
if (cameraError.value) return '识别不可用'
if (sessionPaused.value) return '已暂停'
if (trackingQuality.value === 'lost') return '追踪丢失'
if (trackingQuality.value === 'weak') return '识别较弱'
const src = pipelineSource.value === 'roi' ? 'ROI' : ''
return src ? `识别中 · ${src}` : '识别中'
})
const accuracyText = computed(() => {
if (attemptReps.value <= 0) return '—'
return `${Math.round((validReps.value / attemptReps.value) * 100)}%`
})
const elapsedText = computed(() => formatClock(elapsedSeconds.value))
const remainText = computed(() => formatClock(Math.max(0, TARGET_SECONDS - elapsedSeconds.value)))
const goalProgress = computed(() => Math.min(1, validReps.value / targetReps))
const headlineText = computed(() => {
switch (gesturePhase.value) {
case 'OPEN_READY':
return '准备握紧'
case 'CLOSING':
return '用力握紧'
case 'CLOSED_CONFIRMED':
return '保持住'
case 'REOPENING':
return '慢慢松开'
default:
return '把手放入画面'
}
})
/** 画布四周捕捉状态:lost / seek / open / grip / hold / release */
const captureState = computed(() => {
if (sessionPaused.value) return 'seek'
switch (gesturePhase.value) {
case 'OPEN_READY':
return 'open'
case 'CLOSING':
return 'grip'
case 'CLOSED_CONFIRMED':
return 'hold'
case 'REOPENING':
return 'release'
default:
return trackingQuality.value === 'lost' ? 'lost' : 'seek'
}
})
const captureLabel = computed(() => {
switch (captureState.value) {
case 'open':
return '已捕捉到手'
case 'grip':
return '握紧中'
case 'hold':
return '保持中'
case 'release':
return '松开中'
case 'seek':
return '正在捕捉…'
default:
return '脱离画面'
}
})
const cameraHint = computed(() => {
if (cameraError.value) return cameraError.value
if (!cameraReady.value) return '开启前置摄像头后开始训练(裸手开合试验)'
return '正在准备相机…'
})
function toggleStageMode() {
stageMode.value = stageMode.value === 'anim' ? 'camera' : 'anim'
}
/** 未捕捉到手时,演示动画自动循环示范动作 */
const demoIdle = computed(() => captureState.value === 'seek' || captureState.value === 'lost')
function formatClock(total: number) {
const m = Math.floor(total / 60)
const s = total % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
function initNavMetrics() {
try {
const sys = uni.getSystemInfoSync()
statusBarPx.value = Number(sys.statusBarHeight) || 20
// #ifdef MP-WEIXIN
const menu = typeof uni.getMenuButtonBoundingClientRect === 'function'
? uni.getMenuButtonBoundingClientRect()
: null
if (menu && menu.height) {
navRowPx.value = menu.height + (menu.top - statusBarPx.value) * 2
capsuleSpacerPx.value = Math.max(88, sys.windowWidth - menu.left + 8)
}
// #endif
} catch (_) {}
}
function requestCameraPermission(): Promise<boolean> {
// #ifdef MP-WEIXIN
return new Promise((resolve) => {
// @ts-ignore
const wxApi = typeof wx !== 'undefined' ? wx : null
if (!wxApi?.authorize) {
resolve(true)
return
}
wxApi.authorize({
scope: 'scope.camera',
success: () => resolve(true),
fail: () => {
uni.showModal({
title: '需要摄像头权限',
content: '握力环 2.0 需要前置摄像头做视觉识别(试验),请在设置中开启。',
confirmText: '去设置',
success: (res) => {
if (res.confirm) uni.openSetting({})
resolve(false)
},
})
},
})
})
// #endif
// #ifndef MP-WEIXIN
return Promise.resolve(false)
// #endif
}
async function startCameraSession() {
const ok = await requestCameraPermission()
if (!ok) {
cameraError.value = '未获得摄像头权限'
return
}
cameraError.value = ''
cameraInited.value = false
cameraReady.value = true
trackingQuality.value = 'weak'
await nextTick()
// initdone 回调里再启动 pipeline;若迟迟未到则兜底
setTimeout(() => {
if (
cameraReady.value
&& !pipeline.isActive()
&& !startingPipeline
&& !sessionFinished.value
&& !sessionEnding.value
) {
beginSession()
}
}, 600)
}
function onCameraInitDone() {
cameraInited.value = true
if (sessionFinished.value || sessionEnding.value) return
if (!sessionPlaying.value || !pipeline.isActive()) {
beginSession()
}
}
async function beginSession() {
if (startingPipeline || sessionEnding.value || sessionFinished.value) return
sessionFinished.value = false
endModalShown = false
sessionPlaying.value = true
sessionPaused.value = false
startTimer()
startingPipeline = true
try {
const ok = await pipeline.start()
// 达目标/结束过程中若 start 仍在飞,立刻停掉,避免“结束后又活过来”
if (!ok || sessionEnding.value || sessionFinished.value) {
pipeline.stop()
sessionPlaying.value = false
stopTimer()
return
}
try {
uni.setKeepScreenOn({ keepScreenOn: true })
} catch (_) {}
} finally {
startingPipeline = false
}
}
function startTimer() {
if (timerId) return
timerId = setInterval(() => {
if (sessionPaused.value || sessionEnding.value || sessionFinished.value) return
elapsedSeconds.value += 1
if (elapsedSeconds.value >= TARGET_SECONDS) {
setTimeout(() => finishSession('time'), 0)
}
}, 1000)
}
function stopTimer() {
if (timerId) {
clearInterval(timerId)
timerId = null
}
}
/** 连击中断:超时未完成下一次,或手脱离画面 */
function breakCombo() {
if (comboBreakTimer) {
clearTimeout(comboBreakTimer)
comboBreakTimer = null
}
if (combo.value >= 2) {
showComboBreak.value = true
if (comboBreakHideTimer) clearTimeout(comboBreakHideTimer)
comboBreakHideTimer = setTimeout(() => {
showComboBreak.value = false
}, 1200)
}
combo.value = 0
}
function armComboWindow() {
if (comboBreakTimer) clearTimeout(comboBreakTimer)
comboBreakTimer = setTimeout(() => {
comboBreakTimer = null
if (sessionPlaying.value && !sessionFinished.value) breakCombo()
}, COMBO_WINDOW_MS)
}
function celebrate(kind: 'combo' | 'progress' | 'finish', text: string, durationMs = 1400) {
if (sessionEnding.value || sessionFinished.value) return
celebrateKind.value = kind
celebrateText.value = text
celebrateKey.value += 1
celebrateVisible.value = true
if (celebrateTimer) clearTimeout(celebrateTimer)
celebrateTimer = setTimeout(() => {
celebrateVisible.value = false
}, durationMs)
}
/**
* 粒子策略:
* - 普通次数:不播(减卡顿)
* - 连击 ≥3 且命中里程碑才播
* - 前段 egg → 中段 walnut/can → 高潮 balloon → 末段停播/极简
*/
function pickCrushEffect(reps: number, currentCombo: number, target: number): { type: CrushType; intensity: number } | null {
if (reps >= target - 2) return null // 最后 2 次全力保流畅
if (currentCombo < 3) return null
const isMilestone =
currentCombo === 3
|| currentCombo === 5
|| currentCombo === 8
|| currentCombo === 10
|| currentCombo === 15
|| currentCombo === 20
|| (currentCombo >= 12 && currentCombo % 5 === 0)
if (!isMilestone) return null
// 两次粒子至少隔 900ms,避免连发叠帧
const now = Date.now()
if (now - lastCrushTs < 900) return null
const progress = reps / target
if (progress < 0.35) {
return { type: 'egg', intensity: 0.75 }
}
if (progress < 0.6) {
return { type: currentCombo >= 8 ? 'can' : 'walnut', intensity: 0.9 }
}
if (progress < 0.8) {
// 高潮:高连击用气球彩纸,否则易拉罐
return { type: currentCombo >= 10 ? 'balloon' : 'can', intensity: 1 }
}
// 接近尾声:只保留轻量 egg,且要求更高连击
if (currentCombo >= 10 && currentCombo % 5 === 0) {
return { type: 'egg', intensity: 0.55 }
}
return null
}
function triggerCrushIfNeeded(reps: number, currentCombo: number) {
const picked = pickCrushEffect(reps, currentCombo, targetReps)
if (!picked) return
lastCrushTs = Date.now()
if (!crushEnabled.value) crushEnabled.value = true
crushSignal.value = {
type: picked.type,
intensity: picked.intensity,
nonce: Date.now(),
}
}
function onRepCompleted() {
if (sessionEnding.value || sessionFinished.value || !sessionPlaying.value) return
const now = Date.now()
attemptReps.value += 1
validReps.value += 1
if (lastRepTs && now - lastRepTs <= COMBO_WINDOW_MS) {
combo.value += 1
} else {
combo.value = 1
}
lastRepTs = now
comboKey.value += 1
showComboBreak.value = false
armComboWindow()
bestCombo.value = Math.max(bestCombo.value, combo.value)
lastReward.value = 1
showReward.value = true
showHitFeedback.value = true
// 粒子:连击里程碑 + 进度换款,非每次都播
triggerCrushIfNeeded(validReps.value, combo.value)
if (validReps.value >= targetReps - 2) {
pipeline.setNearGoal(true)
}
if (rewardTimer) clearTimeout(rewardTimer)
rewardTimer = setTimeout(() => {
showReward.value = false
}, 480)
if (validReps.value >= targetReps) {
setTimeout(() => finishSession('goal'), 0)
return
}
if (combo.value >= 5 && combo.value % 5 === 0) {
celebrate('combo', `连击 x${combo.value}!太棒了!`)
} else if (validReps.value === Math.ceil(targetReps / 2)) {
celebrate('progress', `已完成一半,继续加油!`)
}
}
function onTogglePause() {
if (isLeaving) return
if (sessionFinished.value) {
onRestart()
return
}
if (!sessionPlaying.value) {
startCameraSession()
return
}
sessionPaused.value = !sessionPaused.value
try {
pipeline.setPaused(sessionPaused.value)
} catch (_) {}
if (sessionPaused.value) trackingQuality.value = 'weak'
}
function onEndTraining() {
if (isLeaving) return
// 强制结束也要跳出当前事件栈,避免与帧回调打架
setTimeout(() => finishSession('manual'), 0)
}
function clearFeedbackTimers() {
if (rewardTimer) {
clearTimeout(rewardTimer)
rewardTimer = null
}
if (comboBreakTimer) {
clearTimeout(comboBreakTimer)
comboBreakTimer = null
}
if (comboBreakHideTimer) {
clearTimeout(comboBreakHideTimer)
comboBreakHideTimer = null
}
if (celebrateTimer) {
clearTimeout(celebrateTimer)
celebrateTimer = null
}
if (teardownTimer) {
clearTimeout(teardownTimer)
teardownTimer = null
}
}
function finishSession(reason: 'goal' | 'time' | 'manual') {
if (sessionFinished.value && endModalShown) return
if (sessionEnding.value && sessionFinished.value) return
sessionEnding.value = true
sessionPlaying.value = false
sessionPaused.value = false
stopTimer()
clearFeedbackTimers()
celebrateVisible.value = false
showReward.value = false
showHitFeedback.value = false
showComboBreak.value = false
crushSignal.value = null
celebrateVisible.value = false
showReward.value = false
showHitFeedback.value = false
showComboBreak.value = false
lastRepTs = 0
lastCrushTs = 0
gesturePhase.value = 'UNTRACKED'
energyLevel.value = 0
pipelineSource.value = 'none'
trackingQuality.value = 'lost'
// 1) 先停识别(仍保留 camera 节点,避开原生销毁死锁)
try {
pipeline.setNearGoal(false)
pipeline.stop()
} catch (_) {}
sessionFinished.value = true
sessionEnding.value = false
const title = reason === 'goal' ? '目标完成' : reason === 'time' ? '时间到' : '训练结束'
const content = `有效握紧 ${validReps.value} 次 · 准确率 ${accuracyText.value} · 最高连击 x${bestCombo.value}`
// 2) 延后拆相机 + 弹窗,确保已离开帧回调栈
teardownTimer = setTimeout(() => {
teardownTimer = null
cameraReady.value = false
cameraInited.value = false
try {
uni.setKeepScreenOn({ keepScreenOn: false })
} catch (_) {}
if (endModalShown) return
endModalShown = true
uni.showModal({
title,
content,
confirmText: '再练一次',
cancelText: '返回',
success: (res) => {
endModalShown = false
if (res.confirm) onRestart()
else onBack()
},
fail: () => {
endModalShown = false
},
})
}, 120)
}
function onRestart() {
if (isLeaving) return
endModalShown = false
sessionEnding.value = false
sessionFinished.value = false
validReps.value = 0
attemptReps.value = 0
combo.value = 0
bestCombo.value = 0
elapsedSeconds.value = 0
energyLevel.value = 0
gesturePhase.value = 'UNTRACKED'
showReward.value = false
showHitFeedback.value = false
showComboBreak.value = false
celebrateVisible.value = false
crushSignal.value = null
crushEnabled.value = false
lastCrushTs = 0
lastRepTs = 0
cameraError.value = ''
stageMode.value = 'anim'
try {
pipeline.setNearGoal(false)
} catch (_) {}
startCameraSession()
}
function leavePage() {
if (isLeaving) return
isLeaving = true
// 先停识别,但不要在跳转前强拆页面;完整清理交给 onUnload
stopTimer()
pipeline.stop()
try {
uni.setKeepScreenOn({ keepScreenOn: false })
} catch (_) {}
sessionPlaying.value = false
sessionPaused.value = false
uni.navigateBack({
fail: () => {
// 无上一页时回首页,避免 redirectTo 子包页与销毁竞态导致 pageId 错乱
uni.reLaunch({
url: '/pages/index/index',
fail: () => {
isLeaving = false
},
})
},
})
}
function onBack() {
if (isLeaving) return
if (sessionPlaying.value && validReps.value > 0 && !sessionFinished.value) {
uni.showModal({
title: '退出训练?',
content: '当前为视觉识别试验进度,不会写入旧版握力环页。',
success: (res) => {
if (res.confirm) leavePage()
},
})
return
}
leavePage()
}
function onCameraError(e: any) {
cameraError.value = e?.detail?.errMsg || '摄像头不可用'
cameraReady.value = false
trackingQuality.value = 'lost'
pipeline.stop()
}
function cleanup() {
if (cleanedUp) return
cleanedUp = true
sessionEnding.value = true
stopTimer()
pipeline.stop()
cameraReady.value = false
clearFeedbackTimers()
try {
uni.setKeepScreenOn({ keepScreenOn: false })
} catch (_) {}
sessionPlaying.value = false
sessionPaused.value = false
sessionEnding.value = false
}
onLoad(() => {
initNavMetrics()
isLeaving = false
cleanedUp = false
})
// 仅在真正离开页面时清理;onHide 会在弹窗/短暂切后台时误伤会话
onUnload(cleanup)
onBeforeUnmount(cleanup)
</script>
<style lang="scss" scoped>
$bg: #f8fafc;
$card: #ffffff;
$text: #0f172a;
$muted: #64748b;
$brand: #14b8a6;
$brand-deep: #0f766e;
$green: #22b85a;
$orange: #ff6b21;
$danger: #e83e3e;
.page {
min-height: 100vh;
background: linear-gradient(180deg, #f0fdfa 0%, $bg 28%, $bg 100%);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.nav {
background: #ffffff;
}
.nav-row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12rpx 0 8rpx;
}
.nav-back {
position: relative;
z-index: 2;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-back-icon {
font-size: 48rpx;
line-height: 1;
color: #181818;
font-weight: 500;
}
.nav-center {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
}
.nav-title {
font-size: 32rpx;
font-weight: 600;
color: #181818;
}
.nav-status {
margin-top: 4rpx;
display: flex;
align-items: center;
gap: 8rpx;
}
.nav-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #94a3b8;
&.good {
background: #07c160;
}
&.weak {
background: #f59e0b;
}
&.lost {
background: #dc2626;
}
}
.nav-status-text {
font-size: 22rpx;
color: #888888;
}
.nav-capsule-spacer {
position: relative;
z-index: 2;
flex-shrink: 0;
}
.metrics-wrap {
padding: 16rpx 32rpx 12rpx;
}
.metrics {
background: $card;
border-radius: 24rpx;
box-shadow: 0 8rpx 28rpx rgba(15, 23, 42, 0.08);
display: flex;
align-items: center;
min-height: 120rpx;
padding: 20rpx 12rpx;
}
.metric {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.metric-value {
font-size: 36rpx;
font-weight: 800;
color: $text;
line-height: 1.1;
}
.metric-value--combo {
font-size: 28rpx;
color: $orange;
}
.metric-label {
font-size: 20rpx;
color: $muted;
}
.stage {
position: relative;
flex: 1;
margin: 8rpx 0 0;
min-height: 720rpx;
background: #f3faf9;
overflow: hidden;
}
.stage-camera,
.stage-fallback {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.stage-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24rpx;
background: linear-gradient(180deg, #e8faf8 0%, #f7fbfa 100%);
}
.stage-fallback-text {
font-size: 26rpx;
color: $muted;
}
.stage-fallback-btn {
padding: 18rpx 36rpx;
border-radius: 999rpx;
background: $brand;
}
.stage-fallback-btn-text {
color: #fff;
font-size: 28rpx;
font-weight: 700;
}
.stage-wash {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(248, 250, 252, 0.88) 0%,
rgba(240, 253, 250, 0.28) 22%,
rgba(255, 255, 255, 0.12) 55%,
rgba(248, 250, 252, 0.9) 100%
);
}
.stage-hud {
position: absolute;
inset: 0;
z-index: 30;
pointer-events: none;
}
.hit-title {
position: absolute;
top: 30rpx;
left: 0;
right: 0;
text-align: center;
font-size: 56rpx;
font-weight: 900;
letter-spacing: 4rpx;
color: $brand-deep;
text-shadow:
0 0 8rpx #ffffff,
0 3rpx 0 rgba(255, 255, 255, 0.9),
0 8rpx 20rpx rgba(15, 118, 110, 0.25);
&.active {
color: #17b357;
text-shadow:
0 0 8rpx #ffffff,
0 3rpx 0 rgba(255, 255, 255, 0.9),
0 8rpx 22rpx rgba(23, 179, 87, 0.35);
}
}
/* ===== 悬浮连击胶囊(仿视频:橙色渐变 + 速度线) ===== */
.combo-float {
position: absolute;
top: 118rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
animation: combo-pop 300ms cubic-bezier(0.18, 0.89, 0.32, 1.4);
}
@keyframes combo-pop {
0% {
transform: translateX(-50%) scale(0.6);
opacity: 0;
}
100% {
transform: translateX(-50%) scale(1);
opacity: 1;
}
}
.combo-pill {
padding: 10rpx 30rpx 12rpx;
border-radius: 999rpx;
background: linear-gradient(95deg, #ff5a1f 0%, #ff9e2c 70%, #ffc93c 100%);
box-shadow: 0 10rpx 26rpx rgba(255, 199, 44, 0.45), 0 4rpx 10rpx rgba(255, 90, 31, 0.35);
}
.combo-pill-text {
color: #fff;
font-size: 30rpx;
font-weight: 900;
font-style: italic;
text-shadow: 0 2rpx 4rpx rgba(200, 60, 0, 0.35);
}
/* 连击时限条:时限内完成下一次才算连击 */
.combo-drain {
margin-top: 6rpx;
height: 5rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.35);
overflow: hidden;
}
.combo-drain-fill {
height: 100%;
border-radius: 999rpx;
background: #ffffff;
animation: combo-drain 6s linear forwards;
}
@keyframes combo-drain {
from {
width: 100%;
}
to {
width: 0%;
}
}
/* 速度线:胶囊右侧的冲刺短线 */
.speed-line {
height: 8rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #ffc93c 0%, rgba(255, 201, 60, 0) 100%);
margin-left: 12rpx;
}
.speed-line--1 {
width: 44rpx;
transform: translateY(-12rpx);
}
.speed-line--2 {
width: 64rpx;
margin-left: -52rpx;
transform: translateY(4rpx);
}
.speed-line--3 {
width: 36rpx;
transform: translateY(18rpx);
margin-left: -48rpx;
}
/* ===== 左上角飘带(仿视频:绿色斜标) ===== */
.combo-ribbon {
position: absolute;
left: -14rpx;
top: 40rpx;
padding: 14rpx 34rpx 14rpx 26rpx;
background: linear-gradient(120deg, #17b357 0%, #35d97b 100%);
border-radius: 10rpx 18rpx 18rpx 10rpx;
transform: rotate(-8deg);
box-shadow: 0 10rpx 24rpx rgba(23, 179, 87, 0.35);
animation: ribbon-in 380ms cubic-bezier(0.18, 0.89, 0.32, 1.3);
}
@keyframes ribbon-in {
0% {
transform: rotate(-8deg) translateX(-120rpx);
opacity: 0;
}
100% {
transform: rotate(-8deg) translateX(0);
opacity: 1;
}
}
.combo-ribbon-line {
display: block;
color: #fff;
font-size: 30rpx;
font-weight: 900;
font-style: italic;
text-shadow: 0 2rpx 4rpx rgba(9, 92, 45, 0.4);
}
.combo-ribbon-sub {
display: block;
margin-top: 2rpx;
color: #eafff2;
font-size: 22rpx;
font-weight: 800;
font-style: italic;
}
.combo-break {
position: absolute;
top: 118rpx;
left: 50%;
transform: translateX(-50%);
padding: 8rpx 24rpx;
border-radius: 999rpx;
background: rgba(100, 116, 139, 0.85);
}
.combo-break-text {
color: #fff;
font-size: 24rpx;
font-weight: 700;
}
/* ===== +N 爆发(仿视频:橙色星芒) ===== */
.reward-wrap {
position: absolute;
right: 24rpx;
top: 22%;
width: 220rpx;
height: 220rpx;
animation: reward-pop 640ms cubic-bezier(0.18, 0.89, 0.32, 1.35);
}
@keyframes reward-pop {
0% {
transform: scale(0.4) rotate(-8deg);
opacity: 0;
}
45% {
transform: scale(1.12) rotate(2deg);
opacity: 1;
}
100% {
transform: scale(1) rotate(0deg);
}
}
.reward-glow {
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(255, 236, 170, 0.95) 0%,
rgba(255, 196, 66, 0.55) 42%,
rgba(255, 170, 40, 0) 70%
);
}
.reward-burst {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) rotate(-6deg);
font-size: 84rpx;
font-weight: 900;
font-style: italic;
color: #ff8a1e;
text-shadow: 0 0 6rpx #fff7e0, 0 6rpx 18rpx rgba(255, 138, 30, 0.5);
}
/* ===== 动画演示层(纯代码绘制,默认显示,可切实景) ===== */
.stage-anim {
position: absolute;
inset: 0;
z-index: 8;
background: linear-gradient(180deg, #e8f4fb 0%, #f0fbf7 55%, #e9f7f3 100%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.ambient-glow {
position: absolute;
width: 1100rpx;
height: 1100rpx;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(
circle,
rgba(204, 251, 241, 0.9) 0%,
rgba(240, 253, 250, 0.5) 32%,
transparent 70%
);
pointer-events: none;
}
/* ===== 移植自旧握力环页的圆形视频舞台(手势驱动) ===== */
.ring-container {
position: relative;
width: 640rpx;
height: 640rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
/* 节拍光晕:随握力实时收缩变亮 */
.beat-halo {
position: absolute;
width: 640rpx;
height: 640rpx;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(20, 184, 166, 0.45) 0%,
rgba(45, 212, 191, 0.18) 38%,
rgba(20, 184, 166, 0) 70%
);
opacity: 0.35;
transform: scale(1);
transition: transform 140ms ease-out, opacity 140ms linear;
will-change: transform, opacity;
}
/* 圆形示范视频主体 */
.video-ring {
position: relative;
width: 560rpx;
height: 560rpx;
border-radius: 50%;
overflow: hidden;
box-shadow:
0 20rpx 56rpx rgba(20, 184, 166, 0.35),
0 10rpx 28rpx rgba(45, 212, 191, 0.22);
transform: scale(1);
transition: transform 140ms ease-out;
will-change: transform;
z-index: 1;
}
.demo-video {
width: 100%;
height: 100%;
border-radius: 50%;
background: #e2f5f1;
}
.video-ring-border {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
box-shadow:
inset 0 0 0 4rpx rgba(255, 255, 255, 0.6),
inset 0 8rpx 24rpx rgba(15, 118, 110, 0.25);
transition: box-shadow 200ms linear;
}
/* ---- 握力驱动:环随压缩收缩、光晕增强 ---- */
.sq-1 .video-ring { transform: scale(0.985); }
.sq-2 .video-ring { transform: scale(0.965); }
.sq-3 .video-ring { transform: scale(0.945); }
.sq-4 .video-ring { transform: scale(0.92); }
.sq-1 .beat-halo { opacity: 0.5; transform: scale(0.97); }
.sq-2 .beat-halo { opacity: 0.65; transform: scale(0.94); }
.sq-3 .beat-halo { opacity: 0.8; transform: scale(0.9); }
.sq-4 .beat-halo {
opacity: 1;
transform: scale(0.86);
background: radial-gradient(
circle,
rgba(34, 184, 90, 0.5) 0%,
rgba(74, 222, 128, 0.22) 38%,
rgba(34, 184, 90, 0) 70%
);
}
.sq-4 .video-ring-border {
box-shadow:
inset 0 0 0 6rpx rgba(74, 222, 128, 0.85),
inset 0 8rpx 24rpx rgba(15, 118, 110, 0.25);
}
/* ---- 未捕捉到手:舞台自动循环示范(沿用旧页 beat-pulse 节奏) ---- */
.idle .beat-halo {
opacity: 1;
animation: beat-pulse 1900ms ease-in-out infinite;
}
.idle .video-ring {
animation: video-squeeze 1900ms ease-in-out infinite;
}
@keyframes beat-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.4;
}
50% {
transform: scale(0.86);
opacity: 0.95;
}
}
@keyframes video-squeeze {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(0.95);
}
}
/* ===== 画布四角捕捉框 + 状态 ===== */
.capture-frame {
position: absolute;
inset: 20rpx;
z-index: 20;
pointer-events: none;
}
.corner {
position: absolute;
width: 56rpx;
height: 56rpx;
border: 8rpx solid #94a3b8;
transition: border-color 200ms linear;
}
.corner--tl {
left: 0;
top: 0;
border-right: none;
border-bottom: none;
border-top-left-radius: 20rpx;
}
.corner--tr {
right: 0;
top: 0;
border-left: none;
border-bottom: none;
border-top-right-radius: 20rpx;
}
.corner--bl {
left: 0;
bottom: 0;
border-right: none;
border-top: none;
border-bottom-left-radius: 20rpx;
}
.corner--br {
right: 0;
bottom: 0;
border-left: none;
border-top: none;
border-bottom-right-radius: 20rpx;
}
/* 捕捉状态驱动四角颜色 */
.cap-lost .corner {
border-color: #dc2626;
}
.cap-seek .corner {
border-color: #94a3b8;
}
.cap-open .corner {
border-color: $brand;
}
.cap-grip .corner,
.cap-hold .corner {
border-color: $green;
}
.cap-release .corner {
border-color: #f59e0b;
}
.capture-pill {
position: absolute;
left: 50%;
bottom: 36rpx;
transform: translateX(-50%);
z-index: 25;
display: flex;
align-items: center;
gap: 12rpx;
padding: 12rpx 30rpx;
border-radius: 999rpx;
background: rgba(15, 23, 42, 0.55);
}
.capture-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #94a3b8;
}
.cap-lost .capture-dot {
background: #dc2626;
}
.cap-open .capture-dot {
background: #25d9d0;
}
.cap-grip .capture-dot,
.cap-hold .capture-dot {
background: #22b85a;
}
.cap-release .capture-dot {
background: #f59e0b;
}
.capture-pill-text {
color: #fff;
font-size: 28rpx;
font-weight: 700;
}
.mode-toggle {
position: absolute;
right: 16rpx;
top: 12rpx;
z-index: 45;
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(15, 23, 42, 0.45);
}
.mode-toggle-text {
color: #fff;
font-size: 22rpx;
font-weight: 600;
}
/* ===== 庆祝层:横幅 + 撒花 ===== */
.celebrate {
position: absolute;
inset: 0;
z-index: 50;
pointer-events: none;
overflow: hidden;
}
.celebrate-banner {
position: absolute;
left: 50%;
top: 38%;
transform: translateX(-50%);
padding: 20rpx 48rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #ff6a2d 0%, #ffb52d 100%);
box-shadow: 0 12rpx 36rpx rgba(255, 107, 33, 0.4);
animation: banner-in 420ms cubic-bezier(0.18, 0.89, 0.32, 1.28);
white-space: nowrap;
}
.celebrate.finish .celebrate-banner {
background: linear-gradient(90deg, #16a34a 0%, #4ade80 100%);
box-shadow: 0 12rpx 36rpx rgba(22, 163, 74, 0.4);
}
@keyframes banner-in {
0% {
transform: translateX(-50%) scale(0.5);
opacity: 0;
}
100% {
transform: translateX(-50%) scale(1);
opacity: 1;
}
}
.celebrate-banner-text {
color: #fff;
font-size: 40rpx;
font-weight: 900;
}
.confetti {
position: absolute;
top: -30rpx;
width: 18rpx;
height: 30rpx;
border-radius: 4rpx;
opacity: 0;
animation: confetti-fall 1.6s ease-in forwards;
}
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(120vh) rotate(540deg);
opacity: 0.6;
}
}
/* 14 片彩纸:错开位置 / 颜色 / 延迟 */
.confetti-1 { left: 6%; background: #ff6a2d; animation-delay: 0ms; }
.confetti-2 { left: 13%; background: #22b85a; animation-delay: 120ms; }
.confetti-3 { left: 20%; background: #25d9d0; animation-delay: 60ms; }
.confetti-4 { left: 27%; background: #ffb52d; animation-delay: 200ms; }
.confetti-5 { left: 34%; background: #f472b6; animation-delay: 30ms; }
.confetti-6 { left: 41%; background: #22b85a; animation-delay: 160ms; }
.confetti-7 { left: 48%; background: #ff6a2d; animation-delay: 90ms; }
.confetti-8 { left: 55%; background: #25d9d0; animation-delay: 240ms; }
.confetti-9 { left: 62%; background: #ffb52d; animation-delay: 50ms; }
.confetti-10 { left: 69%; background: #f472b6; animation-delay: 180ms; }
.confetti-11 { left: 76%; background: #22b85a; animation-delay: 110ms; }
.confetti-12 { left: 83%; background: #ff6a2d; animation-delay: 260ms; }
.confetti-13 { left: 90%; background: #25d9d0; animation-delay: 140ms; }
.confetti-14 { left: 96%; background: #ffb52d; animation-delay: 20ms; }
.footer {
position: relative;
z-index: 100;
background: #fff;
padding: 24rpx 36rpx calc(24rpx + env(safe-area-inset-bottom));
}
.goal-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.goal-text {
font-size: 28rpx;
font-weight: 800;
color: $text;
}
.remain-text {
font-size: 26rpx;
font-weight: 700;
color: $brand-deep;
}
.goal-track {
margin-top: 16rpx;
height: 14rpx;
border-radius: 999rpx;
background: #e2e8f0;
overflow: hidden;
}
.goal-fill {
height: 100%;
border-radius: 999rpx;
background: linear-gradient(90deg, #42c7c6 0%, #54c9c2 100%);
}
.actions {
margin-top: 28rpx;
display: flex;
gap: 20rpx;
}
.action {
flex: 1;
height: 96rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
box-sizing: border-box;
}
.action--pause {
background: #f0fdfa;
border: 2rpx solid $brand;
}
.action--end {
background: #fef2f2;
border: 2rpx solid #f28b8b;
}
.action-icon {
font-size: 24rpx;
}
.action--pause .action-icon,
.action--pause .action-text {
color: $brand-deep;
font-weight: 800;
}
.action--end .action-icon,
.action--end .action-text {
color: $danger;
font-weight: 800;
}
.action-text {
font-size: 30rpx;
}
</style>