This commit is contained in:
2026-05-29 18:17:44 +08:00
parent c589bfa647
commit 132a17b946
15 changed files with 285 additions and 2175 deletions
@@ -2,18 +2,6 @@
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }"> <view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<!-- 展开后的菜单卡片(从下往上动画) --> <!-- 展开后的菜单卡片(从下往上动画) -->
<view v-if="expanded" class="menu-list" @click.stop> <view v-if="expanded" class="menu-list" @click.stop>
<!-- "练一练" 暂时隐藏,后续恢复时取消注释即可 -->
<!-- <view class="menu-item" @click="goto('/training/pages/index')">
<view class="menu-icon menu-icon&#45;&#45;train">
<text class="menu-icon-text">💪</text>
</view>
<view class="menu-info">
<text class="menu-title">练一练</text>
<text class="menu-sub">器械跟练</text>
</view>
<view class="menu-arrow"></view>
</view> -->
<view class="menu-item" @click="goto('/training/pages/dumbbell')"> <view class="menu-item" @click="goto('/training/pages/dumbbell')">
<view class="menu-icon menu-icon--dumbbell"> <view class="menu-icon menu-icon--dumbbell">
<view class="dumbbell-shape"> <view class="dumbbell-shape">
-6
View File
@@ -109,12 +109,6 @@
{ {
"root": "training", "root": "training",
"pages": [ "pages": [
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "练一练"
}
},
{ {
"path": "pages/grip-ring", "path": "pages/grip-ring",
"style": { "style": {
-213
View File
@@ -1,213 +0,0 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* 用小米 MiMo TTS 生成训练语音素材
*
* 文档: https://platform.xiaomimimo.com/docs/en-US/usage-guide/speech-synthesis-v2.5
* 接口: POST https://api.xiaomimimo.com/v1/chat/completions
*
* 准备:
* export MIMO_API_KEY=sk-your-key-here
* (或 export XIAOMI_API_KEY=...)
*
* 运行:
* cd uniapp
* node scripts/generate-voice.mjs # 默认音色 冰糖
* node scripts/generate-voice.mjs --voice 茉莉
* node scripts/generate-voice.mjs --force # 已存在的也重新生成
*
* 输出(分包):
* training/static/voice/numbers/{1..30}.mp3
* training/static/voice/prompts/{key}.mp3
*
* 需要 Node 18+(用内置 fetch
*/
import { mkdir, writeFile, access, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Buffer } from 'node:buffer'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const ROOT = join(__dirname, '..')
const VOICE_DIR = join(ROOT, 'training/static/voice')
const NUMBERS_DIR = join(VOICE_DIR, 'numbers')
const PROMPTS_DIR = join(VOICE_DIR, 'prompts')
// ============ 参数 ============
const args = process.argv.slice(2)
const FORCE = args.includes('--force')
const VOICE = pickArg('--voice') || '冰糖'
const MODEL = pickArg('--model') || 'mimo-v2.5-tts'
const FORMAT = pickArg('--format') || 'mp3'
const API_KEY =
process.env.MIMO_API_KEY ||
process.env.XIAOMI_API_KEY ||
process.env.XIAOMI_MIMO_API_KEY
const ENDPOINT =
process.env.MIMO_ENDPOINT || 'https://api.xiaomimimo.com/v1/chat/completions'
if (!API_KEY) {
console.error('❌ 未找到 API Key')
console.error(' 请先 export MIMO_API_KEY=your-key')
process.exit(1)
}
function pickArg(name) {
const idx = args.indexOf(name)
if (idx === -1) return null
return args[idx + 1]
}
// ============ 语音内容 ============
// 数字报数:简短的"教练点数"风格
const NUMBER_STYLE =
'用简短有力的健身教练口吻报数,干净利落,节奏明快,每个数字独立清晰。'
// 口令:温柔有鼓励性的女教练
const PROMPT_STYLE =
'用温柔但有力量的女教练口吻说话,语调亲切自然,节奏适中,像在带学员训练。'
const PROMPTS = {
start: '开始',
ready: '准备',
rest: '休息一下',
'next-set': '下一组开始',
'last-rep': '最后一次',
'keep-it-up': '继续坚持',
'good-job': '很棒,训练完成',
'breathe-in': '吸气',
'breathe-out': '呼气',
}
// ============ 工具 ============
async function fileExists(p) {
try {
await access(p)
return true
} catch {
return false
}
}
async function ensureDir(d) {
await mkdir(d, { recursive: true })
}
async function tts(text, outPath, styleInstruction) {
if (!FORCE && (await fileExists(outPath))) {
const s = await stat(outPath)
if (s.size > 0) {
console.log(`[skip] ${outPath} (已存在)`)
return
}
}
const body = {
model: MODEL,
messages: [
{ role: 'user', content: styleInstruction },
{ role: 'assistant', content: text },
],
audio: { format: FORMAT, voice: VOICE },
}
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'api-key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const errText = await res.text().catch(() => '')
throw new Error(`HTTP ${res.status} ${res.statusText}: ${errText.slice(0, 300)}`)
}
const json = await res.json()
const audioBase64 = json?.choices?.[0]?.message?.audio?.data
if (!audioBase64) {
throw new Error(
`响应里找不到 audio.data 字段。完整响应: ${JSON.stringify(json).slice(0, 500)}`,
)
}
const buf = Buffer.from(audioBase64, 'base64')
await writeFile(outPath, buf)
console.log(`[ok] ${outPath} (${buf.length} bytes) → "${text}"`)
}
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
// ============ 主流程 ============
async function main() {
console.log('====== 小米 MiMo TTS 语音生成 ======')
console.log(`Endpoint : ${ENDPOINT}`)
console.log(`Model : ${MODEL}`)
console.log(`Voice : ${VOICE}`)
console.log(`Format : ${FORMAT}`)
console.log(`Force : ${FORCE}`)
console.log(`Output : ${VOICE_DIR}`)
console.log('====================================\n')
await ensureDir(NUMBERS_DIR)
await ensureDir(PROMPTS_DIR)
let successCount = 0
let failCount = 0
console.log('--- 数字 1-30 ---')
for (let n = 1; n <= 30; n++) {
try {
await tts(String(n), join(NUMBERS_DIR, `${n}.${FORMAT}`), NUMBER_STYLE)
successCount++
await sleep(120)
} catch (e) {
failCount++
console.error(`[fail] 数字 ${n}: ${e.message}`)
}
}
console.log('\n--- 口令 ---')
for (const [key, text] of Object.entries(PROMPTS)) {
try {
await tts(text, join(PROMPTS_DIR, `${key}.${FORMAT}`), PROMPT_STYLE)
successCount++
await sleep(120)
} catch (e) {
failCount++
console.error(`[fail] ${key}: ${e.message}`)
}
}
console.log('\n====================================')
console.log(`成功 ${successCount} | 失败 ${failCount}`)
console.log('====================================')
if (FORMAT !== 'mp3') {
console.log(
`\n⚠️ 当前生成格式是 ${FORMAT},但 hooks/useVoiceCoach.ts 默认引用 .mp3。`,
)
console.log(' 要么换 --format mp3 重跑,要么修改 hooks 里的 src 后缀。')
}
console.log('\n下一步:')
console.log(' 1. 准备 click 音 → training/static/audio/click.mp3')
console.log(' 2. 准备 BGM → training/static/bgm/{train-light,rest-meditation}.mp3')
console.log(' 3. 编译运行 → 进入 /training/pages/index')
}
main().catch((e) => {
console.error('\n❌ 致命错误:', e)
process.exit(1)
})
@@ -9,6 +9,7 @@ export interface MetronomeOptions {
accentEvery?: number accentEvery?: number
poolSize?: number poolSize?: number
volume?: number // 节拍音音量 0-1 volume?: number // 节拍音音量 0-1
silent?: boolean // 静音模式:只驱动节拍回调,不发声(如握力环只需视觉节拍)
onBeat?: (beatIndex: number, isAccent: boolean) => void onBeat?: (beatIndex: number, isAccent: boolean) => void
} }
@@ -119,6 +120,7 @@ export function useMetronome(options: MetronomeOptions = {}) {
accentSrc, accentSrc,
poolSize = 4, poolSize = 4,
volume = 1, volume = 1,
silent = false,
onBeat, onBeat,
} = options } = options
@@ -136,6 +138,7 @@ export function useMetronome(options: MetronomeOptions = {}) {
let nextTickAt = 0 let nextTickAt = 0
const ensureAudio = () => { const ensureAudio = () => {
if (silent) return
if (!clickPool) { if (!clickPool) {
clickPool = new AudioPool(clickSrc, poolSize, volume) clickPool = new AudioPool(clickSrc, poolSize, volume)
clickPool.warmUp() clickPool.warmUp()
-32
View File
@@ -1,32 +0,0 @@
/**
* TTS 语音鼓励 Hook
* 用于在捏碎特效触发时播放语音鼓励
*
* 当前状态:预留接口,待集成 MiMo TTS
* TODO: 集成 MiMo TTS SDK
*/
export function useTTS() {
/**
* 播放语音鼓励
* 随机播放:"加油"、"太棒了"、"继续"、"很好"等
*
* 实现计划:
* 1. 引入 MiMo TTS SDK
* 2. 配置语音库(鼓励短语列表)
* 3. 随机选择短语并播放
*/
const playEncouragement = () => {
// TODO: 集成 MiMo TTS
// 示例实现:
// const phrases = ['加油', '太棒了', '继续', '很好', '坚持']
// const phrase = phrases[Math.floor(Math.random() * phrases.length)]
// mimoTTS.speak(phrase)
console.log('[TTS] 播放语音鼓励(待实现 MiMo TTS 集成)')
}
return {
playEncouragement
}
}
@@ -1,115 +0,0 @@
import { ref, onUnmounted } from 'vue'
const BGM_BASE = '/training/static/bgm'
export type BgmMode = 'none' | 'training' | 'resting'
export interface UseTrainingBgmOptions {
trainingSrc?: string
restingSrc?: string
volume?: number
fadeMs?: number
}
export function useTrainingBgm(options: UseTrainingBgmOptions = {}) {
const {
trainingSrc = `${BGM_BASE}/train-light.mp3`,
restingSrc = `${BGM_BASE}/rest-meditation.mp3`,
volume = 0.6,
fadeMs = 600,
} = options
const mode = ref<BgmMode>('none')
const enabled = ref<boolean>(true)
let trainCtx: UniApp.InnerAudioContext | null = null
let restCtx: UniApp.InnerAudioContext | null = null
let fadeTimers: ReturnType<typeof setInterval>[] = []
const ensureCtx = () => {
if (!trainCtx) {
trainCtx = uni.createInnerAudioContext()
trainCtx.src = trainingSrc
trainCtx.loop = true
trainCtx.obeyMuteSwitch = false
trainCtx.volume = 0
}
if (!restCtx) {
restCtx = uni.createInnerAudioContext()
restCtx.src = restingSrc
restCtx.loop = true
restCtx.obeyMuteSwitch = false
restCtx.volume = 0
}
}
const fade = (
ctx: UniApp.InnerAudioContext,
from: number,
to: number,
duration = fadeMs,
) => {
const steps = 16
const stepMs = Math.max(16, duration / steps)
let i = 0
const t = setInterval(() => {
i++
const v = from + (to - from) * (i / steps)
ctx.volume = Math.max(0, Math.min(1, v))
if (i >= steps) {
clearInterval(t)
fadeTimers = fadeTimers.filter((x) => x !== t)
}
}, stepMs)
fadeTimers.push(t)
}
const switchTo = (target: BgmMode) => {
if (!enabled.value) return
if (mode.value === target) return
ensureCtx()
if (target === 'training' && trainCtx && restCtx) {
fade(restCtx, restCtx.volume, 0)
setTimeout(() => restCtx?.pause(), fadeMs)
trainCtx.play()
fade(trainCtx, 0, volume)
} else if (target === 'resting' && trainCtx && restCtx) {
fade(trainCtx, trainCtx.volume, 0)
setTimeout(() => trainCtx?.pause(), fadeMs)
restCtx.play()
fade(restCtx, 0, volume)
} else if (target === 'none') {
if (trainCtx) {
fade(trainCtx, trainCtx.volume, 0)
setTimeout(() => trainCtx?.pause(), fadeMs)
}
if (restCtx) {
fade(restCtx, restCtx.volume, 0)
setTimeout(() => restCtx?.pause(), fadeMs)
}
}
mode.value = target
}
const setEnabled = (v: boolean) => {
enabled.value = v
if (!v) switchTo('none')
}
onUnmounted(() => {
fadeTimers.forEach((t) => clearInterval(t))
fadeTimers = []
trainCtx?.destroy?.()
restCtx?.destroy?.()
trainCtx = null
restCtx = null
})
return {
mode,
enabled,
switchTo,
setEnabled,
}
}
@@ -1,210 +0,0 @@
import { ref, computed, onUnmounted } from 'vue'
import { calculateCalories, getFoodComparison } from '../utils/calorie'
import type { FoodComparison } from '../utils/calorie'
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
export type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
export interface TrainingSessionConfig {
sets: number
reps: number
restSec: number
beatsPerRep?: number
onRepComplete?: (currentRep: number, totalReps: number) => void
onSetComplete?: (currentSet: number, totalSets: number) => void
onEnterRest?: (restSec: number) => void
onExitRest?: () => void
onDone?: () => void
onCountdownTick?: (remainingSec: number) => void
onCrushTrigger?: (itemType: CrushItemType) => void
}
export interface TrainingStats {
totalSets: number
totalReps: number
durationSeconds: number
calories: number
foodComparison: FoodComparison
}
// 捏碎特效配置
const CRUSH_TRIGGER_INTERVAL_MIN = 8 // 最小触发间隔
const CRUSH_TRIGGER_INTERVAL_MAX = 12 // 最大触发间隔
const CRUSH_ITEMS: CrushItemType[] = ['egg', 'walnut', 'can', 'balloon']
// 随机触发判断(平均每 8-12 次触发一次)
function shouldTriggerCrush(): boolean {
// 使用随机间隔的平均值:(8 + 12) / 2 = 10
const avgInterval = (CRUSH_TRIGGER_INTERVAL_MIN + CRUSH_TRIGGER_INTERVAL_MAX) / 2
return Math.random() < (1 / avgInterval)
}
// 随机选择物品
function randomCrushItem(): CrushItemType {
return CRUSH_ITEMS[Math.floor(Math.random() * CRUSH_ITEMS.length)]
}
export function useTrainingSession(bpm?: number) {
const phase = ref<SessionPhase>('idle')
const currentSet = ref<number>(0)
const currentRep = ref<number>(0)
const restRemainingSec = ref<number>(0)
const beatCounter = ref<number>(0)
let config: TrainingSessionConfig | null = null
let restTimer: ReturnType<typeof setInterval> | null = null
let crushTimers: ReturnType<typeof setTimeout>[] = [] // 存储所有捏碎特效的 setTimeout
let currentBpm = bpm ?? 80
let startTime = 0 // 训练开始时间戳
const totalReps = computed(() => config?.reps ?? 0)
const totalSets = computed(() => config?.sets ?? 0)
const beatsPerRep = computed(() => config?.beatsPerRep ?? 2)
const isTraining = computed(() => phase.value === 'training')
const isResting = computed(() => phase.value === 'resting')
const isDone = computed(() => phase.value === 'done')
const setBpm = (newBpm: number) => {
currentBpm = newBpm
}
const start = (cfg: TrainingSessionConfig) => {
config = cfg
phase.value = 'training'
currentSet.value = 1
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
startTime = Date.now() // 记录开始时间
}
// 获取训练统计数据
const getStats = (): TrainingStats => {
const durationSeconds = Math.floor((Date.now() - startTime) / 1000)
const actualTotalReps = (config?.sets ?? 0) * (config?.reps ?? 0)
const calories = calculateCalories({
totalReps: actualTotalReps,
bpm: currentBpm,
userWeight: 60, // 默认 60kg
})
return {
totalSets: config?.sets ?? 0,
totalReps: actualTotalReps,
durationSeconds,
calories,
foodComparison: getFoodComparison(calories),
}
}
const onBeat = () => {
if (phase.value !== 'training' || !config) return
beatCounter.value++
if (beatCounter.value % beatsPerRep.value !== 0) return
currentRep.value++
config.onRepComplete?.(currentRep.value, totalReps.value)
// 随机触发捏碎特效
if (shouldTriggerCrush() && config.onCrushTrigger) {
const item = randomCrushItem()
// 延迟到动画峰值(50%)触发
const repDurationMs = (60000 / currentBpm) * beatsPerRep.value
const timer = setTimeout(() => {
config?.onCrushTrigger?.(item)
}, repDurationMs / 2)
crushTimers.push(timer)
}
if (currentRep.value >= totalReps.value) {
config.onSetComplete?.(currentSet.value, totalSets.value)
if (currentSet.value >= totalSets.value) {
phase.value = 'done'
config.onDone?.()
return
}
enterRest()
}
}
const enterRest = () => {
if (!config) return
phase.value = 'resting'
restRemainingSec.value = config.restSec
config.onEnterRest?.(config.restSec)
restTimer = setInterval(() => {
restRemainingSec.value--
config?.onCountdownTick?.(restRemainingSec.value)
if (restRemainingSec.value <= 0) {
exitRest()
}
}, 1000)
}
const exitRest = () => {
if (restTimer) {
clearInterval(restTimer)
restTimer = null
}
if (!config) return
currentSet.value++
currentRep.value = 0
beatCounter.value = 0
phase.value = 'training'
config.onExitRest?.()
}
const skipRest = () => {
if (phase.value !== 'resting') return
exitRest()
}
const stop = () => {
if (restTimer) {
clearInterval(restTimer)
restTimer = null
}
// 清理所有待触发的捏碎特效
crushTimers.forEach(timer => clearTimeout(timer))
crushTimers = []
phase.value = 'idle'
currentSet.value = 0
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
}
onUnmounted(() => {
if (restTimer) clearInterval(restTimer)
crushTimers.forEach(timer => clearTimeout(timer))
crushTimers = []
})
return {
phase,
currentSet,
currentRep,
restRemainingSec,
totalReps,
totalSets,
isTraining,
isResting,
isDone,
start,
stop,
skipRest,
onBeat,
setBpm,
getStats,
}
}
@@ -1,87 +0,0 @@
import { onUnmounted } from 'vue'
const VOICE_BASE = '/training/static/voice'
export type VoicePromptKey =
| 'start'
| 'ready'
| 'rest'
| 'next-set'
| 'last-rep'
| 'keep-it-up'
| 'good-job'
| 'breathe-in'
| 'breathe-out'
export interface UseVoiceCoachOptions {
enabled?: boolean
volume?: number
}
export function useVoiceCoach(options: UseVoiceCoachOptions = {}) {
const { enabled = true, volume = 1 } = options
let ctx: UniApp.InnerAudioContext | null = null
let queue: string[] = []
let isPlayingQueue = false
const ensureCtx = () => {
if (ctx) return
ctx = uni.createInnerAudioContext()
ctx.volume = volume
ctx.obeyMuteSwitch = false
ctx.onEnded(() => {
playNextInQueue()
})
ctx.onError(() => {
playNextInQueue()
})
}
const playNextInQueue = () => {
if (!ctx || queue.length === 0) {
isPlayingQueue = false
return
}
const nextSrc = queue.shift()!
ctx.src = nextSrc
ctx.play()
}
const enqueue = (src: string) => {
if (!enabled) return
ensureCtx()
queue.push(src)
if (!isPlayingQueue) {
isPlayingQueue = true
playNextInQueue()
}
}
const speakNumber = (n: number) => {
if (n < 1 || n > 30) return
enqueue(`${VOICE_BASE}/numbers/${n}.mp3`)
}
const speakPrompt = (key: VoicePromptKey) => {
enqueue(`${VOICE_BASE}/prompts/${key}.mp3`)
}
const stop = () => {
queue = []
isPlayingQueue = false
ctx?.stop()
}
onUnmounted(() => {
stop()
ctx?.destroy?.()
ctx = null
})
return {
speakNumber,
speakPrompt,
stop,
}
}
@@ -1,280 +0,0 @@
<template>
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
<view class="stage">
<!-- 哑铃弯举摆臂 -->
<view v-if="animationType === 'curl'" class="curl-arm">
<view class="curl-forearm">
<view class="curl-dumbbell">{{ icon }}</view>
</view>
</view>
<!-- 哑铃推举上下移动 -->
<view v-else-if="animationType === 'press'" class="press-wrap">
<view class="press-dumbbell left">{{ icon }}</view>
<view class="press-dumbbell right">{{ icon }}</view>
</view>
<!-- 哑铃侧平举双臂张开 -->
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
<view class="sidefly-arm sidefly-left">
<view class="sidefly-dumbbell">{{ icon }}</view>
</view>
<view class="sidefly-arm sidefly-right">
<view class="sidefly-dumbbell">{{ icon }}</view>
</view>
</view>
<!-- 握力环缩放 + 粒子特效 -->
<view v-else-if="animationType === 'grip'" class="grip-container">
<view class="grip-ring">
<view class="grip-ring-inner">{{ icon }}</view>
</view>
<CrushCanvas ref="crushCanvasRef" />
</view>
<!-- 卷腹身体折叠 -->
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
<view class="crunch-body">{{ icon }}</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { AnimationType } from '../exercises'
import CrushCanvas from './crush-canvas.vue'
interface Props {
animationType: AnimationType
bpm: number
beatsPerRep?: number
icon?: string
isPlaying?: boolean
}
const props = withDefaults(defineProps<Props>(), {
beatsPerRep: 2,
icon: '🏋',
isPlaying: false,
})
const cssVars = computed(() => {
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
return {
'--rep-duration': `${repDurationMs}ms`,
'--anim-state': props.isPlaying ? 'running' : 'paused',
} as Record<string, string>
})
const crushCanvasRef = ref()
// 暴露捏碎特效触发方法(供外部测试)
const triggerCrushEffect = (itemType: 'egg' | 'walnut' | 'can' | 'balloon') => {
crushCanvasRef.value?.triggerCrush(itemType)
}
defineExpose({
triggerCrushEffect
})
</script>
<style lang="scss" scoped>
.exercise-anim {
width: 100%;
height: 480rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
border-radius: 24rpx;
overflow: hidden;
}
.stage {
width: 320rpx;
height: 320rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* ===== 弯举 ===== */
.curl-arm {
width: 60rpx;
height: 240rpx;
background: #fbbf24;
border-radius: 30rpx;
position: relative;
.curl-forearm {
width: 60rpx;
height: 200rpx;
background: #f59e0b;
border-radius: 30rpx;
position: absolute;
bottom: 0;
left: 0;
transform-origin: bottom center;
animation: curl-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
}
.curl-dumbbell {
position: absolute;
top: -20rpx;
left: 50%;
transform: translateX(-50%);
font-size: 56rpx;
}
}
@keyframes curl-anim {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(-115deg);
}
}
/* ===== 推举 ===== */
.press-wrap {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0 20rpx;
}
.press-dumbbell {
font-size: 88rpx;
animation: press-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
}
@keyframes press-anim {
0%,
100% {
transform: translateY(80rpx);
}
50% {
transform: translateY(-80rpx);
}
}
/* ===== 侧平举 ===== */
.sidefly-wrap {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.sidefly-arm {
position: absolute;
width: 140rpx;
height: 24rpx;
background: #f59e0b;
border-radius: 12rpx;
top: 50%;
transform-origin: center right;
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
&.sidefly-left {
right: 50%;
}
&.sidefly-right {
left: 50%;
transform-origin: center left;
animation-name: sidefly-right;
}
}
.sidefly-dumbbell {
position: absolute;
top: -36rpx;
font-size: 56rpx;
}
.sidefly-left .sidefly-dumbbell {
left: -20rpx;
}
.sidefly-right .sidefly-dumbbell {
right: -20rpx;
}
@keyframes sidefly-left {
0%,
100% {
transform: rotate(80deg);
}
50% {
transform: rotate(0deg);
}
}
@keyframes sidefly-right {
0%,
100% {
transform: rotate(-80deg);
}
50% {
transform: rotate(0deg);
}
}
/* ===== 握力环 ===== */
.grip-container {
position: relative;
width: 240rpx;
height: 240rpx;
}
.grip-ring {
width: 240rpx;
height: 240rpx;
animation: grip-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
border: 16rpx solid #22c55e;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(34, 197, 94, 0.1);
}
.grip-ring-inner {
font-size: 80rpx;
}
@keyframes grip-anim {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(0.65);
}
}
/* ===== 卷腹 ===== */
.crunch-wrap {
width: 280rpx;
height: 280rpx;
display: flex;
align-items: flex-end;
justify-content: center;
}
.crunch-body {
font-size: 120rpx;
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
transform-origin: bottom center;
}
@keyframes crunch-anim {
0%,
100% {
transform: scaleY(1) translateY(0);
}
50% {
transform: scaleY(0.6) translateY(-10rpx);
}
}
</style>
+70 -95
View File
@@ -72,26 +72,9 @@
<!-- ===== 控制面板统计 + 档位 + 目标 ===== --> <!-- ===== 控制面板统计 + 档位 + 目标 ===== -->
<view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }"> <view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }">
<view class="stats-row">
<view class="stat-chip">
<text class="stat-chip-label">次数</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? totalReps : '—' }}</text>
</view>
<view class="stat-chip">
<text class="stat-chip-label">时长</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? formatTime(elapsedSeconds) : '—' }}</text>
</view>
<view class="stat-chip">
<text class="stat-chip-label">糖分</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `${totalSugar.toFixed(1)}g` : '—' }}</text>
</view>
</view>
<view class="deck-divider" />
<view class="presets-block"> <view class="presets-block">
<view class="presets-header"> <view class="presets-header">
<text class="presets-title">训练强度</text> <text class="presets-title">选择训练强度</text>
<text v-if="isPlaying" class="presets-hint">训练中不可切换</text> <text v-if="isPlaying" class="presets-hint">训练中不可切换</text>
</view> </view>
<view class="presets"> <view class="presets">
@@ -102,26 +85,24 @@
:class="{ active: currentPreset === p.id, disabled: isPlaying }" :class="{ active: currentPreset === p.id, disabled: isPlaying }"
@click="onPresetTap(p.id)" @click="onPresetTap(p.id)"
> >
<text class="preset-icon">{{ PRESET_ICONS[p.id] }}</text>
<text class="preset-name">{{ p.label }}</text> <text class="preset-name">{{ p.label }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text> <text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ p.desc }}</text>
</view> </view>
</view> </view>
<view class="intensity-estimate">
<text class="estimate-icon">🔥</text>
<text class="estimate-text">{{ estimateText }}</text>
</view>
</view> </view>
<view class="deck-divider" /> <view class="deck-divider" />
<!-- 训练时长(默认折叠,点击展开) -->
<view class="target-block"> <view class="target-block">
<view class="target-header"> <view class="target-header" @click="targetExpanded = !targetExpanded">
<text class="target-title">训练目标</text> <text class="target-title">训练时长</text>
<text class="target-hint">到时间自动结束</text> <view class="target-current">
<text class="target-current-text">{{ currentTargetLabel }}</text>
<text class="target-toggle">{{ targetExpanded ? '▲' : '▼' }}</text>
</view> </view>
<view class="target-chips"> </view>
<view v-if="targetExpanded" class="target-chips">
<view <view
v-for="t in TARGET_OPTIONS" v-for="t in TARGET_OPTIONS"
:key="t.minutes" :key="t.minutes"
@@ -134,10 +115,9 @@
</view> </view>
</view> </view>
<!-- 仅暂停时显示一行提示 + 结束训练文字链 -->
<view class="deck-divider" /> <view class="deck-divider" />
<!-- 训练功效介绍(可折叠) --> <!-- 训练功效介绍(可折叠,默认收起) -->
<view class="benefit-block"> <view class="benefit-block">
<view class="benefit-header" @click="benefitExpanded = !benefitExpanded"> <view class="benefit-header" @click="benefitExpanded = !benefitExpanded">
<text class="benefit-title">训练功效</text> <text class="benefit-title">训练功效</text>
@@ -264,21 +244,6 @@ const DUMBBELL_PRESETS: readonly DumbbellPreset[] = [
{ id: 'heavy', bpm: 80, met: 6.5, label: '重度', desc: '强化 · 挑战' }, { id: 'heavy', bpm: 80, met: 6.5, label: '重度', desc: '强化 · 挑战' },
] ]
// 训练功效介绍(专业文案)
const TRAINING_BENEFIT = {
muscles: ['肱二头肌', '三角肌', '胸大肌', '背阔肌'],
effect: '多角度抗阻训练上肢与肩背肌群,提升肌肉力量与耐力,雕塑手臂与肩部线条,增强骨密度,改善上肢日常功能。',
}
// 强度预估参考时长(选择"自由"目标时按此估算)
const ESTIMATE_REF_MINUTES = 10
const PRESET_ICONS: Record<PresetId, string> = {
light: '🌱',
medium: '💪',
heavy: '🔥',
}
// 画布指导视频 // 画布指导视频
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/202605281739117bf3c2914.mp4' const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/202605281739117bf3c2914.mp4'
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/202605291142269212d9402.jpg' const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/202605291142269212d9402.jpg'
@@ -349,16 +314,21 @@ const timerInterval = ref<number | null>(null)
const preset = computed(() => DUMBBELL_PRESETS.find(p => p.id === currentPreset.value) || DUMBBELL_PRESETS[1]) const preset = computed(() => DUMBBELL_PRESETS.find(p => p.id === currentPreset.value) || DUMBBELL_PRESETS[1])
const currentBpm = computed(() => preset.value.bpm) const currentBpm = computed(() => preset.value.bpm)
const currentMet = computed(() => preset.value.met) const currentMet = computed(() => preset.value.met)
const benefitExpanded = ref(false)
// 当前强度预估消耗文案: 按选中目标时长(自由→10分钟参考)估算 // 训练时长折叠状态 + 当前时长文案
const estimateText = computed(() => { const targetExpanded = ref(false)
const mins = targetMinutes.value > 0 ? targetMinutes.value : ESTIMATE_REF_MINUTES const currentTargetLabel = computed(() => {
const sugar = (currentMet.value * 60 * (mins / 60)) / 4 const opt = TARGET_OPTIONS.find(t => t.minutes === targetMinutes.value)
const suffix = targetMinutes.value > 0 ? '' : '(按 10 分钟估算)' return opt ? opt.label : '自由'
return `${preset.value.label}训练 ${mins} 分钟,约消耗 ${sugar.toFixed(1)}g 糖分${suffix}`
}) })
// 训练功效介绍(专业文案) + 折叠状态
const benefitExpanded = ref(false)
const TRAINING_BENEFIT = {
muscles: ['肱二头肌', '三角肌', '胸大肌', '背阔肌'],
effect: '多角度抗阻训练上肢与肩背肌群,提升肌肉力量与耐力,雕塑手臂与肩部线条,增强骨密度,改善上肢日常功能。',
}
// 示范视频跟随训练状态 // 示范视频跟随训练状态
watch(isPlaying, (playing) => { watch(isPlaying, (playing) => {
const videoCtx = uni.createVideoContext('dumbbellDemoVideo') const videoCtx = uni.createVideoContext('dumbbellDemoVideo')
@@ -826,7 +796,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
margin-left: -28rpx; margin-left: -28rpx;
margin-right: -28rpx; margin-right: -28rpx;
flex-shrink: 0; flex-shrink: 0;
height: 520rpx; height: 740rpx;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -859,8 +829,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.ring-container { .ring-container {
position: relative; position: relative;
width: 560rpx; width: 720rpx;
height: 560rpx; height: 720rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -869,8 +839,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.beat-halo { .beat-halo {
position: absolute; position: absolute;
width: 560rpx; width: 720rpx;
height: 560rpx; height: 720rpx;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, background: radial-gradient(circle,
rgba(59, 130, 246, 0.45) 0%, rgba(59, 130, 246, 0.45) 0%,
@@ -899,8 +869,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.video-ring { .video-ring {
position: relative; position: relative;
width: 460rpx; width: 640rpx;
height: 460rpx; height: 640rpx;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
box-shadow: box-shadow:
@@ -964,8 +934,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.hint-icon { .hint-icon {
width: 116rpx; width: 140rpx;
height: 116rpx; height: 140rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -981,10 +951,10 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.play-triangle { .play-triangle {
width: 0; width: 0;
height: 0; height: 0;
border-left: 34rpx solid #ffffff; border-left: 44rpx solid #ffffff;
border-top: 23rpx solid transparent; border-top: 30rpx solid transparent;
border-bottom: 23rpx solid transparent; border-bottom: 30rpx solid transparent;
margin-left: 8rpx; margin-left: 10rpx;
} }
@keyframes hint-bounce { @keyframes hint-bounce {
@@ -997,7 +967,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.hint-text { .hint-text {
font-size: 26rpx; font-size: 34rpx;
color: #ffffff; color: #ffffff;
font-weight: 700; font-weight: 700;
letter-spacing: 3rpx; letter-spacing: 3rpx;
@@ -1182,8 +1152,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.presets-title { .presets-title {
font-size: 26rpx; font-size: 32rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
@@ -1200,38 +1170,26 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.preset { .preset {
flex: 1; flex: 1;
background: #f8fafc; background: #f8fafc;
border: 2rpx solid transparent; border: 3rpx solid transparent;
border-radius: 20rpx; border-radius: 24rpx;
padding: 20rpx 6rpx 18rpx; padding: 32rpx 8rpx 28rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4rpx; gap: 10rpx;
transition: all 0.18s; transition: all 0.18s;
.preset-icon {
font-size: 32rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.preset-name { .preset-name {
font-size: 26rpx; font-size: 40rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
.preset-bpm { .preset-bpm {
font-size: 22rpx; font-size: 26rpx;
color: $text-2; color: $text-2;
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.preset-desc {
font-size: 18rpx;
color: $text-3;
text-align: center;
line-height: 1.3;
}
&:active { &:active {
transform: scale(0.97); transform: scale(0.97);
@@ -1270,17 +1228,34 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 4rpx; padding: 12rpx 6rpx;
border-radius: 16rpx;
&:active {
background: #f8fafc;
}
} }
.target-title { .target-title {
font-size: 26rpx; font-size: 32rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
.target-hint { .target-current {
font-size: 20rpx; display: flex;
align-items: center;
gap: 12rpx;
}
.target-current-text {
font-size: 30rpx;
font-weight: 700;
color: $brand-deep;
}
.target-toggle {
font-size: 22rpx;
color: $text-3; color: $text-3;
} }
@@ -1291,7 +1266,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.target-chip { .target-chip {
flex: 1; flex: 1;
height: 72rpx; height: 88rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1301,8 +1276,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
transition: all 0.18s; transition: all 0.18s;
.target-chip-text { .target-chip-text {
font-size: 24rpx; font-size: 30rpx;
font-weight: 600; font-weight: 700;
color: $text-2; color: $text-2;
} }
@@ -1,99 +0,0 @@
export type AnimationType =
| 'curl'
| 'press'
| 'sidefly'
| 'crunch'
export interface ExercisePreset {
id: string
name: string
equipment: '哑铃' | '健身环' | '徒手'
icon: string
animationType: AnimationType
description: string
tips: string[]
contraindication?: string
defaultBpm: number
bpmMin: number
bpmMax: number
defaultSets: number
defaultReps: number
defaultRestSec: number
beatsPerRep: number
}
export const EXERCISE_PRESETS: ExercisePreset[] = [
{
id: 'dumbbell-curl',
name: '哑铃弯举',
equipment: '哑铃',
icon: '🏋',
animationType: 'curl',
description: '锻炼肱二头肌,注意肘部固定不动',
tips: ['肘部贴紧身体', '上举吸气,下落呼气', '动作放慢,控制重量'],
contraindication: '肘关节炎症急性期不宜',
defaultBpm: 60,
bpmMin: 40,
bpmMax: 100,
defaultSets: 3,
defaultReps: 12,
defaultRestSec: 60,
beatsPerRep: 2,
},
{
id: 'dumbbell-press',
name: '哑铃推举',
equipment: '哑铃',
icon: '🏋',
animationType: 'press',
description: '锻炼肩部三角肌,核心保持收紧',
tips: ['不要含胸驼背', '推举时呼气', '不要锁死肘关节'],
contraindication: '肩袖损伤者请咨询医生',
defaultBpm: 60,
bpmMin: 40,
bpmMax: 90,
defaultSets: 3,
defaultReps: 10,
defaultRestSec: 90,
beatsPerRep: 2,
},
{
id: 'dumbbell-sidefly',
name: '哑铃侧平举',
equipment: '哑铃',
icon: '🏋',
animationType: 'sidefly',
description: '锻炼三角肌中束,重量宜轻不宜重',
tips: ['手肘微屈', '抬至与肩平齐即可', '想象用肩膀发力,不是手臂'],
defaultBpm: 60,
bpmMin: 40,
bpmMax: 80,
defaultSets: 3,
defaultReps: 12,
defaultRestSec: 60,
beatsPerRep: 2,
},
{
id: 'crunch',
name: '集中机(仰卧卷腹)',
equipment: '健身环',
icon: '💪',
animationType: 'crunch',
description: '锻炼腹直肌,借助健身环增加阻力',
tips: ['下背贴地', '用腹部发力,不是脖子', '上抬呼气,下落吸气'],
contraindication: '腰椎间盘突出急性期不宜',
defaultBpm: 50,
bpmMin: 40,
bpmMax: 80,
defaultSets: 3,
defaultReps: 15,
defaultRestSec: 60,
beatsPerRep: 2,
},
]
export function getPresetById(id: string): ExercisePreset | undefined {
return EXERCISE_PRESETS.find((e) => e.id === id)
}
+70 -95
View File
@@ -72,26 +72,9 @@
<!-- ===== 控制面板统计 + 档位 + 目标 ===== --> <!-- ===== 控制面板统计 + 档位 + 目标 ===== -->
<view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }"> <view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }">
<view class="stats-row">
<view class="stat-chip">
<text class="stat-chip-label">次数</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? totalReps : '—' }}</text>
</view>
<view class="stat-chip">
<text class="stat-chip-label">时长</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? formatTime(elapsedSeconds) : '—' }}</text>
</view>
<view class="stat-chip">
<text class="stat-chip-label">糖分</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `${totalSugar.toFixed(1)}g` : '—' }}</text>
</view>
</view>
<view class="deck-divider" />
<view class="presets-block"> <view class="presets-block">
<view class="presets-header"> <view class="presets-header">
<text class="presets-title">训练强度</text> <text class="presets-title">选择训练强度</text>
<text v-if="isPlaying" class="presets-hint">训练中不可切换</text> <text v-if="isPlaying" class="presets-hint">训练中不可切换</text>
</view> </view>
<view class="presets"> <view class="presets">
@@ -102,26 +85,24 @@
:class="{ active: currentPreset === p.id, disabled: isPlaying }" :class="{ active: currentPreset === p.id, disabled: isPlaying }"
@click="onPresetTap(p.id)" @click="onPresetTap(p.id)"
> >
<text class="preset-icon">{{ PRESET_ICONS[p.id] }}</text>
<text class="preset-name">{{ p.label }}</text> <text class="preset-name">{{ p.label }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text> <text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ p.desc }}</text>
</view> </view>
</view> </view>
<view class="intensity-estimate">
<text class="estimate-icon">🔥</text>
<text class="estimate-text">{{ estimateText }}</text>
</view>
</view> </view>
<view class="deck-divider" /> <view class="deck-divider" />
<!-- 训练时长(默认折叠,点击展开) -->
<view class="target-block"> <view class="target-block">
<view class="target-header"> <view class="target-header" @click="targetExpanded = !targetExpanded">
<text class="target-title">训练目标</text> <text class="target-title">训练时长</text>
<text class="target-hint">到时间自动结束</text> <view class="target-current">
<text class="target-current-text">{{ currentTargetLabel }}</text>
<text class="target-toggle">{{ targetExpanded ? '▲' : '▼' }}</text>
</view> </view>
<view class="target-chips"> </view>
<view v-if="targetExpanded" class="target-chips">
<view <view
v-for="t in TARGET_OPTIONS" v-for="t in TARGET_OPTIONS"
:key="t.minutes" :key="t.minutes"
@@ -134,10 +115,9 @@
</view> </view>
</view> </view>
<!-- 仅暂停时显示一行提示 + 结束训练文字链 -->
<view class="deck-divider" /> <view class="deck-divider" />
<!-- 训练功效介绍(可折叠) --> <!-- 训练功效介绍(可折叠,默认收起) -->
<view class="benefit-block"> <view class="benefit-block">
<view class="benefit-header" @click="benefitExpanded = !benefitExpanded"> <view class="benefit-header" @click="benefitExpanded = !benefitExpanded">
<text class="benefit-title">训练功效</text> <text class="benefit-title">训练功效</text>
@@ -264,21 +244,6 @@ const FOOT_PEDAL_PRESETS: readonly FootPedalPreset[] = [
{ id: 'heavy', bpm: 85, met: 7.0, label: '重度', desc: '强化 · 挑战' }, { id: 'heavy', bpm: 85, met: 7.0, label: '重度', desc: '强化 · 挑战' },
] ]
// 训练功效介绍(专业文案)
const TRAINING_BENEFIT = {
muscles: ['股四头肌', '腘绳肌', '小腿肌群', '臀大肌'],
effect: '低冲击有氧蹬踏,强化下肢肌力与心肺耐力,促进腿部血液循环、帮助燃脂塑形,特别适合久坐人群激活双腿。',
}
// 强度预估参考时长(选择"自由"目标时按此估算)
const ESTIMATE_REF_MINUTES = 10
const PRESET_ICONS: Record<PresetId, string> = {
light: '🌱',
medium: '💪',
heavy: '🔥',
}
// 画布指导视频 // 画布指导视频
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/20260528173911fd8002487.mp4' const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/20260528173911fd8002487.mp4'
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/2026052911251788eb49233.jpg' const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/2026052911251788eb49233.jpg'
@@ -349,16 +314,21 @@ const timerInterval = ref<number | null>(null)
const preset = computed(() => FOOT_PEDAL_PRESETS.find(p => p.id === currentPreset.value) || FOOT_PEDAL_PRESETS[1]) const preset = computed(() => FOOT_PEDAL_PRESETS.find(p => p.id === currentPreset.value) || FOOT_PEDAL_PRESETS[1])
const currentBpm = computed(() => preset.value.bpm) const currentBpm = computed(() => preset.value.bpm)
const currentMet = computed(() => preset.value.met) const currentMet = computed(() => preset.value.met)
const benefitExpanded = ref(false)
// 当前强度预估消耗文案: 按选中目标时长(自由→10分钟参考)估算 // 训练时长折叠状态 + 当前时长文案
const estimateText = computed(() => { const targetExpanded = ref(false)
const mins = targetMinutes.value > 0 ? targetMinutes.value : ESTIMATE_REF_MINUTES const currentTargetLabel = computed(() => {
const sugar = (currentMet.value * 60 * (mins / 60)) / 4 const opt = TARGET_OPTIONS.find(t => t.minutes === targetMinutes.value)
const suffix = targetMinutes.value > 0 ? '' : '(按 10 分钟估算)' return opt ? opt.label : '自由'
return `${preset.value.label}训练 ${mins} 分钟,约消耗 ${sugar.toFixed(1)}g 糖分${suffix}`
}) })
// 训练功效介绍(专业文案) + 折叠状态
const benefitExpanded = ref(false)
const TRAINING_BENEFIT = {
muscles: ['股四头肌', '腘绳肌', '小腿肌群', '臀大肌'],
effect: '低冲击有氧蹬踏,强化下肢肌力与心肺耐力,促进腿部血液循环、帮助燃脂塑形,特别适合久坐人群激活双腿。',
}
// 示范视频跟随训练状态 // 示范视频跟随训练状态
watch(isPlaying, (playing) => { watch(isPlaying, (playing) => {
const videoCtx = uni.createVideoContext('footPedalDemoVideo') const videoCtx = uni.createVideoContext('footPedalDemoVideo')
@@ -826,7 +796,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
margin-left: -28rpx; margin-left: -28rpx;
margin-right: -28rpx; margin-right: -28rpx;
flex-shrink: 0; flex-shrink: 0;
height: 520rpx; height: 740rpx;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -859,8 +829,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.ring-container { .ring-container {
position: relative; position: relative;
width: 560rpx; width: 720rpx;
height: 560rpx; height: 720rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -869,8 +839,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.beat-halo { .beat-halo {
position: absolute; position: absolute;
width: 560rpx; width: 720rpx;
height: 560rpx; height: 720rpx;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, background: radial-gradient(circle,
rgba(249, 115, 22, 0.45) 0%, rgba(249, 115, 22, 0.45) 0%,
@@ -899,8 +869,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.video-ring { .video-ring {
position: relative; position: relative;
width: 460rpx; width: 640rpx;
height: 460rpx; height: 640rpx;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
box-shadow: box-shadow:
@@ -964,8 +934,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.hint-icon { .hint-icon {
width: 116rpx; width: 140rpx;
height: 116rpx; height: 140rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -981,10 +951,10 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.play-triangle { .play-triangle {
width: 0; width: 0;
height: 0; height: 0;
border-left: 34rpx solid #ffffff; border-left: 44rpx solid #ffffff;
border-top: 23rpx solid transparent; border-top: 30rpx solid transparent;
border-bottom: 23rpx solid transparent; border-bottom: 30rpx solid transparent;
margin-left: 8rpx; margin-left: 10rpx;
} }
@keyframes hint-bounce { @keyframes hint-bounce {
@@ -997,7 +967,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.hint-text { .hint-text {
font-size: 26rpx; font-size: 34rpx;
color: #ffffff; color: #ffffff;
font-weight: 700; font-weight: 700;
letter-spacing: 3rpx; letter-spacing: 3rpx;
@@ -1182,8 +1152,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.presets-title { .presets-title {
font-size: 26rpx; font-size: 32rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
@@ -1200,38 +1170,26 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.preset { .preset {
flex: 1; flex: 1;
background: #f8fafc; background: #f8fafc;
border: 2rpx solid transparent; border: 3rpx solid transparent;
border-radius: 20rpx; border-radius: 24rpx;
padding: 20rpx 6rpx 18rpx; padding: 32rpx 8rpx 28rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4rpx; gap: 10rpx;
transition: all 0.18s; transition: all 0.18s;
.preset-icon {
font-size: 32rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.preset-name { .preset-name {
font-size: 26rpx; font-size: 40rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
.preset-bpm { .preset-bpm {
font-size: 22rpx; font-size: 26rpx;
color: $text-2; color: $text-2;
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.preset-desc {
font-size: 18rpx;
color: $text-3;
text-align: center;
line-height: 1.3;
}
&:active { &:active {
transform: scale(0.97); transform: scale(0.97);
@@ -1270,17 +1228,34 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 4rpx; padding: 12rpx 6rpx;
border-radius: 16rpx;
&:active {
background: #f8fafc;
}
} }
.target-title { .target-title {
font-size: 26rpx; font-size: 32rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
.target-hint { .target-current {
font-size: 20rpx; display: flex;
align-items: center;
gap: 12rpx;
}
.target-current-text {
font-size: 30rpx;
font-weight: 700;
color: $brand-deep;
}
.target-toggle {
font-size: 22rpx;
color: $text-3; color: $text-3;
} }
@@ -1291,7 +1266,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.target-chip { .target-chip {
flex: 1; flex: 1;
height: 72rpx; height: 88rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1301,8 +1276,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
transition: all 0.18s; transition: all 0.18s;
.target-chip-text { .target-chip-text {
font-size: 24rpx; font-size: 30rpx;
font-weight: 600; font-weight: 700;
color: $text-2; color: $text-2;
} }
+72 -97
View File
@@ -74,28 +74,11 @@
</view> </view>
</view> </view>
<!-- ===== 控制面板统计 + 档位 + 目标 ===== --> <!-- ===== 控制面板训练强度() + 训练时长(折叠) ===== -->
<view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }"> <view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }">
<view class="stats-row">
<view class="stat-chip">
<text class="stat-chip-label">次数</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? totalReps : '—' }}</text>
</view>
<view class="stat-chip">
<text class="stat-chip-label">时长</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? formatTime(elapsedSeconds) : '—' }}</text>
</view>
<view class="stat-chip">
<text class="stat-chip-label">糖分</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `${totalSugar.toFixed(1)}g` : '—' }}</text>
</view>
</view>
<view class="deck-divider" />
<view class="presets-block"> <view class="presets-block">
<view class="presets-header"> <view class="presets-header">
<text class="presets-title">训练强度</text> <text class="presets-title">选择训练强度</text>
<text v-if="isPlaying" class="presets-hint">训练中不可切换</text> <text v-if="isPlaying" class="presets-hint">训练中不可切换</text>
</view> </view>
<view class="presets"> <view class="presets">
@@ -106,27 +89,24 @@
:class="{ active: currentPreset === p.id, disabled: isPlaying }" :class="{ active: currentPreset === p.id, disabled: isPlaying }"
@click="onPresetTap(p.id)" @click="onPresetTap(p.id)"
> >
<text class="preset-icon">{{ PRESET_ICONS[p.id] }}</text>
<text class="preset-name">{{ p.label }}</text> <text class="preset-name">{{ p.label }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text> <text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ p.desc }}</text>
</view> </view>
</view> </view>
<!-- 当前强度预估消耗 -->
<view class="intensity-estimate">
<text class="estimate-icon">🔥</text>
<text class="estimate-text">{{ estimateText }}</text>
</view>
</view> </view>
<view class="deck-divider" /> <view class="deck-divider" />
<!-- 训练时长(默认折叠,点击展开) -->
<view class="target-block"> <view class="target-block">
<view class="target-header"> <view class="target-header" @click="targetExpanded = !targetExpanded">
<text class="target-title">训练目标</text> <text class="target-title">训练时长</text>
<text class="target-hint">到时间自动结束</text> <view class="target-current">
<text class="target-current-text">{{ currentTargetLabel }}</text>
<text class="target-toggle">{{ targetExpanded ? '▲' : '▼' }}</text>
</view> </view>
<view class="target-chips"> </view>
<view v-if="targetExpanded" class="target-chips">
<view <view
v-for="t in TARGET_OPTIONS" v-for="t in TARGET_OPTIONS"
:key="t.minutes" :key="t.minutes"
@@ -141,7 +121,7 @@
<view class="deck-divider" /> <view class="deck-divider" />
<!-- 训练功效介绍(可折叠) --> <!-- 训练功效介绍(可折叠,默认收起) -->
<view class="benefit-block"> <view class="benefit-block">
<view class="benefit-header" @click="benefitExpanded = !benefitExpanded"> <view class="benefit-header" @click="benefitExpanded = !benefitExpanded">
<text class="benefit-title">训练功效</text> <text class="benefit-title">训练功效</text>
@@ -270,21 +250,6 @@ const GRIP_PRESETS: readonly GripPreset[] = [
{ id: 'heavy', bpm: 100, met: 4.5, label: '重度', desc: '强化 · 挑战' }, { id: 'heavy', bpm: 100, met: 4.5, label: '重度', desc: '强化 · 挑战' },
] ]
// 训练功效介绍(专业文案)
const TRAINING_BENEFIT = {
muscles: ['前臂屈肌', '手部小肌群', '握力'],
effect: '增强握力与前臂肌肉耐力,促进手部血液循环,缓解手指与腕部僵硬,适合日常碎片化锻炼及手部功能康复。',
}
// 强度预估参考时长(选择"自由"目标时按此估算)
const ESTIMATE_REF_MINUTES = 10
const PRESET_ICONS: Record<PresetId, string> = {
light: '🌱',
medium: '💪',
heavy: '🔥',
}
// 画布指导视频(跟随训练状态播放) // 画布指导视频(跟随训练状态播放)
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/20260528173911bf6f93386.mp4' const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/20260528173911bf6f93386.mp4'
// 视频封面(未开始/暂停时显示的静态首帧) // 视频封面(未开始/暂停时显示的静态首帧)
@@ -391,20 +356,25 @@ const timerInterval = ref<number | null>(null)
const preset = computed(() => GRIP_PRESETS.find(p => p.id === currentPreset.value) || GRIP_PRESETS[1]) const preset = computed(() => GRIP_PRESETS.find(p => p.id === currentPreset.value) || GRIP_PRESETS[1])
const currentBpm = computed(() => preset.value.bpm) const currentBpm = computed(() => preset.value.bpm)
const currentMet = computed(() => preset.value.met) const currentMet = computed(() => preset.value.met)
const benefitExpanded = ref(false)
// 当前强度预估消耗文案: 按选中目标时长(自由→10分钟参考)估算 // 训练时长折叠状态 + 当前时长文案
const estimateText = computed(() => { const targetExpanded = ref(false)
const mins = targetMinutes.value > 0 ? targetMinutes.value : ESTIMATE_REF_MINUTES const currentTargetLabel = computed(() => {
const sugar = (currentMet.value * 60 * (mins / 60)) / 4 const opt = TARGET_OPTIONS.find(t => t.minutes === targetMinutes.value)
const suffix = targetMinutes.value > 0 ? '' : '(按 10 分钟估算)' return opt ? opt.label : '自由'
return `${preset.value.label}训练 ${mins} 分钟,约消耗 ${sugar.toFixed(1)}g 糖分${suffix}`
}) })
// 训练功效介绍(专业文案) + 折叠状态
const benefitExpanded = ref(false)
const TRAINING_BENEFIT = {
muscles: ['前臂屈肌', '手部小肌群', '握力'],
effect: '增强握力与前臂肌肉耐力,促进手部血液循环,缓解手指与腕部僵硬,适合日常碎片化锻炼及手部功能康复。',
}
const metronome = useMetronome({ const metronome = useMetronome({
initialBpm: currentBpm.value, initialBpm: currentBpm.value,
accentEvery: 2, accentEvery: 2,
volume: 0.35, // 节拍音音量调小,避免木鱼声太大 silent: true, // 移除木鱼节奏音,仅保留节拍驱动(视觉缩放/次数统计)
onBeat: onBeat, onBeat: onBeat,
}) })
const { isPlaying } = metronome const { isPlaying } = metronome
@@ -905,7 +875,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
margin-left: -28rpx; margin-left: -28rpx;
margin-right: -28rpx; margin-right: -28rpx;
flex-shrink: 0; flex-shrink: 0;
height: 520rpx; height: 740rpx;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -938,8 +908,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.ring-container { .ring-container {
position: relative; position: relative;
width: 560rpx; width: 720rpx;
height: 560rpx; height: 720rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -948,8 +918,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.beat-halo { .beat-halo {
position: absolute; position: absolute;
width: 560rpx; width: 720rpx;
height: 560rpx; height: 720rpx;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, background: radial-gradient(circle,
rgba(20, 184, 166, 0.45) 0%, rgba(20, 184, 166, 0.45) 0%,
@@ -978,8 +948,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.video-ring { .video-ring {
position: relative; position: relative;
width: 460rpx; width: 640rpx;
height: 460rpx; height: 640rpx;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
box-shadow: box-shadow:
@@ -1043,8 +1013,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.hint-icon { .hint-icon {
width: 116rpx; width: 140rpx;
height: 116rpx; height: 140rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1060,10 +1030,10 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.play-triangle { .play-triangle {
width: 0; width: 0;
height: 0; height: 0;
border-left: 34rpx solid #ffffff; border-left: 44rpx solid #ffffff;
border-top: 23rpx solid transparent; border-top: 30rpx solid transparent;
border-bottom: 23rpx solid transparent; border-bottom: 30rpx solid transparent;
margin-left: 8rpx; margin-left: 10rpx;
} }
@keyframes hint-bounce { @keyframes hint-bounce {
@@ -1076,7 +1046,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.hint-text { .hint-text {
font-size: 26rpx; font-size: 34rpx;
color: #ffffff; color: #ffffff;
font-weight: 700; font-weight: 700;
letter-spacing: 3rpx; letter-spacing: 3rpx;
@@ -1261,8 +1231,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.presets-title { .presets-title {
font-size: 26rpx; font-size: 32rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
@@ -1279,38 +1249,26 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.preset { .preset {
flex: 1; flex: 1;
background: #f8fafc; background: #f8fafc;
border: 2rpx solid transparent; border: 3rpx solid transparent;
border-radius: 20rpx; border-radius: 24rpx;
padding: 20rpx 6rpx 18rpx; padding: 32rpx 8rpx 28rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4rpx; gap: 10rpx;
transition: all 0.18s; transition: all 0.18s;
.preset-icon {
font-size: 32rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.preset-name { .preset-name {
font-size: 26rpx; font-size: 40rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
.preset-bpm { .preset-bpm {
font-size: 22rpx; font-size: 26rpx;
color: $text-2; color: $text-2;
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.preset-desc {
font-size: 18rpx;
color: $text-3;
text-align: center;
line-height: 1.3;
}
&:active { &:active {
transform: scale(0.97); transform: scale(0.97);
@@ -1349,17 +1307,34 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 4rpx; padding: 12rpx 6rpx;
border-radius: 16rpx;
&:active {
background: #f8fafc;
}
} }
.target-title { .target-title {
font-size: 26rpx; font-size: 32rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
.target-hint { .target-current {
font-size: 20rpx; display: flex;
align-items: center;
gap: 12rpx;
}
.target-current-text {
font-size: 30rpx;
font-weight: 700;
color: $brand-deep;
}
.target-toggle {
font-size: 22rpx;
color: $text-3; color: $text-3;
} }
@@ -1370,7 +1345,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.target-chip { .target-chip {
flex: 1; flex: 1;
height: 72rpx; height: 88rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1380,8 +1355,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
transition: all 0.18s; transition: all 0.18s;
.target-chip-text { .target-chip-text {
font-size: 24rpx; font-size: 30rpx;
font-weight: 600; font-weight: 700;
color: $text-2; color: $text-2;
} }
-739
View File
@@ -1,739 +0,0 @@
<template>
<view class="training-page">
<view class="header">
<view class="header-main">
<text class="title">练一练</text>
<text class="subtitle">器械动作跟练</text>
</view>
<view class="header-links">
<view class="header-link" @click="goGripRing">
🟢 握力环
</view>
<view class="header-link" @click="goMetronome">
🎵 节拍器
</view>
</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
ref="exerciseAnimRef"
: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>
<view v-if="trainingStats" class="completion-content">
<view class="stats-grid">
<view class="stat-item">
<text class="stat-label">组数</text>
<text class="stat-value">{{ trainingStats.totalSets }}</text>
</view>
<view class="stat-item">
<text class="stat-label">次数</text>
<text class="stat-value">{{ trainingStats.totalReps }}</text>
</view>
<view class="stat-item">
<text class="stat-label">时长</text>
<text class="stat-value">{{ formatDuration(trainingStats.durationSeconds) }}</text>
</view>
</view>
<view class="calorie-card">
<text class="calorie-label">消耗卡路里</text>
<text class="calorie-value">{{ trainingStats.calories }} kcal</text>
<text class="food-comparison">
{{ trainingStats.foodComparison.message }}
</text>
</view>
</view>
<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 type { CrushItemType, TrainingStats } from '../hooks/useTrainingSession'
import { useVoiceCoach } from '../hooks/useVoiceCoach'
import { useTrainingBgm } from '../hooks/useTrainingBgm'
import { useTTS } from '../hooks/useTTS'
import { formatDuration } from '../utils/calorie'
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 tts = useTTS()
const exerciseAnimRef = ref()
const session = useTrainingSession(bpm.value)
const {
currentSet,
currentRep,
restRemainingSec,
totalReps,
totalSets,
isTraining,
isResting,
isDone,
getStats,
} = session
const trainingStats = ref<TrainingStats | null>(null)
const metronome = useMetronome({
initialBpm: bpm.value,
onBeat: () => session.onBeat(),
})
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
watch(bpm, (v) => {
metronome.setBpm(v)
session.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 onCrushTrigger = (item: CrushItemType) => {
// 触发粒子特效
exerciseAnimRef.value?.triggerCrushEffect(item)
// 播放语音鼓励
tts.playEncouragement()
}
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 })
// 获取训练统计数据
trainingStats.value = getStats()
},
onCrushTrigger: onCrushTrigger,
})
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' })
}
const goGripRing = () => {
uni.navigateTo({ url: '/training/pages/grip-ring' })
}
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-links {
display: flex;
gap: 12rpx;
}
.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);
gap: 24rpx;
}
.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;
}
}
}
.completion-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.stats-grid {
display: flex;
gap: 16rpx;
width: 100%;
}
.stat-item {
flex: 1;
background: #f8fafc;
border-radius: 16rpx;
padding: 20rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #64748b;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
}
.calorie-card {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
border-radius: 16rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
width: 100%;
}
.calorie-label {
font-size: 24rpx;
color: #065f46;
font-weight: 500;
}
.calorie-value {
font-size: 48rpx;
font-weight: 800;
color: #047857;
line-height: 1.2;
}
.food-comparison {
font-size: 24rpx;
color: #065f46;
margin-top: 4rpx;
}
.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>
+70 -95
View File
@@ -72,26 +72,9 @@
<!-- ===== 控制面板统计 + 档位 + 目标 ===== --> <!-- ===== 控制面板统计 + 档位 + 目标 ===== -->
<view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }"> <view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }">
<view class="stats-row">
<view class="stat-chip">
<text class="stat-chip-label">次数</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? totalReps : '—' }}</text>
</view>
<view class="stat-chip">
<text class="stat-chip-label">时长</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? formatTime(elapsedSeconds) : '—' }}</text>
</view>
<view class="stat-chip">
<text class="stat-chip-label">糖分</text>
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `${totalSugar.toFixed(1)}g` : '—' }}</text>
</view>
</view>
<view class="deck-divider" />
<view class="presets-block"> <view class="presets-block">
<view class="presets-header"> <view class="presets-header">
<text class="presets-title">训练强度</text> <text class="presets-title">选择训练强度</text>
<text v-if="isPlaying" class="presets-hint">训练中不可切换</text> <text v-if="isPlaying" class="presets-hint">训练中不可切换</text>
</view> </view>
<view class="presets"> <view class="presets">
@@ -102,26 +85,24 @@
:class="{ active: currentPreset === p.id, disabled: isPlaying }" :class="{ active: currentPreset === p.id, disabled: isPlaying }"
@click="onPresetTap(p.id)" @click="onPresetTap(p.id)"
> >
<text class="preset-icon">{{ PRESET_ICONS[p.id] }}</text>
<text class="preset-name">{{ p.label }}</text> <text class="preset-name">{{ p.label }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text> <text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ p.desc }}</text>
</view> </view>
</view> </view>
<view class="intensity-estimate">
<text class="estimate-icon">🔥</text>
<text class="estimate-text">{{ estimateText }}</text>
</view>
</view> </view>
<view class="deck-divider" /> <view class="deck-divider" />
<!-- 训练时长(默认折叠,点击展开) -->
<view class="target-block"> <view class="target-block">
<view class="target-header"> <view class="target-header" @click="targetExpanded = !targetExpanded">
<text class="target-title">训练目标</text> <text class="target-title">训练时长</text>
<text class="target-hint">到时间自动结束</text> <view class="target-current">
<text class="target-current-text">{{ currentTargetLabel }}</text>
<text class="target-toggle">{{ targetExpanded ? '▲' : '▼' }}</text>
</view> </view>
<view class="target-chips"> </view>
<view v-if="targetExpanded" class="target-chips">
<view <view
v-for="t in TARGET_OPTIONS" v-for="t in TARGET_OPTIONS"
:key="t.minutes" :key="t.minutes"
@@ -134,10 +115,9 @@
</view> </view>
</view> </view>
<!-- 仅暂停时显示一行提示 + 结束训练文字链 -->
<view class="deck-divider" /> <view class="deck-divider" />
<!-- 训练功效介绍(可折叠) --> <!-- 训练功效介绍(可折叠,默认收起) -->
<view class="benefit-block"> <view class="benefit-block">
<view class="benefit-header" @click="benefitExpanded = !benefitExpanded"> <view class="benefit-header" @click="benefitExpanded = !benefitExpanded">
<text class="benefit-title">训练功效</text> <text class="benefit-title">训练功效</text>
@@ -264,21 +244,6 @@ const PILATES_PRESETS: readonly PilatesPreset[] = [
{ id: 'heavy', bpm: 80, met: 5.0, label: '重度', desc: '强化 · 挑战' }, { id: 'heavy', bpm: 80, met: 5.0, label: '重度', desc: '强化 · 挑战' },
] ]
// 训练功效介绍(专业文案)
const TRAINING_BENEFIT = {
muscles: ['核心肌群', '盆底肌', '大腿内侧', '臀部'],
effect: '通过持续抗阻收紧核心与盆底肌,强化深层稳定肌群,改善体态、收紧腹部与腿部线条,提升身体协调性与柔韧度。',
}
// 强度预估参考时长(选择"自由"目标时按此估算)
const ESTIMATE_REF_MINUTES = 10
const PRESET_ICONS: Record<PresetId, string> = {
light: '🌱',
medium: '💪',
heavy: '🔥',
}
// 画布指导视频 // 画布指导视频
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/202605281739110fa2d3784.mp4' const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/202605281739110fa2d3784.mp4'
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/2026052911251785db25125.jpg' const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/2026052911251785db25125.jpg'
@@ -349,16 +314,21 @@ const timerInterval = ref<number | null>(null)
const preset = computed(() => PILATES_PRESETS.find(p => p.id === currentPreset.value) || PILATES_PRESETS[1]) const preset = computed(() => PILATES_PRESETS.find(p => p.id === currentPreset.value) || PILATES_PRESETS[1])
const currentBpm = computed(() => preset.value.bpm) const currentBpm = computed(() => preset.value.bpm)
const currentMet = computed(() => preset.value.met) const currentMet = computed(() => preset.value.met)
const benefitExpanded = ref(false)
// 当前强度预估消耗文案: 按选中目标时长(自由→10分钟参考)估算 // 训练时长折叠状态 + 当前时长文案
const estimateText = computed(() => { const targetExpanded = ref(false)
const mins = targetMinutes.value > 0 ? targetMinutes.value : ESTIMATE_REF_MINUTES const currentTargetLabel = computed(() => {
const sugar = (currentMet.value * 60 * (mins / 60)) / 4 const opt = TARGET_OPTIONS.find(t => t.minutes === targetMinutes.value)
const suffix = targetMinutes.value > 0 ? '' : '(按 10 分钟估算)' return opt ? opt.label : '自由'
return `${preset.value.label}训练 ${mins} 分钟,约消耗 ${sugar.toFixed(1)}g 糖分${suffix}`
}) })
// 训练功效介绍(专业文案) + 折叠状态
const benefitExpanded = ref(false)
const TRAINING_BENEFIT = {
muscles: ['核心肌群', '盆底肌', '大腿内侧', '臀部'],
effect: '通过持续抗阻收紧核心与盆底肌,强化深层稳定肌群,改善体态、收紧腹部与腿部线条,提升身体协调性与柔韧度。',
}
// 示范视频跟随训练状态 // 示范视频跟随训练状态
watch(isPlaying, (playing) => { watch(isPlaying, (playing) => {
const videoCtx = uni.createVideoContext('pilatesDemoVideo') const videoCtx = uni.createVideoContext('pilatesDemoVideo')
@@ -826,7 +796,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
margin-left: -28rpx; margin-left: -28rpx;
margin-right: -28rpx; margin-right: -28rpx;
flex-shrink: 0; flex-shrink: 0;
height: 520rpx; height: 740rpx;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -859,8 +829,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.ring-container { .ring-container {
position: relative; position: relative;
width: 560rpx; width: 720rpx;
height: 560rpx; height: 720rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -869,8 +839,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.beat-halo { .beat-halo {
position: absolute; position: absolute;
width: 560rpx; width: 720rpx;
height: 560rpx; height: 720rpx;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, background: radial-gradient(circle,
rgba(139, 92, 246, 0.45) 0%, rgba(139, 92, 246, 0.45) 0%,
@@ -899,8 +869,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.video-ring { .video-ring {
position: relative; position: relative;
width: 460rpx; width: 640rpx;
height: 460rpx; height: 640rpx;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
box-shadow: box-shadow:
@@ -964,8 +934,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.hint-icon { .hint-icon {
width: 116rpx; width: 140rpx;
height: 116rpx; height: 140rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -981,10 +951,10 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.play-triangle { .play-triangle {
width: 0; width: 0;
height: 0; height: 0;
border-left: 34rpx solid #ffffff; border-left: 44rpx solid #ffffff;
border-top: 23rpx solid transparent; border-top: 30rpx solid transparent;
border-bottom: 23rpx solid transparent; border-bottom: 30rpx solid transparent;
margin-left: 8rpx; margin-left: 10rpx;
} }
@keyframes hint-bounce { @keyframes hint-bounce {
@@ -997,7 +967,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.hint-text { .hint-text {
font-size: 26rpx; font-size: 34rpx;
color: #ffffff; color: #ffffff;
font-weight: 700; font-weight: 700;
letter-spacing: 3rpx; letter-spacing: 3rpx;
@@ -1182,8 +1152,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.presets-title { .presets-title {
font-size: 26rpx; font-size: 32rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
@@ -1200,38 +1170,26 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.preset { .preset {
flex: 1; flex: 1;
background: #f8fafc; background: #f8fafc;
border: 2rpx solid transparent; border: 3rpx solid transparent;
border-radius: 20rpx; border-radius: 24rpx;
padding: 20rpx 6rpx 18rpx; padding: 32rpx 8rpx 28rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4rpx; gap: 10rpx;
transition: all 0.18s; transition: all 0.18s;
.preset-icon {
font-size: 32rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.preset-name { .preset-name {
font-size: 26rpx; font-size: 40rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
.preset-bpm { .preset-bpm {
font-size: 22rpx; font-size: 26rpx;
color: $text-2; color: $text-2;
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.preset-desc {
font-size: 18rpx;
color: $text-3;
text-align: center;
line-height: 1.3;
}
&:active { &:active {
transform: scale(0.97); transform: scale(0.97);
@@ -1270,17 +1228,34 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 4rpx; padding: 12rpx 6rpx;
border-radius: 16rpx;
&:active {
background: #f8fafc;
}
} }
.target-title { .target-title {
font-size: 26rpx; font-size: 32rpx;
font-weight: 700; font-weight: 800;
color: $text-1; color: $text-1;
} }
.target-hint { .target-current {
font-size: 20rpx; display: flex;
align-items: center;
gap: 12rpx;
}
.target-current-text {
font-size: 30rpx;
font-weight: 700;
color: $brand-deep;
}
.target-toggle {
font-size: 22rpx;
color: $text-3; color: $text-3;
} }
@@ -1291,7 +1266,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.target-chip { .target-chip {
flex: 1; flex: 1;
height: 72rpx; height: 88rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1301,8 +1276,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
transition: all 0.18s; transition: all 0.18s;
.target-chip-text { .target-chip-text {
font-size: 24rpx; font-size: 30rpx;
font-weight: 600; font-weight: 700;
color: $text-2; color: $text-2;
} }