Files
zyt/TUICallKit-Vue3/training/pages/index.vue
T
longandCursor 50abe5dece refactor: 训练模块整体迁入分包,减小主包体积
主包超出 2MB 上限无法上传发布版,把训练相关全部移到独立分包。

目录调整:
- pages/training/* → training/pages/*
- hooks/use{Metronome,VoiceCoach,TrainingSession,TrainingBgm}.ts
  → training/hooks/*
- static/training/* → training/static/*
- 主包 hooks/ 目录已清空,所有 use* 都被分包独占

引用路径更新:
- 分包内 hook import 用相对路径 ../hooks/xxx,不依赖 @/ alias
- 资源路径 /static/training/audio/click.mp3
  → /training/static/audio/click.mp3
- useMetronome 默认 clickSrc / useVoiceCoach VOICE_BASE
  / useTrainingBgm BGM_BASE 全部对齐新路径

pages.json:
- 主包 pages 移除 training/index 和 training/metronome
- subPackages 新增 root="training",含 pages/index 和 pages/metronome
- 跟现有 TUIKit / doctor 分包风格一致

调用方:
- components/dev-training-entry navigateTo URL 更新成
  /training/pages/metronome (此组件留在主包,主包跳分包是合法操作)

文档/工具同步:
- training/static/README.md 路径示例改为 training/static/...
- scripts/generate-voice.mjs VOICE_DIR 与提示输出对齐新路径

Git mv 检测到的全是 rename(R),历史完整保留。
分包总体 80K(pages 44K + hooks 20K + static 16K),主包瘦身明显。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:00:01 +08:00

611 lines
16 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="training-page">
<view class="header">
<view class="header-main">
<text class="title">练一练</text>
<text class="subtitle">器械动作跟练</text>
</view>
<view class="header-link" @click="goMetronome">
🎵 节拍器
</view>
</view>
<view class="equipment-tabs">
<view
v-for="eq in equipmentList"
:key="eq"
class="equipment-tab"
:class="{ active: currentEquipment === eq }"
@click="onSwitchEquipment(eq)"
>
{{ eq }}
</view>
</view>
<scroll-view scroll-x class="exercise-scroll">
<view class="exercise-list">
<view
v-for="ex in filteredExercises"
:key="ex.id"
class="exercise-card"
:class="{ active: selectedId === ex.id }"
@click="onSelect(ex.id)"
>
<text class="exercise-icon">{{ ex.icon }}</text>
<text class="exercise-name">{{ ex.name }}</text>
</view>
</view>
</scroll-view>
<view v-if="selected" class="exercise-detail">
<exercise-anim
:animation-type="selected.animationType"
:bpm="bpm"
:beats-per-rep="selected.beatsPerRep"
:icon="selected.icon"
:is-playing="metronomeIsPlaying"
/>
<view class="beat-indicator">
<view
v-for="i in 4"
:key="i"
class="beat-dot"
:class="{ active: ((beatIndex - 1) % 4) + 1 === i && metronomeIsPlaying }"
/>
</view>
<view class="meta-row">
<text class="meta-label">{{ selected.description }}</text>
</view>
<view class="settings">
<view class="setting-row">
<text class="setting-label">节奏</text>
<slider
:value="bpm"
:min="selected.bpmMin"
:max="selected.bpmMax"
:step="2"
block-size="24"
active-color="#22c55e"
@change="onBpmChange"
/>
<text class="setting-value">{{ bpm }} BPM</text>
</view>
<view class="setting-row inline">
<view class="inline-item">
<text class="setting-label">组数</text>
<view class="stepper">
<text class="step-btn" @click="adjust('sets', -1)">-</text>
<text class="step-val">{{ sets }}</text>
<text class="step-btn" @click="adjust('sets', 1)">+</text>
</view>
</view>
<view class="inline-item">
<text class="setting-label">次数</text>
<view class="stepper">
<text class="step-btn" @click="adjust('reps', -1)">-</text>
<text class="step-val">{{ reps }}</text>
<text class="step-btn" @click="adjust('reps', 1)">+</text>
</view>
</view>
<view class="inline-item">
<text class="setting-label">休息()</text>
<view class="stepper">
<text class="step-btn" @click="adjust('rest', -15)">-</text>
<text class="step-val">{{ restSec }}</text>
<text class="step-btn" @click="adjust('rest', 15)">+</text>
</view>
</view>
</view>
</view>
<view v-if="!isTraining && !isResting && !isDone" class="action-row">
<button class="btn-primary" @click="onStart">开始训练</button>
</view>
<view v-if="isTraining" class="status-card training">
<text class="status-title">训练中</text>
<text class="status-line"> {{ currentSet }} / {{ totalSets }} </text>
<text class="status-line big">{{ currentRep }} / {{ totalReps }}</text>
<view class="action-row">
<button class="btn-secondary" @click="onStop">停止</button>
</view>
</view>
<view v-if="isResting" class="status-card resting">
<text class="status-title">休息中</text>
<text class="status-line big">{{ restRemainingSec }}s</text>
<view class="action-row">
<button class="btn-secondary" @click="onSkipRest">跳过休息</button>
<button class="btn-text" @click="onStop">结束训练</button>
</view>
</view>
<view v-if="isDone" class="status-card done">
<text class="status-title">训练完成 🎉</text>
<text class="status-line"> {{ totalSets }} × {{ totalReps }} </text>
<view class="action-row">
<button class="btn-primary" @click="onStart">再来一次</button>
</view>
</view>
<view class="tips-card">
<text class="tips-title">动作要领</text>
<text v-for="(tip, i) in selected.tips" :key="i" class="tips-item">
{{ tip }}
</text>
<text v-if="selected.contraindication" class="tips-warn">
{{ selected.contraindication }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import ExerciseAnim from './components/exercise-anim.vue'
import { EXERCISE_PRESETS, getPresetById } from './exercises'
import type { ExercisePreset } from './exercises'
import { useMetronome } from '../hooks/useMetronome'
import { useTrainingSession } from '../hooks/useTrainingSession'
import { useVoiceCoach } from '../hooks/useVoiceCoach'
import { useTrainingBgm } from '../hooks/useTrainingBgm'
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
type Equipment = (typeof equipmentList)[number]
const currentEquipment = ref<Equipment>('哑铃')
const selectedId = ref<string>(EXERCISE_PRESETS[0].id)
const filteredExercises = computed(() =>
EXERCISE_PRESETS.filter((e) => e.equipment === currentEquipment.value),
)
const selected = computed<ExercisePreset | undefined>(() =>
getPresetById(selectedId.value),
)
const bpm = ref<number>(60)
const sets = ref<number>(3)
const reps = ref<number>(12)
const restSec = ref<number>(60)
watch(
selected,
(val) => {
if (!val) return
bpm.value = val.defaultBpm
sets.value = val.defaultSets
reps.value = val.defaultReps
restSec.value = val.defaultRestSec
},
{ immediate: true },
)
const voice = useVoiceCoach()
const bgm = useTrainingBgm()
const session = useTrainingSession()
const {
currentSet,
currentRep,
restRemainingSec,
totalReps,
totalSets,
isTraining,
isResting,
isDone,
} = session
const metronome = useMetronome({
initialBpm: bpm.value,
onBeat: () => session.onBeat(),
})
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
watch(bpm, (v) => metronome.setBpm(v))
const onSwitchEquipment = (eq: Equipment) => {
if (isTraining.value || isResting.value) return
currentEquipment.value = eq
const first = EXERCISE_PRESETS.find((e) => e.equipment === eq)
if (first) selectedId.value = first.id
}
const onSelect = (id: string) => {
if (isTraining.value || isResting.value) return
selectedId.value = id
}
const onBpmChange = (e: any) => {
const v = e.detail?.value ?? e
bpm.value = Number(v)
}
const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
if (isTraining.value || isResting.value) return
if (key === 'sets') sets.value = Math.max(1, Math.min(10, sets.value + delta))
if (key === 'reps') reps.value = Math.max(1, Math.min(50, reps.value + delta))
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
}
const onStart = () => {
if (!selected.value) return
uni.setKeepScreenOn({ keepScreenOn: true })
voice.speakPrompt('start')
session.start({
sets: sets.value,
reps: reps.value,
restSec: restSec.value,
beatsPerRep: selected.value.beatsPerRep,
onRepComplete: (rep, total) => {
if (rep <= 30) voice.speakNumber(rep)
if (rep === total - 1) voice.speakPrompt('last-rep')
},
onSetComplete: (set, total) => {
if (set < total) voice.speakPrompt('keep-it-up')
},
onEnterRest: () => {
metronome.stop()
voice.speakPrompt('rest')
bgm.switchTo('resting')
},
onExitRest: () => {
voice.speakPrompt('next-set')
bgm.switchTo('training')
metronome.start()
},
onDone: () => {
metronome.stop()
bgm.switchTo('none')
voice.speakPrompt('good-job')
uni.setKeepScreenOn({ keepScreenOn: false })
},
})
bgm.switchTo('training')
metronome.start()
}
const onStop = () => {
metronome.stop()
session.stop()
bgm.switchTo('none')
uni.setKeepScreenOn({ keepScreenOn: false })
}
const onSkipRest = () => {
session.skipRest()
}
const goMetronome = () => {
uni.navigateTo({ url: '/training/pages/metronome' })
}
onHide(() => {
if (isTraining.value || isResting.value) {
onStop()
}
})
onUnmounted(() => {
uni.setKeepScreenOn({ keepScreenOn: false })
})
</script>
<style lang="scss" scoped>
.training-page {
min-height: 100vh;
background: #f9fafb;
padding: 32rpx 24rpx 80rpx;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
.header-main {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.title {
font-size: 44rpx;
font-weight: 700;
color: #111827;
}
.subtitle {
font-size: 26rpx;
color: #6b7280;
margin-top: 6rpx;
}
.header-link {
padding: 12rpx 24rpx;
background: #f3f4f6;
border-radius: 24rpx;
font-size: 24rpx;
color: #4b5563;
&:active {
background: #e5e7eb;
}
}
}
.equipment-tabs {
display: flex;
gap: 16rpx;
margin-bottom: 24rpx;
overflow-x: auto;
.equipment-tab {
flex-shrink: 0;
padding: 14rpx 32rpx;
background: #fff;
border-radius: 32rpx;
font-size: 28rpx;
color: #4b5563;
border: 2rpx solid transparent;
&.active {
background: #22c55e;
color: #fff;
border-color: #22c55e;
}
}
}
.exercise-scroll {
width: 100%;
margin-bottom: 24rpx;
}
.exercise-list {
display: flex;
gap: 16rpx;
padding-right: 16rpx;
}
.exercise-card {
flex-shrink: 0;
width: 160rpx;
padding: 20rpx 12rpx;
background: #fff;
border-radius: 16rpx;
text-align: center;
border: 2rpx solid transparent;
&.active {
border-color: #22c55e;
background: #f0fdf4;
}
.exercise-icon {
display: block;
font-size: 56rpx;
margin-bottom: 8rpx;
}
.exercise-name {
font-size: 24rpx;
color: #374151;
}
}
.exercise-detail {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.beat-indicator {
display: flex;
justify-content: center;
gap: 16rpx;
padding: 8rpx 0;
.beat-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #d1d5db;
transition: transform 0.15s, background 0.15s;
&.active {
background: #22c55e;
transform: scale(1.6);
}
}
}
.meta-row {
.meta-label {
font-size: 26rpx;
color: #6b7280;
text-align: center;
display: block;
}
}
.settings {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.setting-row {
display: flex;
align-items: center;
gap: 16rpx;
.setting-label {
font-size: 26rpx;
color: #4b5563;
min-width: 80rpx;
}
.setting-value {
font-size: 26rpx;
color: #22c55e;
font-weight: 600;
min-width: 120rpx;
text-align: right;
}
slider {
flex: 1;
}
&.inline {
justify-content: space-between;
gap: 8rpx;
}
}
.inline-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
}
.stepper {
display: flex;
align-items: center;
gap: 12rpx;
.step-btn {
width: 48rpx;
height: 48rpx;
line-height: 48rpx;
text-align: center;
background: #f3f4f6;
border-radius: 24rpx;
font-size: 32rpx;
color: #4b5563;
}
.step-val {
font-size: 30rpx;
font-weight: 600;
color: #111827;
min-width: 56rpx;
text-align: center;
}
}
.action-row {
display: flex;
gap: 16rpx;
justify-content: center;
button {
flex: 1;
max-width: 320rpx;
}
}
.btn-primary {
background: #22c55e;
color: #fff;
border-radius: 999rpx;
font-size: 30rpx;
height: 88rpx;
line-height: 88rpx;
border: none;
}
.btn-secondary {
background: #fff;
color: #22c55e;
border: 2rpx solid #22c55e;
border-radius: 999rpx;
font-size: 28rpx;
height: 80rpx;
line-height: 76rpx;
}
.btn-text {
background: transparent;
color: #ef4444;
font-size: 26rpx;
height: 80rpx;
line-height: 80rpx;
border: none;
}
.status-card {
background: #fff;
border-radius: 20rpx;
padding: 32rpx 24rpx;
text-align: center;
display: flex;
flex-direction: column;
gap: 12rpx;
align-items: center;
&.training {
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
}
&.resting {
background: linear-gradient(135deg, #fef3c7, #fef9c3);
}
&.done {
background: linear-gradient(135deg, #dbeafe, #ede9fe);
}
.status-title {
font-size: 30rpx;
color: #4b5563;
font-weight: 600;
}
.status-line {
font-size: 28rpx;
color: #6b7280;
&.big {
font-size: 64rpx;
color: #111827;
font-weight: 700;
}
}
}
.tips-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
.tips-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
margin-bottom: 8rpx;
}
.tips-item {
font-size: 26rpx;
color: #4b5563;
line-height: 1.6;
}
.tips-warn {
font-size: 26rpx;
color: #ef4444;
margin-top: 12rpx;
line-height: 1.6;
}
}
</style>