## 主要改动 ### 1. 连续训练模式 - 移除组数/休息逻辑,只统计总次数和时长 - 训练持续进行直到用户手动暂停 - 实时显示次数、时长、消耗糖分 ### 2. 递进式奖励系统 - 鸡蛋(8-12次) → 核桃(15-20次) → 易拉罐(25-30次) → 气球(40-50次) - 每个奖励在区间内随机触发 - 奖励按顺序递进,不会跳级 - 捏碎粒子特效 + 奖励弹窗 ### 3. 糖分计算与食物对比 - 公式: (MET × 体重 × 时长) / 4 - MET = 3.5, 体重 = 60kg - 食物对比: 苹果/香蕉/米饭/巧克力/可乐 ### 4. 视觉设计优化 - 真实握力环造型(完整圆环 + 中空 + 握点) - 立体渐变和纹理效果 - 挤压动画(训练时整体缩放) - 简洁交互(整个环可点击,无中心按钮) ### 5. 问题修复 - 修复进入页面时音效预热响声(静音预热) - 暂时禁用音效URL(避免404错误) - 修复捏碎特效层级问题 ## 技术实现 - 新增: grip-ring.vue (主页面) - 新增: grip-ring-canvas.vue (画布组件) - 修改: useMetronome.ts (静音预热) - 修改: exercises.ts (移除握力环) - 修改: index.vue (添加头部链接) - 修改: dev-training-entry/index.vue (添加菜单入口) - 修改: pages.json (添加路由) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
740 lines
19 KiB
Vue
740 lines
19 KiB
Vue
<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>
|