Files
zyt/TUICallKit-Vue3/components/dev-training-entry/index.vue
T
2026-06-03 11:39:36 +08:00

738 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 页面内嵌模块 weekly 统计页主内容区 -->
<view
v-if="visible && props.inline"
class="dev-entry-inline"
:class="{ 'dev-entry-inline--weekly': props.tone === 'weekly' }"
>
<view class="inline-hero">
<view class="inline-hero-icon" aria-hidden="true">
<view class="inline-hero-wave" />
</view>
<view class="inline-hero-text">
<view class="inline-hero-title-row">
<text class="inline-title">居家耗糖训练</text>
<text v-if="props.tone === 'weekly'" class="inline-pill">体验</text>
</view>
<text class="inline-sub">低强度动起来帮助身体消耗糖分</text>
</view>
</view>
<scroll-view
scroll-x
class="inline-scroll"
:show-scrollbar="false"
:enable-flex="true"
>
<view class="inline-scroll-track">
<view
v-for="item in inlineItems"
:key="item.url"
class="inline-card"
@click="goto(item.url)"
>
<view v-if="item.featured" class="inline-card-badge">
<text>推荐</text>
</view>
<view class="menu-icon" :class="item.iconClass">
<view v-if="item.icon === 'dumbbell'" class="dumbbell-shape">
<view class="dumbbell-plate dumbbell-plate-left" />
<view class="dumbbell-bar" />
<view class="dumbbell-plate dumbbell-plate-right" />
</view>
<view v-else-if="item.icon === 'pedal'" class="foot-pedal-shape">
<view class="pedal-bar pedal-bar-left" />
<view class="pedal-bar pedal-bar-right" />
</view>
<view v-else-if="item.icon === 'pilates'" class="pilates-ring-shape" />
<view v-else-if="item.icon === 'grip'" class="grip-ring-shape" />
<view v-else-if="item.icon === 'metro'" class="menu-icon--metro-inner">
<view class="metro-bar metro-bar-1" />
<view class="metro-bar metro-bar-2" />
<view class="metro-bar metro-bar-3" />
</view>
</view>
<text class="inline-card-title">{{ item.title }}</text>
<text class="inline-card-sub">{{ item.sub }}</text>
<text class="inline-card-go" aria-hidden="true">进入</text>
</view>
</view>
</scroll-view>
<text v-if="props.tone === 'weekly'" class="inline-scroll-hint">左右滑动选择适合您的训练</text>
</view>
<view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<!-- 展开后的菜单卡片(从下往上动画) -->
<view v-if="expanded" class="menu-list" @click.stop>
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
<view class="menu-icon menu-icon--dumbbell">
<view class="dumbbell-shape">
<view class="dumbbell-plate dumbbell-plate-left" />
<view class="dumbbell-bar" />
<view class="dumbbell-plate dumbbell-plate-right" />
</view>
</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/foot-pedal')">
<view class="menu-icon menu-icon--pedal">
<view class="foot-pedal-shape">
<view class="pedal-bar pedal-bar-left" />
<view class="pedal-bar pedal-bar-right" />
</view>
</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/pilates-ring')">
<view class="menu-icon menu-icon--pilates">
<view class="pilates-ring-shape" />
</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/grip-ring')">
<view class="menu-icon menu-icon--grip">
<view class="grip-ring-shape" />
</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/metronome')">
<view class="menu-icon menu-icon--metro">
<view class="metro-bar metro-bar-1" />
<view class="metro-bar metro-bar-2" />
<view class="metro-bar metro-bar-3" />
</view>
<view class="menu-info">
<text class="menu-title">耗糖节拍器</text>
<text class="menu-sub">健走配速</text>
</view>
<view class="menu-arrow"></view>
</view>
</view>
<!-- 主浮动按钮(单字"练" + 呼吸光环) -->
<view class="fab-container">
<view v-if="!expanded" class="fab-pulse" />
<view v-if="!expanded" class="fab-pulse fab-pulse-2" />
<view class="entry-fab" :class="{ expanded }" @click="toggle">
<view v-if="expanded" class="icon-close">
<view class="cross-bar bar-1" />
<view class="cross-bar bar-2" />
</view>
<!-- 心跳波形 icon: 健康主题,SVG 折线 -->
<view v-else class="fab-icon" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
type TrainingItem = {
url: string
title: string
sub: string
icon: 'dumbbell' | 'pedal' | 'pilates' | 'grip' | 'metro'
iconClass: string
featured?: boolean
}
const trainingItems: TrainingItem[] = [
{ url: '/training/pages/dumbbell', title: '哑铃', sub: '力量塑形', icon: 'dumbbell', iconClass: 'menu-icon--dumbbell' },
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
]
const props = withDefaults(
defineProps<{
/** 悬浮按钮距底部 rpx(仅非 inline */
bottom?: number
/** 内嵌到页面主内容区,非 fixed 悬浮 */
inline?: boolean
/** weekly 页:与统计页 Vital Mint 视觉一致 */
tone?: 'default' | 'weekly'
}>(),
{ bottom: 200, inline: false, tone: 'default' },
)
/** weekly 将健走节拍器置前并标推荐 */
const inlineItems = computed(() => {
if (props.tone !== 'weekly') return trainingItems
const metro = trainingItems.find((i) => i.icon === 'metro')
const rest = trainingItems.filter((i) => i.icon !== 'metro')
return metro ? [metro, ...rest] : trainingItems
})
const isDevMode = (): boolean => {
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
try {
return process.env.NODE_ENV !== 'production'
} catch (_) {
return false
}
}
const visible = ref(isDevMode())
const expanded = ref(false)
const toggle = () => {
expanded.value = !expanded.value
}
const goto = (url: string) => {
expanded.value = false
uni.navigateTo({ url })
}
</script>
<style lang="scss" scoped>
$brand: #10b981;
$brand-deep: #047857;
$brand-light: #34d399;
.dev-entry-wrap {
position: fixed;
right: 24rpx;
z-index: 999;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 18rpx;
}
/* ============================================================
* 主浮动按钮
* ============================================================ */
.fab-container {
position: relative;
width: 108rpx;
height: 108rpx;
}
.entry-fab {
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(140deg, $brand-light 0%, $brand-deep 100%);
box-shadow:
0 8rpx 20rpx rgba(16, 185, 129, 0.42),
0 2rpx 6rpx rgba(15, 23, 42, 0.12),
inset 0 -8rpx 16rpx rgba(0, 0, 0, 0.12),
inset 0 8rpx 16rpx rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s;
&:active {
transform: scale(0.92);
}
&.expanded {
background: linear-gradient(140deg, #94a3b8 0%, #475569 100%);
transform: rotate(135deg);
box-shadow:
0 6rpx 16rpx rgba(15, 23, 42, 0.2),
inset 0 -6rpx 14rpx rgba(0, 0, 0, 0.12),
inset 0 6rpx 14rpx rgba(255, 255, 255, 0.12);
}
.fab-icon {
width: 60rpx;
height: 60rpx;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.22));
animation: fab-icon-beat 1.6s ease-in-out infinite;
}
}
@keyframes fab-icon-beat {
0%, 60%, 100% {
transform: scale(1);
}
20% {
transform: scale(1.12);
}
40% {
transform: scale(1);
}
}
/* 关闭态 X 图标 (CSS 几何) */
.icon-close {
position: relative;
width: 36rpx;
height: 36rpx;
.cross-bar {
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 5rpx;
background: #fff;
border-radius: 3rpx;
transform-origin: center;
}
.bar-1 {
transform: translateY(-50%) rotate(45deg);
}
.bar-2 {
transform: translateY(-50%) rotate(-45deg);
}
}
/* 呼吸光环(2 圈错相位扩散) */
.fab-pulse {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(16, 185, 129, 0.25);
animation: fab-ring 2.4s ease-out infinite;
pointer-events: none;
}
.fab-pulse-2 {
animation-delay: 1.2s;
}
@keyframes fab-ring {
0% {
transform: scale(0.95);
opacity: 0.7;
}
100% {
transform: scale(1.55);
opacity: 0;
}
}
/* ============================================================
* 展开菜单
* ============================================================ */
.menu-list {
display: flex;
flex-direction: column;
gap: 14rpx;
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20rpx) scale(0.92);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.menu-item {
display: flex;
align-items: center;
gap: 16rpx;
background: rgba(255, 255, 255, 0.96);
padding: 14rpx 20rpx 14rpx 14rpx;
border-radius: 999rpx;
box-shadow:
0 8rpx 24rpx rgba(15, 23, 42, 0.1),
0 1rpx 2rpx rgba(15, 23, 42, 0.06);
min-width: 280rpx;
transition: transform 0.15s;
backdrop-filter: blur(12rpx);
&:active {
transform: scale(0.97);
background: #f0fdf4;
}
}
.menu-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2rpx 8rpx rgba(16, 185, 129, 0.32);
&--train {
background: linear-gradient(140deg, $brand-light, $brand-deep);
}
&--dumbbell {
background: linear-gradient(140deg, #93c5fd, #3b82f6);
box-shadow: 0 2rpx 8rpx rgba(59, 130, 246, 0.36);
}
&--pedal {
background: linear-gradient(140deg, #fdba74, #f97316);
box-shadow: 0 2rpx 8rpx rgba(249, 115, 22, 0.36);
}
&--pilates {
background: linear-gradient(140deg, #c4b5fd, #8b5cf6);
box-shadow: 0 2rpx 8rpx rgba(139, 92, 246, 0.36);
}
&--grip {
background: linear-gradient(140deg, #5eead4, #14b8a6);
box-shadow: 0 2rpx 8rpx rgba(20, 184, 166, 0.36);
}
&--metro {
background: linear-gradient(140deg, #fbbf24, #d97706);
box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.36);
gap: 4rpx;
}
}
.menu-icon-text {
font-size: 32rpx;
line-height: 1;
}
/* 哑铃小 icon */
.dumbbell-shape {
width: 40rpx;
height: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.dumbbell-plate {
width: 10rpx;
height: 18rpx;
background: #fff;
border-radius: 3rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.dumbbell-bar {
width: 16rpx;
height: 5rpx;
background: #fff;
border-radius: 2rpx;
margin: 0 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
/* 脚蹬器小 icon: 左右踏板 */
.foot-pedal-shape {
width: 40rpx;
height: 28rpx;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 6rpx;
}
.pedal-bar {
width: 14rpx;
background: #fff;
border-radius: 4rpx 4rpx 2rpx 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.pedal-bar-left {
height: 18rpx;
transform: rotate(-12deg);
}
.pedal-bar-right {
height: 22rpx;
transform: rotate(12deg);
}
/* 瑜伽环小 icon: 双弧阻力环 */
.pilates-ring-shape {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 5rpx solid #fff;
position: relative;
box-shadow:
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 18rpx;
height: 18rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 3rpx solid rgba(255, 255, 255, 0.85);
}
}
/* 握力环小 icon: 白色描边的环(甜甜圈) */
.grip-ring-shape {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 7rpx solid #fff;
box-shadow:
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
}
/* 节拍器小 icon: 3 根高低柱模拟均衡器/节拍 */
.metro-bar {
width: 4rpx;
background: #fff;
border-radius: 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.metro-bar-1 {
height: 16rpx;
}
.metro-bar-2 {
height: 28rpx;
}
.metro-bar-3 {
height: 22rpx;
}
.menu-info {
display: flex;
flex-direction: column;
gap: 2rpx;
flex: 1;
}
.menu-title {
font-size: 28rpx;
color: #0f172a;
font-weight: 700;
letter-spacing: 1rpx;
}
.menu-sub {
font-size: 20rpx;
color: #94a3b8;
letter-spacing: 0.5rpx;
}
.menu-arrow {
font-size: 32rpx;
color: #cbd5e1;
font-weight: 300;
line-height: 1;
margin-left: 4rpx;
}
.menu-icon--metro-inner {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4rpx;
width: 100%;
height: 100%;
}
/* ============================================================
* 内嵌模块(weekly 等)
* ============================================================ */
.dev-entry-inline {
width: 100%;
box-sizing: border-box;
}
.dev-entry-inline--weekly {
padding: 32rpx 28rpx 24rpx;
border-radius: 48rpx;
background: var(--surface-container-lowest, #fff);
border: 1rpx solid rgba(0, 108, 73, 0.12);
box-shadow:
0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08),
0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
}
.inline-hero {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 24rpx;
}
.inline-hero-icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.inline-hero-wave {
width: 52rpx;
height: 52rpx;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23006c49' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.inline-hero-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.inline-hero-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
}
.inline-title {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
}
.inline-pill {
font-size: 20rpx;
font-weight: 700;
color: #006c49;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: rgba(0, 108, 73, 0.1);
line-height: 1.2;
}
.inline-sub {
font-size: 26rpx;
font-weight: 500;
color: #475569;
line-height: 1.4;
}
.inline-scroll {
width: 100%;
white-space: nowrap;
}
.inline-scroll-track {
display: inline-flex;
flex-direction: row;
align-items: stretch;
gap: 16rpx;
padding: 4rpx 2rpx 8rpx;
}
.inline-card {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
width: 212rpx;
min-height: 220rpx;
padding: 20rpx 18rpx 18rpx;
border-radius: 28rpx;
background: #f4f7f5;
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-sizing: border-box;
flex-shrink: 0;
transition: transform 0.2s ease, background 0.2s ease;
&:active {
transform: scale(0.98);
background: #e8f3ee;
}
.menu-icon {
width: 72rpx;
height: 72rpx;
margin-bottom: 14rpx;
flex-shrink: 0;
}
}
.inline-card-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
padding: 4rpx 10rpx;
border-radius: 8rpx;
background: #006c49;
text {
font-size: 18rpx;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
}
.inline-card-title {
font-size: 28rpx;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
width: 100%;
}
.inline-card-sub {
margin-top: 6rpx;
font-size: 22rpx;
font-weight: 500;
color: #64748b;
line-height: 1.35;
width: 100%;
}
.inline-card-go {
margin-top: auto;
padding-top: 12rpx;
font-size: 22rpx;
font-weight: 700;
color: #006c49;
line-height: 1.2;
}
.inline-scroll-hint {
display: block;
margin-top: 14rpx;
font-size: 22rpx;
font-weight: 600;
color: #94a3b8;
text-align: center;
line-height: 1.35;
}
@media (prefers-reduced-motion: reduce) {
.inline-card:active {
transform: none;
}
}
</style>