会员个人中心页面初步完成

This commit is contained in:
时舟年
2026-06-04 14:18:53 +08:00
parent 8e7c8f52f6
commit 7350293d0e
170 changed files with 18092 additions and 35 deletions
+97
View File
@@ -0,0 +1,97 @@
<template>
<view class="tab-page">
<view class="tab-page__header">
<text class="tab-page__title">课程</text>
<text class="tab-page__subtitle">精品团课 · 私教 · 线上课</text>
</view>
<RecommendCourses />
<view class="tab-page__actions">
<view class="tab-page__btn" hover-class="tab-page__btn--hover" @tap="goCourseList">
<text class="tab-page__btn-text">预约课程</text>
</view>
<view class="tab-page__btn tab-page__btn--ghost" hover-class="tab-page__btn--hover" @tap="goMyCourses">
<text class="tab-page__btn-text tab-page__btn-text--ghost">我的课程</text>
</view>
</view>
<view class="bottom-placeholder"></view>
<TabBar :active="1" />
</view>
</template>
<script setup>
import RecommendCourses from '@/components/index/RecommendCourses.vue'
import TabBar from '@/components/TabBar.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
function goCourseList() {
navigateToPage(PAGE.COURSE_LIST)
}
function goMyCourses() {
navigateToPage(PAGE.MY_COURSES)
}
</script>
<style lang="scss" scoped>
.tab-page {
min-height: 100vh;
background-color: #f0f4f8;
padding-bottom: 160rpx;
}
.tab-page__header {
padding: 48rpx 32rpx 16rpx;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #1a202c;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.tab-page__actions {
display: flex;
gap: 20rpx;
padding: 24rpx 32rpx;
}
.tab-page__btn {
flex: 1;
height: 80rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff6b35 0%, #ff8c5a 100%);
display: flex;
align-items: center;
justify-content: center;
}
.tab-page__btn--ghost {
background: #fff;
border: 1px solid #e2e8f0;
}
.tab-page__btn-text {
font-size: 28rpx;
font-weight: 600;
color: #fff;
}
.tab-page__btn-text--ghost {
color: #1a4a6f;
}
.bottom-placeholder {
height: 40rpx;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<view class="tab-page">
<view class="tab-page__header">
<text class="tab-page__title">发现</text>
<text class="tab-page__subtitle">活动 · 资讯 · 今日推荐</text>
</view>
<TodayRecommend />
<view class="discover-links">
<view class="discover-link" hover-class="discover-link--hover" @tap="goReferral">
<text class="discover-link__title">邀请好友</text>
<text class="discover-link__desc">邀请注册/购课双方得积分</text>
</view>
<view class="discover-link" hover-class="discover-link--hover" @tap="goCouponCenter">
<text class="discover-link__title">领券中心</text>
<text class="discover-link__desc">限时优惠券先到先得</text>
</view>
<view class="discover-link" hover-class="discover-link--hover" @tap="goPointsMall">
<text class="discover-link__title">积分商城</text>
<text class="discover-link__desc">积分兑换好礼</text>
</view>
</view>
<view class="bottom-placeholder"></view>
<TabBar :active="3" />
</view>
</template>
<script setup>
import TodayRecommend from '@/components/index/TodayRecommend.vue'
import TabBar from '@/components/TabBar.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
function goReferral() {
navigateToPage(PAGE.REFERRAL)
}
function goCouponCenter() {
navigateToPage(PAGE.COUPON_CENTER)
}
function goPointsMall() {
navigateToPage(PAGE.POINTS_MALL)
}
</script>
<style lang="scss" scoped>
.tab-page {
min-height: 100vh;
background-color: #f0f4f8;
padding-bottom: 160rpx;
}
.tab-page__header {
padding: 48rpx 32rpx 16rpx;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #1a202c;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.discover-links {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 24rpx 32rpx;
}
.discover-link {
padding: 24rpx 28rpx;
border-radius: 20rpx;
background: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.discover-link__title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #1e2a3a;
}
.discover-link__desc {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #64748b;
}
.bottom-placeholder {
height: 40rpx;
}
</style>
+1 -1
View File
@@ -16,7 +16,7 @@
<view class="bottom-placeholder"></view>
<!-- 底部导航 -->
<TabBar />
<TabBar :active="0" />
</view>
</template>
@@ -0,0 +1,162 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="历史对比" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">选择对比记录</text>
<view class="bt-compare-header">
<view
class="bt-compare-picker"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="pickRecord('a')"
>
<text class="bt-compare-picker__label">记录 A较新</text>
<text class="bt-compare-picker__date">{{ recordA?.date || '点击选择' }}</text>
</view>
<view
class="bt-compare-picker"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="pickRecord('b')"
>
<text class="bt-compare-picker__label">记录 B较旧</text>
<text class="bt-compare-picker__date">{{ recordB?.date || '点击选择' }}</text>
</view>
</view>
</view>
<view v-if="compareData" class="bt-card">
<text class="bt-card__title">指标对比</text>
<view class="bt-compare-row">
<text class="bt-compare-row__label">指标</text>
<text class="bt-compare-row__val">A</text>
<text class="bt-compare-row__val">B</text>
<text class="bt-compare-row__diff">差值</text>
</view>
<view
v-for="row in compareData.metrics"
:key="row.key"
class="bt-compare-row"
>
<text class="bt-compare-row__label">{{ row.label }}</text>
<text class="bt-compare-row__val">{{ row.valueA }}</text>
<text class="bt-compare-row__val">{{ row.valueB }}</text>
<text
class="bt-compare-row__diff"
:style="{ color: diffColor(row) }"
>
{{ formatDiff(row.diff, row.key) }}
</text>
</view>
</view>
<view v-if="compareData" class="bt-card">
<text class="bt-card__title">评分变化</text>
<view class="bt-metrics">
<view class="bt-metric">
<text class="bt-metric__value">{{ compareData.recordA.score }}</text>
<text class="bt-metric__label">记录 A 评分</text>
</view>
<view class="bt-metric">
<text class="bt-metric__value">{{ compareData.recordB.score }}</text>
<text class="bt-metric__label">记录 B 评分</text>
</view>
</view>
<text class="bt-card__desc" style="margin-top: 12px;">
综合评分变化 {{ scoreDiff > 0 ? '+' : '' }}{{ scoreDiff }}
{{ scoreDiff > 0 ? ',整体趋势向好' : scoreDiff < 0 ? ',建议加强训练' : '' }}
</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getBodyTestHistory, getCompareData } from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
records: [],
recordA: null,
recordB: null,
compareData: null
}
},
computed: {
scoreDiff() {
if (!this.compareData) return 0
return this.compareData.recordA.score - this.compareData.recordB.score
}
},
onShow() {
const store = loadMemberStore()
this.records = getBodyTestHistory(store)
if (this.records.length >= 2 && !this.recordA) {
this.recordA = this.records[0]
this.recordB = this.records[1]
this.refreshCompare()
}
},
methods: {
onBack() {
goBackOrTab(PAGE.BODY_TEST_HISTORY)
},
pickRecord(which) {
const labels = this.records.map(
(r) => `${r.date} · ${r.score}分 · ${r.gradeLabel}`
)
uni.showActionSheet({
itemList: labels,
success: (res) => {
const picked = this.records[res.tapIndex]
if (which === 'a') this.recordA = picked
else this.recordB = picked
this.refreshCompare()
}
})
},
refreshCompare() {
if (!this.recordA || !this.recordB) {
this.compareData = null
return
}
if (this.recordA.id === this.recordB.id) {
uni.showToast({ title: '请选择两条不同记录', icon: 'none' })
this.compareData = null
return
}
const store = loadMemberStore()
this.compareData = getCompareData(store, this.recordA.id, this.recordB.id)
},
formatDiff(diff, key) {
const sign = diff > 0 ? '+' : ''
const units = { bodyFat: '%', weight: '', bmi: '', muscleMass: '', visceralFat: '', bmr: '' }
return `${sign}${diff}${units[key] || ''}`
},
diffColor(row) {
const lowerBetter = ['weight', 'bodyFat', 'visceralFat'].includes(row.key)
const good = lowerBetter ? row.diff < 0 : row.diff > 0
if (row.diff === 0) return '#8A99B4'
return good ? '#2ECC71' : '#F39C12'
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,146 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="连接设备" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ device.name }}</text>
<text class="bt-card__desc">型号 {{ device.model }} · 请按以下步骤完成蓝牙配对</text>
</view>
<view class="bt-card">
<text class="bt-card__title">连接引导</text>
<view class="bt-steps">
<view
v-for="step in steps"
:key="step.step"
class="bt-step"
>
<view class="bt-step__num">{{ step.step }}</view>
<view class="bt-step__content">
<text class="bt-step__title">{{ step.title }}</text>
<text class="bt-step__desc">{{ step.desc }}</text>
</view>
</view>
</view>
</view>
<view v-if="searching" class="bt-card">
<view class="bt-measure">
<text class="bt-measure__hint">正在搜索附近设备</text>
<text class="bt-measure__hint">{{ searchHint }}</text>
</view>
</view>
<view v-if="connected" class="bt-card">
<view class="bt-device">
<view class="bt-device__icon-wrap">
<image class="bt-device__icon" src="/static/images/shield.png" mode="aspectFit" />
</view>
<view class="bt-device__info">
<text class="bt-device__name">连接成功</text>
<text class="bt-device__status bt-device__status--on">
{{ device.name }} · 电量 {{ device.battery }}%
</text>
</view>
<view class="bt-device__dot bt-device__dot--on"></view>
</view>
</view>
<view class="bt-footer-actions">
<view
v-if="!connected"
class="bt-btn bt-btn--primary"
:class="{ 'bt-btn--outline': searching }"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="searchDevice"
>
<text class="bt-btn__text">{{ searching ? '搜索中…' : '搜索并连接' }}</text>
</view>
<view
v-else
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="goMeasuring"
>
<text class="bt-btn__text">开始测量</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import {
loadMemberStore,
persistMemberStore
} from '@/common/memberInfo/store.js'
import {
connectBodyTestDevice,
bodyTestMock
} from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
device: {},
steps: bodyTestMock.connectSteps,
searching: false,
connected: false,
searchHint: '请保持手机蓝牙已开启'
}
},
onShow() {
const store = loadMemberStore()
this.device = { ...store.bodyTest.device }
this.connected = store.bodyTest.device.connected
},
methods: {
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
searchDevice() {
if (this.searching) return
this.searching = true
this.searchHint = '发现 InBody 270…'
setTimeout(() => {
const store = loadMemberStore()
connectBodyTestDevice(store)
persistMemberStore(store)
this.device = { ...store.bodyTest.device }
this.connected = true
this.searching = false
uni.showToast({ title: '设备已连接', icon: 'success' })
}, 1800)
},
goMeasuring() {
navigateToPage(PAGE.BODY_TEST_MEASURING)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.bt-footer-actions .bt-btn--primary.bt-btn--outline {
background: var(--bg-gray, #F2F5F9);
box-shadow: none;
}
.bt-footer-actions .bt-btn--primary.bt-btn--outline .bt-btn__text {
color: var(--text-muted, #5E6F8D);
}
</style>
@@ -0,0 +1,210 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="体测报告" @back="onBack" />
<view class="mi-mod-tabs">
<view
v-for="y in years"
:key="y"
class="bt-tab"
:class="{ 'bt-tab--active': activeYear === y }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="activeYear = y"
>
<text class="bt-tab__text">{{ y === 'all' ? '全部' : y + '年' }}</text>
</view>
</view>
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goCompare"
>
历史对比
</text>
</view>
<view class="bt-page__body">
<view
v-for="(item, index) in records"
:key="item.id"
class="mi-timeline-card"
hover-class="mi-tap-row--hover"
@tap="viewReport(item)"
>
<view class="mi-timeline-card__line">
<view class="mi-timeline-card__dot"></view>
<view v-if="index < records.length - 1" class="mi-timeline-card__bar"></view>
</view>
<view class="mi-timeline-card__content">
<view class="mi-timeline-card__head">
<text class="mi-timeline-card__date">{{ item.date }} {{ item.time }}</text>
<text class="mi-timeline-card__score">{{ item.score }}</text>
</view>
<text class="mi-timeline-card__grade">{{ item.grade }} {{ item.gradeLabel }} · {{ item.status }}</text>
<text class="mi-timeline-card__metrics">
体脂 {{ item.metrics.bodyFat }}% · 肌肉 {{ item.metrics.muscleMass }}kg · BMI {{ item.metrics.bmi }}
</text>
<view v-if="item.changeBadge" class="mi-timeline-card__badge" :class="item.changeBadge.good ? 'mi-timeline-card__badge--good' : 'mi-timeline-card__badge--warn'">
<text>{{ item.changeBadge.text }}</text>
</view>
</view>
</view>
<view v-if="!records.length" class="bt-empty">
<text class="bt-empty__text">暂无体测报告</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getBodyTestHistory, getBodyTestYears, getBodyTestChangeBadge } from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { activeYear: 'all', years: [], allRecords: [] }
},
computed: {
records() {
const list = this.activeYear === 'all'
? this.allRecords
: this.allRecords.filter((r) => r.date.startsWith(this.activeYear))
return list.map((item, index) => {
const previous = list[index + 1]
return {
...item,
changeBadge: getBodyTestChangeBadge(item, previous)
}
})
}
},
watch: {
activeYear() { this.loadList() }
},
onShow() { this.loadList() },
methods: {
loadList() {
const store = loadMemberStore()
this.years = getBodyTestYears(store)
this.allRecords = getBodyTestHistory(store)
},
onBack() { goBackOrTab(PAGE.MEMBER) },
viewReport(item) {
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${item.id}`)
},
goCompare() {
navigateToPage(PAGE.BODY_TEST_COMPARE)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.mi-timeline-card {
display: flex;
flex-direction: row;
gap: 12px;
margin-bottom: 4px;
}
.mi-timeline-card__line {
width: 16px;
display: flex;
flex-direction: column;
align-items: center;
}
.mi-timeline-card__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-orange, #FF6B35);
flex-shrink: 0;
}
.mi-timeline-card__bar {
flex: 1;
width: 2px;
background: var(--border-light, #E9EDF2);
margin: 4px 0;
}
.mi-timeline-card__content {
flex: 1;
padding: 12px 14px;
margin-bottom: 10px;
border-radius: 14px;
background: var(--bg-white, #fff);
box-shadow: var(--shadow-sm);
}
.mi-timeline-card__head {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.mi-timeline-card__date {
font-size: 13px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mi-timeline-card__score {
font-size: 18px;
font-weight: 800;
color: var(--primary-dark, #0B2B4B);
}
.mi-timeline-card__grade {
font-size: 12px;
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.mi-timeline-card__metrics {
font-size: 11px;
color: var(--text-light, #8A99B4);
margin-top: 4px;
display: block;
}
.mi-timeline-card__badge {
display: inline-block;
margin-top: 8px;
padding: 2px 8px;
border-radius: 6px;
}
.mi-timeline-card__badge--good {
background: rgba(46, 204, 113, 0.12);
}
.mi-timeline-card__badge--good text {
font-size: 10px;
color: var(--success-green, #2ECC71);
font-weight: 600;
}
.mi-timeline-card__badge--warn text {
font-size: 10px;
color: var(--warning-amber, #F39C12);
font-weight: 600;
}
</style>
@@ -0,0 +1,196 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="智能体测" @back="goBack" />
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goSettings"
>
体测设置
</text>
</view>
<view class="bt-page__body">
<view class="bt-hero">
<view class="bt-hero__top">
<text class="bt-hero__label">最新体测评分</text>
<view class="bt-hero__badge">
<text class="bt-hero__badge-text">{{ latest?.status || '暂无数据' }}</text>
</view>
</view>
<view class="bt-hero__score-row">
<text class="bt-hero__score">{{ latest?.score ?? '--' }}</text>
<text class="bt-hero__grade">{{ latest?.grade ?? '' }} {{ latest?.gradeLabel ?? '' }}</text>
</view>
<text class="bt-hero__meta">
{{ latest ? `最近测量 · ${latest.date} ${latest.time}` : '完成首次体测,获取健康画像' }}
</text>
<view class="bt-hero__actions">
<view
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="startMeasure"
>
<image class="bt-btn__icon" src="/static/images/activity.png" mode="aspectFit" />
<text class="bt-btn__text">开始体测</text>
</view>
<view
v-if="latest"
class="bt-btn bt-btn--ghost"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="viewLatestReport"
>
<text class="bt-btn__text">查看报告</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">设备状态</text>
<view class="bt-device">
<view class="bt-device__icon-wrap">
<image class="bt-device__icon" src="/static/images/mappin2.png" mode="aspectFit" />
</view>
<view class="bt-device__info">
<text class="bt-device__name">{{ device.name }}</text>
<text
class="bt-device__status"
:class="{ 'bt-device__status--on': device.connected }"
>
{{ deviceStatusText }}
</text>
</view>
<view
class="bt-device__dot"
:class="{ 'bt-device__dot--on': device.connected }"
></view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">快捷入口</text>
<view class="bt-grid">
<view
v-for="item in quickLinks"
:key="item.key"
class="bt-grid__item"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="onQuickLink(item.key)"
>
<image class="bt-grid__icon" :src="item.icon" mode="aspectFit" />
<text class="bt-grid__label">{{ item.label }}</text>
</view>
</view>
</view>
<view v-if="latest" class="bt-card">
<text class="bt-card__title">核心指标概览</text>
<view class="bt-metrics">
<view
v-for="m in previewMetrics"
:key="m.key"
class="bt-metric"
>
<text class="bt-metric__value">{{ m.value }}</text>
<text class="bt-metric__label">{{ m.label }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getLatestBodyTestRecord } from '@/common/memberInfo/bodyTestStore.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
latest: null,
device: {},
quickLinks: [
{ key: 'history', label: '历史记录', icon: '/static/images/clock.png' },
{ key: 'compare', label: '历史对比', icon: '/static/images/trendingdown.png' },
{ key: 'trend', label: '趋势分析', icon: '/static/images/activity.png' },
{ key: 'report', label: '体测报告', icon: '/static/images/filetext.png' }
]
}
},
computed: {
deviceStatusText() {
if (this.device.connected) {
return `已连接 · 电量 ${this.device.battery}%`
}
return '未连接 · 点击开始体测进行配对'
},
previewMetrics() {
if (!this.latest?.metrics) return []
const m = this.latest.metrics
return [
{ key: 'weight', label: '体重(kg)', value: m.weight },
{ key: 'bmi', label: 'BMI', value: m.bmi },
{ key: 'bodyFat', label: '体脂率(%)', value: m.bodyFat },
{ key: 'muscleMass', label: '肌肉量(kg)', value: m.muscleMass }
]
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.latest = getLatestBodyTestRecord(store)
this.device = { ...store.bodyTest.device }
},
startMeasure() {
const store = loadMemberStore()
if (store.bodyTest.device.connected) {
navigateToPage(PAGE.BODY_TEST_MEASURING)
} else {
navigateToPage(PAGE.BODY_TEST_CONNECT)
}
},
viewLatestReport() {
if (!this.latest) return
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${this.latest.id}`)
},
goSettings() {
navigateToPage(PAGE.BODY_TEST_SETTINGS)
},
onQuickLink(key) {
const routes = {
history: PAGE.BODY_TEST_HISTORY,
compare: PAGE.BODY_TEST_COMPARE,
trend: PAGE.BODY_TEST_TREND,
report: this.latest
? `${PAGE.BODY_TEST_REPORT}?id=${this.latest.id}`
: PAGE.BODY_TEST_HISTORY
}
navigateToPage(routes[key] || PAGE.BODY_TEST_HOME)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,169 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="测量中" @back="onCancel" />
<view class="bt-page__body">
<view class="bt-card">
<view class="bt-measure">
<view class="bt-measure__ring-wrap">
<view class="bt-measure__ring-bg"></view>
<view
class="bt-measure__ring-fill"
:style="{ transform: `rotate(${ringRotation}deg)` }"
></view>
<view class="bt-measure__center">
<text class="bt-measure__percent">{{ progress }}%</text>
<text class="bt-measure__hint">{{ phaseHint }}</text>
</view>
</view>
<text class="bt-card__desc">{{ statusText }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">实时数据</text>
<view class="bt-measure__live">
<view
v-for="item in liveDisplay"
:key="item.key"
class="bt-measure__live-item"
>
<text class="bt-measure__live-value">{{ item.value }}</text>
<text class="bt-measure__live-label">{{ item.label }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import {
loadMemberStore,
persistMemberStore
} from '@/common/memberInfo/store.js'
import {
interpolateMeasuringMetrics,
saveSimulatedBodyTestRecord
} from '@/common/memberInfo/bodyTestStore.js'
const PHASES = [
{ until: 20, hint: '校准中', text: '请保持站立姿势,双手自然下垂' },
{ until: 50, hint: '阻抗测量', text: '请勿移动,正在进行生物电阻抗分析' },
{ until: 80, hint: '数据分析', text: '正在计算体脂与肌肉分布' },
{ until: 100, hint: '即将完成', text: '生成健康报告中…' }
]
export default {
components: { MemberInfoSubNav },
data() {
return {
progress: 0,
liveMetrics: {},
timer: null,
finished: false
}
},
computed: {
ringRotation() {
return -90 + (this.progress / 100) * 360
},
phaseHint() {
const phase = PHASES.find((p) => this.progress <= p.until)
return phase?.hint || '完成'
},
statusText() {
const phase = PHASES.find((p) => this.progress <= p.until)
return phase?.text || '测量完成'
},
liveDisplay() {
const m = this.liveMetrics
return [
{ key: 'weight', label: '体重(kg)', value: m.weight ?? '--' },
{ key: 'bodyFat', label: '体脂率(%)', value: m.bodyFat ?? '--' },
{ key: 'muscleMass', label: '肌肉量(kg)', value: m.muscleMass ?? '--' },
{ key: 'bmr', label: '基础代谢', value: m.bmr ?? '--' }
]
}
},
onLoad() {
const store = loadMemberStore()
if (!store.bodyTest.device.connected) {
uni.showToast({ title: '请先连接设备', icon: 'none' })
setTimeout(() => {
navigateToPage(PAGE.BODY_TEST_CONNECT)
}, 800)
return
}
this.startMeasurement()
},
onUnload() {
this.clearTimer()
},
methods: {
clearTimer() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
startMeasurement() {
const store = loadMemberStore()
this.liveMetrics = interpolateMeasuringMetrics(0, store.profile)
this.timer = setInterval(() => {
if (this.progress >= 100) {
this.completeMeasurement()
return
}
this.progress = Math.min(100, this.progress + 2)
const s = loadMemberStore()
this.liveMetrics = interpolateMeasuringMetrics(this.progress, s.profile)
}, 120)
},
completeMeasurement() {
if (this.finished) return
this.finished = true
this.clearTimer()
const store = loadMemberStore()
const record = saveSimulatedBodyTestRecord(store, {
...this.liveMetrics,
visceralFat: 6,
boneMass: 2.42,
bodyWater: this.liveMetrics.bodyWater || 52.8,
protein: 16.4
})
persistMemberStore(store)
uni.showToast({ title: '测量完成', icon: 'success' })
setTimeout(() => {
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${record.id}&new=1`)
}, 600)
},
onCancel() {
if (this.finished) return
uni.showModal({
title: '取消测量',
content: '确定要中断当前体测吗?',
success: (res) => {
if (res.confirm) {
this.clearTimer()
goBackOrTab(PAGE.BODY_TEST_HOME)
}
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,315 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="体测报告" @back="onBack" />
<view v-if="record" class="bt-page__body">
<view class="bt-score-card">
<view class="bt-score-card__circle">
<text class="bt-score-card__num">{{ record.score }}</text>
<text class="bt-score-card__grade">{{ record.grade }}</text>
</view>
<view class="bt-score-card__info">
<text class="bt-score-card__title">{{ record.gradeLabel }} · {{ record.status }}</text>
<text class="bt-score-card__date">{{ record.date }} {{ record.time }}</text>
<text v-if="record.bodyAge" class="bt-score-card__date">
身体年龄 {{ record.bodyAge }} · 实际 {{ record.realAge }}
</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">核心指标</text>
<view class="bt-metrics">
<view
v-for="m in metricCards"
:key="m.key"
class="bt-metric"
>
<text class="bt-metric__value">{{ m.display }}</text>
<text class="bt-metric__label">{{ m.label }}</text>
<text
v-if="m.changeText"
class="bt-metric__change"
:class="m.changeClass"
>
较上次 {{ m.changeText }}
</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">体成分雷达图</text>
<BodyTestRadarChart
:labels="radarLabels"
:values="radarValues"
:width="chartWidth"
:height="220"
/>
</view>
<view class="bt-card">
<text class="bt-card__title">人体成分分布</text>
<view class="bt-body-map">
<view class="bt-body-map__figure">
<view class="bt-body-map__head"></view>
<view class="bt-body-map__limbs">
<view class="bt-body-map__arm"></view>
<view class="bt-body-map__arm"></view>
</view>
<view class="bt-body-map__torso"></view>
<view class="bt-body-map__legs">
<view class="bt-body-map__leg"></view>
<view class="bt-body-map__leg"></view>
</view>
</view>
<view class="bt-body-map__segments">
<view
v-for="seg in record.bodySegments"
:key="seg.part"
class="bt-body-map__seg"
:class="'bt-body-map__seg--' + seg.level"
>
<text class="bt-body-map__seg-name">{{ seg.part }}</text>
<text class="bt-body-map__seg-val">{{ seg.value }}</text>
</view>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">指标变化趋势</text>
<BodyTestTrendChart
:points="trendPreview"
unit="kg"
:width="chartWidth"
:height="140"
/>
<view
class="bt-trend-link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goTrend"
>
<text class="bt-trend-link__text">查看完整趋势分析</text>
<image
class="bt-trend-link__arrow"
src="/static/images/chevronright3.png"
mode="aspectFit"
/>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">健康建议</text>
<view class="bt-advice-list">
<view
v-for="(tip, idx) in record.advice"
:key="idx"
class="bt-advice-item"
>
<view class="bt-advice-item__dot"></view>
<text class="bt-advice-item__text">{{ tip }}</text>
</view>
</view>
</view>
<view v-if="courses.length" class="bt-card">
<text class="bt-card__title">推荐课程</text>
<view
v-for="course in courses"
:key="course.id"
class="bt-course"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="onCourseTap(course)"
>
<image class="bt-course__banner" :src="course.banner" mode="aspectFill" />
<view class="bt-course__info">
<text class="bt-course__tag">{{ course.tag }}</text>
<text class="bt-course__title">{{ course.title }}</text>
<text class="bt-course__meta">{{ course.coach }} · {{ course.schedule }}</text>
</view>
</view>
</view>
<view class="bt-footer-actions">
<view
class="bt-btn bt-btn--outline"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="exportReport"
>
<image class="bt-btn__icon" src="/static/images/filetext.png" mode="aspectFit" />
<text class="bt-btn__text">导出 PDF</text>
</view>
<view
class="bt-btn bt-btn--outline"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="shareReport"
>
<image class="bt-btn__icon" src="/static/images/share2.png" mode="aspectFit" />
<text class="bt-btn__text">分享</text>
</view>
<view
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="retest"
>
<text class="bt-btn__text">再次体测</text>
</view>
</view>
</view>
<view v-else class="bt-empty">
<text class="bt-empty__text">未找到体测报告</text>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import BodyTestRadarChart from '@/components/memberInfo/BodyTestRadarChart.vue'
import BodyTestTrendChart from '@/components/memberInfo/BodyTestTrendChart.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import {
getBodyTestRecordById,
getBodyTestTrendData,
getRecommendedCourses,
computeChanges,
formatChangeValue,
bodyTestMock
} from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav, BodyTestRadarChart, BodyTestTrendChart },
data() {
return {
recordId: null,
record: null,
previous: null,
chartWidth: 300
}
},
computed: {
radarLabels() {
return bodyTestMock.radarLabels.map((l) => l.label)
},
radarValues() {
if (!this.record?.radar) return []
const keys = bodyTestMock.radarLabels.map((l) => l.key)
return keys.map((k) => this.record.radar[k] || 0)
},
metricCards() {
if (!this.record?.metrics) return []
const changes = this.changes
const defs = bodyTestMock.metricDefs.slice(0, 8)
return defs.map((def) => {
const val = this.record.metrics[def.key]
const diff = changes[def.key]
let changeText = ''
let changeClass = ''
if (diff !== undefined && diff !== null) {
changeText = formatChangeValue(def.key, diff)
const lowerBetter = ['weight', 'bodyFat', 'visceralFat'].includes(def.key)
const isGood = lowerBetter ? diff < 0 : diff > 0
changeClass = isGood ? 'bt-metric__change--down' : 'bt-metric__change--up'
}
const display = def.unit ? `${val}${def.unit === '%' ? '%' : def.unit === 'kg' ? '' : ` ${def.unit}`}` : val
return {
key: def.key,
label: def.label + (def.unit && def.unit !== '%' && def.unit !== 'kg' ? `(${def.unit})` : def.unit === 'kg' ? '(kg)' : def.unit === '%' ? '(%)' : ''),
display: def.key === 'bodyFat' ? `${val}%` : def.key === 'bodyWater' ? `${val}%` : def.unit === 'kg' ? val : def.unit ? `${val}` : val,
changeText,
changeClass
}
})
},
changes() {
if (this.record?.changes) return this.record.changes
if (this.previous) return computeChanges(this.record, this.previous)
return {}
},
trendPreview() {
const store = loadMemberStore()
return getBodyTestTrendData(store, 'weight', 4)
},
courses() {
return getRecommendedCourses(this.record)
}
},
onLoad(options) {
this.recordId = options?.id ? Number(options.id) : null
this.chartWidth = uni.getSystemInfoSync().windowWidth - 64
this.loadRecord()
},
methods: {
loadRecord() {
const store = loadMemberStore()
const records = store.bodyTest.records
if (this.recordId) {
this.record = getBodyTestRecordById(store, this.recordId)
} else {
this.record = records.length ? { ...records[0] } : null
}
if (this.record) {
const idx = records.findIndex((r) => r.id === this.record.id)
this.previous = idx >= 0 && records[idx + 1] ? records[idx + 1] : null
}
},
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
goTrend() {
navigateToPage(`${PAGE.BODY_TEST_TREND}?metric=weight`)
},
exportReport() {
uni.showLoading({ title: '生成中…' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '报告已保存到相册', icon: 'success' })
}, 1200)
},
shareReport() {
uni.showActionSheet({
itemList: ['分享给微信好友', '生成分享海报', '复制报告链接'],
success: (res) => {
const msgs = ['已唤起微信分享', '海报已生成', '链接已复制']
uni.showToast({ title: msgs[res.tapIndex] || '分享成功', icon: 'none' })
}
})
},
onCourseTap(course) {
uni.showModal({
title: course.title,
content: `${course.coach}\n${course.schedule}\n\n是否前往预约?`,
success: (res) => {
if (res.confirm) {
navigateToPage(PAGE.COURSE_LIST)
}
}
})
},
retest() {
navigateToPage(PAGE.BODY_TEST_HOME)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.bt-footer-actions .bt-btn {
flex: 1;
}
</style>
@@ -0,0 +1,182 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="体测设置" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">连接与同步</text>
<view class="bt-setting">
<view>
<text class="bt-setting__label">蓝牙自动连接</text>
<text class="bt-setting__desc">进入体测页时自动搜索已配对设备</text>
</view>
<switch
:checked="settings.bluetoothEnabled"
color="#FF6B35"
@change="onSwitch('bluetoothEnabled', $event)"
/>
</view>
<view class="bt-setting">
<view>
<text class="bt-setting__label">测量完成自动同步</text>
<text class="bt-setting__desc">结果自动保存至云端与本地</text>
</view>
<switch
:checked="settings.autoSync"
color="#FF6B35"
@change="onSwitch('autoSync', $event)"
/>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">通知与隐私</text>
<view class="bt-setting">
<view>
<text class="bt-setting__label">测量完成通知</text>
<text class="bt-setting__desc">体测结束后推送报告摘要</text>
</view>
<switch
:checked="settings.notifyOnComplete"
color="#FF6B35"
@change="onSwitch('notifyOnComplete', $event)"
/>
</view>
<view class="bt-setting">
<view>
<text class="bt-setting__label">分享时匿名化</text>
<text class="bt-setting__desc">隐藏姓名与手机号</text>
</view>
<switch
:checked="settings.shareAnonymous"
color="#FF6B35"
@change="onSwitch('shareAnonymous', $event)"
/>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">单位制</text>
<view class="bt-setting">
<view>
<text class="bt-setting__label">度量单位</text>
<text class="bt-setting__desc">{{ unitLabel }}</text>
</view>
<view
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="toggleUnit"
>
<text style="font-size: 14px; color: #1A4A6F; font-weight: 600;">切换</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">设备管理</text>
<view class="bt-device">
<view class="bt-device__icon-wrap">
<image class="bt-device__icon" src="/static/images/mappin2.png" mode="aspectFit" />
</view>
<view class="bt-device__info">
<text class="bt-device__name">{{ device.name }}</text>
<text class="bt-device__status">
{{ device.connected ? '已连接' : '未连接' }}
· 上次 {{ device.lastConnected || '--' }}
</text>
</view>
</view>
<view
class="bt-btn bt-btn--outline"
style="margin-top: 12px;"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="disconnect"
>
<text class="bt-btn__text">解除设备配对</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import {
loadMemberStore,
persistMemberStore
} from '@/common/memberInfo/store.js'
import {
updateBodyTestSettings,
disconnectBodyTestDevice
} from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
settings: {},
device: {}
}
},
computed: {
unitLabel() {
return this.settings.unitSystem === 'imperial' ? '英制 (lb / in)' : '公制 (kg / cm)'
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.settings = { ...store.bodyTest.settings }
this.device = { ...store.bodyTest.device }
},
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
onSwitch(key, e) {
const store = loadMemberStore()
updateBodyTestSettings(store, { [key]: e.detail.value })
persistMemberStore(store)
this.settings = { ...store.bodyTest.settings }
uni.showToast({ title: '已保存', icon: 'success' })
},
toggleUnit() {
const store = loadMemberStore()
const next = this.settings.unitSystem === 'metric' ? 'imperial' : 'metric'
updateBodyTestSettings(store, { unitSystem: next })
persistMemberStore(store)
this.settings = { ...store.bodyTest.settings }
uni.showToast({ title: `已切换为${this.unitLabel}`, icon: 'none' })
},
disconnect() {
uni.showModal({
title: '解除配对',
content: '解除后下次体测需重新连接设备,确定继续?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
disconnectBodyTestDevice(store)
persistMemberStore(store)
this.refreshFromStore()
uni.showToast({ title: '已解除配对', icon: 'success' })
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,151 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="趋势分析" @back="onBack" />
<view class="bt-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeMetric === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="switchMetric(tab.key)"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
</view>
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ activeLabel }}变化趋势</text>
<text class="bt-card__desc">基于最近 {{ trendPoints.length }} 次体测数据</text>
<BodyTestTrendChart
:points="trendPoints"
:unit="activeUnit"
:width="chartWidth"
:height="200"
/>
</view>
<view class="bt-card">
<text class="bt-card__title">数据明细</text>
<view
v-for="(pt, idx) in trendPointsReversed"
:key="pt.id"
class="bt-compare-row"
>
<text class="bt-compare-row__label">{{ pt.date }}</text>
<text class="bt-compare-row__val">{{ pt.value }}{{ activeUnit }}</text>
<text
v-if="idx < trendPointsReversed.length - 1"
class="bt-compare-row__diff"
:style="{ color: rowDiffColor(pt, trendPointsReversed[idx + 1]) }"
>
{{ rowDiffText(pt, trendPointsReversed[idx + 1]) }}
</text>
<text v-else class="bt-compare-row__diff">--</text>
</view>
</view>
<view v-if="summaryText" class="bt-card">
<text class="bt-card__title">趋势解读</text>
<text class="bt-card__desc">{{ summaryText }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import BodyTestTrendChart from '@/components/memberInfo/BodyTestTrendChart.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getBodyTestTrendData, bodyTestMock } from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav, BodyTestTrendChart },
data() {
return {
tabs: bodyTestMock.trendMetrics,
activeMetric: 'weight',
trendPoints: [],
chartWidth: 300
}
},
computed: {
activeLabel() {
return this.tabs.find((t) => t.key === this.activeMetric)?.label || ''
},
activeUnit() {
const units = {
weight: 'kg',
bodyFat: '%',
muscleMass: 'kg',
bmi: ''
}
return units[this.activeMetric] || ''
},
trendPointsReversed() {
return [...this.trendPoints].reverse()
},
summaryText() {
if (this.trendPoints.length < 2) return ''
const first = this.trendPoints[0].value
const last = this.trendPoints[this.trendPoints.length - 1].value
const diff = Math.round((last - first) * 10) / 10
const sign = diff > 0 ? '上升' : diff < 0 ? '下降' : '持平'
const abs = Math.abs(diff)
const lowerBetter = ['weight', 'bodyFat'].includes(this.activeMetric)
let advice = ''
if (diff !== 0) {
const good = lowerBetter ? diff < 0 : diff > 0
advice = good ? ',变化方向符合健康目标' : ',建议关注饮食与训练计划'
}
return `期间${this.activeLabel}${sign} ${abs}${this.activeUnit}${advice}`
}
},
onLoad(options) {
if (options?.metric) {
this.activeMetric = options.metric
}
this.chartWidth = uni.getSystemInfoSync().windowWidth - 64
this.loadTrend()
},
methods: {
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
switchMetric(key) {
this.activeMetric = key
this.loadTrend()
},
loadTrend() {
const store = loadMemberStore()
this.trendPoints = getBodyTestTrendData(store, this.activeMetric, 6)
},
rowDiffText(current, older) {
const diff = Math.round((current.value - older.value) * 10) / 10
const sign = diff > 0 ? '+' : ''
return `${sign}${diff}${this.activeUnit}`
},
rowDiffColor(current, older) {
const diff = current.value - older.value
if (diff === 0) return '#8A99B4'
const lowerBetter = ['weight', 'bodyFat'].includes(this.activeMetric)
const good = lowerBetter ? diff < 0 : diff > 0
return good ? '#2ECC71' : '#F39C12'
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,209 @@
<template>
<view class="scroll-container theme-light">
<view class="booking-page">
<MemberInfoSubNav title="我的预约" @back="goBack" />
<view class="booking-page__tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="booking-page__tab"
:class="{ 'booking-page__tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="setActiveTab(tab.key)"
>
<text
class="booking-page__tab-text"
:class="{ 'booking-page__tab-text--active': activeTab === tab.key }"
>
{{ tab.label }}
</text>
<view
v-if="activeTab === tab.key"
class="booking-page__tab-indicator"
></view>
</view>
</view>
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link bt-page__action-link--primary"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goCourseList"
>
预约课程
</text>
</view>
<view class="booking-page__body">
<view
v-if="activeTab === 'ongoing' && upcomingAlert"
class="booking-page__alert"
>
<image
class="booking-page__alert-icon"
src="/static/images/clock1.png"
mode="aspectFit"
/>
<text class="booking-page__alert-text">{{ upcomingAlert }}</text>
</view>
<view
v-for="item in displayedBookings"
:key="item.id"
class="bk-card"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
>
<image
class="bk-card__banner"
:src="item.banner"
mode="aspectFill"
/>
<view class="bk-card__content">
<view class="bk-card__header">
<text class="bk-card__title">{{ item.title }}</text>
<view
class="bk-card__status"
:class="'bk-card__status--' + item.status"
>
<text
class="bk-card__status-text"
:class="'bk-card__status-text--' + item.status"
>
{{ item.statusLabel }}
</text>
</view>
</view>
<view class="bk-card__meta">
<view class="bk-card__meta-row">
<image
class="bk-card__meta-icon"
src="/static/images/clock0.png"
mode="aspectFit"
/>
<text class="bk-card__meta-text">{{ item.schedule }}</text>
</view>
<view class="bk-card__meta-row">
<image
class="bk-card__meta-icon"
src="/static/images/user0.png"
mode="aspectFit"
/>
<text class="bk-card__meta-text">{{ item.coach }}</text>
</view>
</view>
<view class="bk-card__footer">
<text class="bk-card__footer-info">{{ item.footerText }}</text>
<view
v-if="item.canCancel"
class="bk-card__cancel"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="cancelBooking(item)"
>
<text class="bk-card__cancel-text">取消预约</text>
</view>
</view>
</view>
</view>
<view v-if="!displayedBookings.length" class="booking-page__empty">
<text class="booking-page__empty-text">
{{ activeTab === 'ongoing' ? '暂无进行中的预约' : '暂无历史预约' }}
</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { bookingMock } from '@/common/memberInfo/mockData.js'
import {
loadMemberStore,
cancelOngoingBooking,
formatUpcomingAlert
} from '@/common/memberInfo/store.js'
import { canCancelBooking } from '@/common/memberInfo/bookingStore.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: bookingMock.tabs,
ongoingBookings: [],
historyBookings: [],
activeTab: 'ongoing'
}
},
computed: {
displayedBookings() {
return this.activeTab === 'ongoing'
? this.ongoingBookings
: this.historyBookings
},
upcomingAlert() {
return formatUpcomingAlert(this.ongoingBookings[0])
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.ongoingBookings = store.ongoingBookings.map((item) => ({
...item,
canCancel: canCancelBooking(item)
}))
this.historyBookings = store.historyBookings.map((item) => ({ ...item }))
},
goCourseList() {
navigateToPage(PAGE.COURSE_LIST)
},
setActiveTab(tab) {
this.activeTab = tab
},
cancelBooking(item) {
if (!canCancelBooking(item)) {
uni.showToast({ title: '距开课不足2小时,无法取消', icon: 'none' })
return
}
uni.showModal({
title: '取消预约',
content: `确定要取消「${item.title}」吗?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
const result = cancelOngoingBooking(store, item.id)
if (!result.ok) {
uni.showToast({ title: result.message, icon: 'none' })
return
}
this.refreshFromStore()
uni.showToast({ title: '已取消', icon: 'success' })
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/booking-page.css';
</style>
@@ -0,0 +1,96 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="签到记录" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeFilter === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="activeFilter = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
</view>
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title"> {{ filteredList.length }} 条记录</text>
<view
v-for="item in filteredList"
:key="item.id"
class="mi-mod-checkin-row"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="showDetail(item)"
>
<view class="mi-mod-checkin-row__icon" :class="'mi-mod-checkin-row__icon--' + item.tagTheme">
<image class="mi-mod-checkin-row__icon-img" src="/static/images/usercheck.png" mode="aspectFit" />
</view>
<view class="mi-mod-checkin-row__info">
<text class="mi-mod-checkin-row__title">{{ item.title }}</text>
<text class="mi-mod-checkin-row__time">{{ item.time }} · {{ item.location }}</text>
</view>
<view class="mi-mod-checkin-row__tag" :class="'mi-mod-checkin-row__tag--' + item.tagTheme">
<text class="mi-mod-checkin-row__tag-text">{{ item.tag }}</text>
</view>
</view>
<view v-if="!filteredList.length" class="bt-empty">
<text class="bt-empty__text">暂无签到记录</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { moduleMock, getCheckInHistory } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: moduleMock.checkInTabs,
activeFilter: 'all',
list: []
}
},
computed: {
filteredList() {
if (this.activeFilter === 'all') return this.list
return this.list.filter((i) => i.tagTheme === this.activeFilter)
}
},
onShow() {
const store = loadMemberStore()
this.list = getCheckInHistory(store, 'all')
},
methods: {
showDetail(item) {
uni.showModal({
title: item.title,
content: `${item.time}\n地点:${item.location}\n类型:${item.tag}`,
showCancel: false
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,134 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="领券中心" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">可领取优惠券</text>
<text class="bt-card__desc">领取后自动放入可用列表</text>
</view>
<view
v-for="item in list"
:key="item.id"
class="mi-center-coupon"
>
<view class="mi-center-coupon__info">
<text class="mi-center-coupon__amount">¥{{ item.amount }}</text>
<view>
<text class="mi-center-coupon__title">{{ item.title }}</text>
<text class="mi-center-coupon__desc">{{ item.desc }} · {{ item.expireDays }}天有效</text>
</view>
</view>
<view
class="mi-center-coupon__btn"
:class="{ 'mi-center-coupon__btn--done': item.claimed }"
hover-class="mi-tap-btn--hover"
@tap="claim(item)"
>
<text>{{ item.claimed ? '已领取' : '立即领取' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getCouponCenterList, claimCouponFromCenter } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { list: [] }
},
onShow() {
const store = loadMemberStore()
this.list = getCouponCenterList(store)
},
methods: {
onBack() { goBackOrTab(PAGE.COUPONS) },
claim(item) {
if (item.claimed) return
const store = loadMemberStore()
const result = claimCouponFromCenter(store, item.id)
uni.showToast({ title: result.message, icon: result.ok ? 'success' : 'none' })
if (result.ok) {
persistMemberStore(store)
this.list = getCouponCenterList(store)
}
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.mi-center-coupon {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
margin-bottom: 10px;
border-radius: 16px;
background: var(--bg-white, #fff);
box-shadow: var(--shadow-sm);
}
.mi-center-coupon__info {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
flex: 1;
}
.mi-center-coupon__amount {
font-size: 28px;
font-weight: 800;
color: var(--accent-orange, #FF6B35);
}
.mi-center-coupon__title {
font-size: 14px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-center-coupon__desc {
font-size: 11px;
color: var(--text-muted, #5E6F8D);
display: block;
margin-top: 2px;
}
.mi-center-coupon__btn {
padding: 8px 14px;
border-radius: 999px;
}
.mi-center-coupon__btn text {
font-size: 12px;
font-weight: 600;
color: #fff;
}
.mi-center-coupon__btn--done {
background: var(--bg-gray, #F2F5F9);
}
.mi-center-coupon__btn--done text {
color: var(--text-light, #8A99B4);
}
</style>
@@ -0,0 +1,74 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="coupon">
<MemberInfoSubNav title="优惠券详情" @back="onBack" />
<view class="bt-page__body">
<view class="mi-mod-coupon mi-mod-coupon--detail">
<view class="mi-mod-coupon__left">
<text class="mi-mod-coupon__amount">¥{{ coupon.amount }}</text>
<text class="mi-mod-coupon__min">{{ coupon.minSpend }}可用</text>
</view>
<view class="mi-mod-coupon__right">
<text class="mi-mod-coupon__title">{{ coupon.title }}</text>
<text class="mi-mod-coupon__desc">{{ coupon.desc }}</text>
<text class="mi-mod-coupon__expire">有效期至 {{ coupon.expire }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">使用规则</text>
<text class="bt-card__desc" style="white-space: pre-line;">{{ coupon.rules }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">适用范围</text>
<text class="bt-card__desc">{{ coupon.scope }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">使用流程</text>
<text class="bt-card__desc">{{ coupon.flow }}</text>
</view>
<view v-if="coupon.status === 'available'" class="bt-footer-actions">
<view class="bt-btn bt-btn--primary" hover-class="mi-tap-btn--hover" @tap="useNow">
<text class="bt-btn__text">立即使用</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getCouponById } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { coupon: null }
},
onLoad(options) {
const store = loadMemberStore()
this.coupon = getCouponById(store, options?.id)
},
methods: {
onBack() { goBackOrTab(PAGE.COUPONS) },
useNow() {
navigateToPage(PAGE.COURSE_LIST)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-mod-coupon--detail { margin-bottom: 12px; }
.bt-footer-actions .bt-btn { flex: 1; }
</style>
@@ -0,0 +1,169 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的优惠券" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="activeTab = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}({{ countByTab[tab.key] }})</text>
</view>
</view>
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link bt-page__action-link--primary"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goCenter"
>
领券中心
</text>
</view>
<view class="bt-page__body">
<view
v-for="item in displayedCoupons"
:key="item.id"
class="mi-mod-coupon"
:class="'mi-mod-coupon--' + item.status"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
@tap="onCouponTap(item)"
>
<view class="mi-mod-coupon__left">
<text class="mi-mod-coupon__amount">¥{{ item.amount }}</text>
<text class="mi-mod-coupon__min">{{ item.minSpend }}可用</text>
</view>
<view class="mi-mod-coupon__right">
<text class="mi-mod-coupon__title">{{ item.title }}</text>
<text class="mi-mod-coupon__desc">{{ item.desc }}</text>
<text class="mi-mod-coupon__expire">
{{ item.status === 'used' ? `已于 ${item.usedAt} 使用` : `有效期至 ${item.expire}` }}
</text>
<view v-if="item.status === 'available'" class="mi-mod-coupon__use" @tap.stop="useNow(item)">
<text>立即使用</text>
</view>
<view v-if="item.status === 'expired'" class="mi-mod-coupon__del" @tap.stop="removeExpired(item)">
<text>删除</text>
</view>
</view>
</view>
<view v-if="!displayedCoupons.length" class="bt-empty">
<text class="bt-empty__text">暂无{{ activeTabLabel }}优惠券</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { moduleMock, getCouponsByStatus, useCoupon, deleteExpiredCoupon } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: moduleMock.couponTabs,
activeTab: 'available',
coupons: []
}
},
computed: {
displayedCoupons() {
return this.coupons.filter((c) => c.status === this.activeTab)
},
countByTab() {
return {
available: this.coupons.filter((c) => c.status === 'available').length,
used: this.coupons.filter((c) => c.status === 'used').length,
expired: this.coupons.filter((c) => c.status === 'expired').length
}
},
activeTabLabel() {
return this.tabs.find((t) => t.key === this.activeTab)?.label || ''
}
},
onShow() {
this.refreshList()
},
methods: {
refreshList() {
const store = loadMemberStore()
this.coupons = [
...getCouponsByStatus(store, 'available'),
...getCouponsByStatus(store, 'used'),
...getCouponsByStatus(store, 'expired')
]
},
onCouponTap(item) {
navigateToPage(`${PAGE.COUPON_DETAIL}?id=${item.id}`)
},
useNow(item) {
uni.showModal({
title: '使用优惠券',
content: `确认使用「${item.title}」?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
useCoupon(store, item.id)
persistMemberStore(store)
this.refreshList()
navigateToPage(PAGE.COURSE_LIST)
}
})
},
removeExpired(item) {
const store = loadMemberStore()
deleteExpiredCoupon(store, item.id)
persistMemberStore(store)
this.refreshList()
},
goCenter() {
navigateToPage(PAGE.COUPON_CENTER)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-mod-coupon__use {
margin-top: 8px;
align-self: flex-start;
padding: 4px 12px;
border-radius: 999px;
}
.mi-mod-coupon__use text {
font-size: 11px;
font-weight: 600;
color: #fff;
}
.mi-mod-coupon__del {
margin-top: 8px;
}
.mi-mod-coupon__del text {
font-size: 11px;
color: var(--text-light, #8A99B4);
}
</style>
@@ -0,0 +1,175 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="course">
<MemberInfoSubNav title="课程详情" @back="onBack" />
<image class="mi-detail-hero" :src="course.banner" mode="aspectFill" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ course.title }}</text>
<text class="bt-card__desc">{{ course.startTime }}-{{ course.endTime }} · {{ course.location }}</text>
<view class="mi-detail-coach">
<image class="mi-detail-coach__avatar" :src="course.coachAvatar" mode="aspectFill" />
<view>
<text class="mi-detail-coach__name">{{ course.coach }}</text>
<text class="mi-detail-coach__rating">评分 {{ course.coachRating }}</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">课程介绍</text>
<text class="bt-card__desc">{{ course.intro }}</text>
<text class="bt-card__title" style="margin-top:12px;">适合人群</text>
<text class="bt-card__desc">{{ course.suitable }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">教练介绍</text>
<text class="bt-card__desc">{{ course.coachBio }}</text>
</view>
<view v-if="course.reviews?.length" class="bt-card">
<text class="bt-card__title">会员评价</text>
<view v-for="(r, i) in course.reviews" :key="i" class="mi-detail-review">
<text class="mi-detail-review__user">{{ r.user }} · {{ r.score }}</text>
<text class="mi-detail-review__text">{{ r.text }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">取消规则</text>
<text class="bt-card__desc">{{ course.cancelRule }}</text>
<text class="bt-card__desc" style="margin-top:8px;">费用{{ course.price }}</text>
</view>
<view class="bt-footer-actions">
<view
class="bt-btn bt-btn--primary"
:class="{ 'bt-btn--outline': course.full }"
hover-class="mi-tap-btn--hover"
@tap="confirmBook"
>
<text class="bt-btn__text">{{ course.full ? '已约满' : '确认预约' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getCourseById, enrichCourseForDisplay, bookCourse } from '@/common/memberInfo/bookingStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { course: null, courseId: null }
},
onLoad(options) {
this.courseId = options?.id
this.loadCourse()
},
methods: {
loadCourse() {
const store = loadMemberStore()
const raw = getCourseById(store, this.courseId)
this.course = raw ? enrichCourseForDisplay(raw) : null
},
onBack() {
goBackOrTab(PAGE.COURSE_LIST)
},
confirmBook() {
if (!this.course || this.course.full) return
uni.showModal({
title: '确认预约',
content: `预约「${this.course.title}\n${this.course.date} ${this.course.startTime}-${this.course.endTime}`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
const result = bookCourse(store, this.courseId)
if (!result.ok) {
uni.showToast({ title: result.message, icon: 'none' })
return
}
persistMemberStore(store)
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => navigateToPage(PAGE.BOOKING), 800)
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-detail-hero {
width: 100%;
height: 180px;
display: block;
}
.mi-detail-coach {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.mi-detail-coach__avatar {
width: 44px;
height: 44px;
border-radius: 50%;
}
.mi-detail-coach__name {
font-size: 14px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-detail-coach__rating {
font-size: 12px;
color: var(--accent-orange, #FF6B35);
}
.mi-detail-review {
padding: 10px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-detail-review:last-child {
border-bottom: none;
}
.mi-detail-review__user {
font-size: 12px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-detail-review__text {
font-size: 12px;
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.bt-footer-actions .bt-btn {
flex: 1;
}
</style>
@@ -0,0 +1,88 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="课程评价" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ title }}</text>
<text class="bt-card__desc">请为本次课程打分并填写评价</text>
</view>
<view class="bt-card">
<text class="bt-card__title">评分</text>
<view class="mi-eval-stars">
<text
v-for="n in 5"
:key="n"
class="mi-eval-star"
:class="{ 'mi-eval-star--on': n <= score }"
@tap="score = n"
></text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">评价内容</text>
<textarea
v-model="comment"
class="mi-eval-textarea"
placeholder="分享您的上课体验…"
maxlength="200"
/>
</view>
<view class="bt-footer-actions">
<view class="bt-btn bt-btn--primary" hover-class="mi-tap-btn--hover" @tap="submit">
<text class="bt-btn__text">提交评价</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
export default {
components: { MemberInfoSubNav },
data() {
return { title: '', score: 5, comment: '' }
},
onLoad(options) {
this.title = decodeURIComponent(options?.title || '课程评价')
},
methods: {
onBack() { goBackOrTab(PAGE.MY_COURSES) },
submit() {
if (!this.comment.trim()) {
uni.showToast({ title: '请填写评价', icon: 'none' })
return
}
uni.showToast({ title: '评价已提交', icon: 'success' })
setTimeout(() => goBackOrTab(PAGE.MY_COURSES), 800)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.mi-eval-stars { display: flex; flex-direction: row; gap: 8px; margin-top: 8px; }
.mi-eval-star { font-size: 32px; color: #E9EDF2; }
.mi-eval-star--on { color: #FF6B35; }
.mi-eval-textarea {
width: 100%;
min-height: 120px;
margin-top: 8px;
padding: 12px;
border-radius: 12px;
background: var(--bg-gray, #F2F5F9);
font-size: 14px;
box-sizing: border-box;
}
.bt-footer-actions .bt-btn { flex: 1; }
</style>
@@ -0,0 +1,217 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page mi-course-list">
<MemberInfoSubNav title="预约课程" @back="goBack" />
<view class="mi-course-list__filters">
<view class="mi-course-list__date-bar">
<view
class="mi-course-list__mode"
hover-class="mi-tap--hover"
@tap="toggleDateMode"
>
<text class="mi-course-list__mode-text">{{ dateMode === 'day' ? '按天' : '按周' }}</text>
</view>
<scroll-view scroll-x class="mi-course-list__dates">
<view
v-for="d in dateOptions"
:key="d.value"
class="mi-course-list__date"
:class="{ 'mi-course-list__date--active': selectedDate === d.value }"
@tap="selectedDate = d.value"
>
<text class="mi-course-list__date-week">{{ d.week }}</text>
<text class="mi-course-list__date-day">{{ d.day }}</text>
</view>
</scroll-view>
</view>
<scroll-view scroll-x class="mi-course-list__chips">
<view
v-for="t in typeOptions"
:key="t.key"
class="bt-tab"
:class="{ 'bt-tab--active': courseType === t.key }"
@tap="courseType = t.key"
>
<text class="bt-tab__text">{{ t.label }}</text>
</view>
</scroll-view>
<view class="mi-course-list__row">
<picker :range="coaches" @change="onCoachChange">
<view class="mi-course-list__picker">
<text>{{ coach }}</text>
<image class="mi-course-list__arrow" src="/static/images/chevronright3.png" mode="aspectFit" />
</view>
</picker>
<picker :range="periodLabels" @change="onPeriodChange">
<view class="mi-course-list__picker">
<text>{{ periodLabel }}</text>
<image class="mi-course-list__arrow" src="/static/images/chevronright3.png" mode="aspectFit" />
</view>
</picker>
</view>
</view>
<view class="bt-page__body">
<view
v-for="course in displayCourses"
:key="course.id"
class="mi-course-card"
hover-class="mi-tap-card--hover"
@tap="openDetail(course)"
>
<image class="mi-course-card__banner" :src="course.banner" mode="aspectFill" />
<view class="mi-course-card__body">
<view class="mi-course-card__head">
<text class="mi-course-card__title">{{ course.title }}</text>
<view class="mi-course-card__type" :class="'mi-course-card__type--' + course.type">
<text>{{ course.type === 'group' ? '团课' : '私教' }}</text>
</view>
</view>
<view class="mi-course-card__coach">
<image class="mi-course-card__avatar" :src="course.coachAvatar" mode="aspectFill" />
<text>{{ course.coach }}</text>
</view>
<text class="mi-course-card__meta">
{{ course.startTime }}-{{ course.endTime }} · {{ course.location }}
</text>
<view class="mi-course-card__capacity">
<view class="mi-course-card__bar">
<view class="mi-course-card__bar-fill" :style="{ width: course.percent + '%' }"></view>
</view>
<text class="mi-course-card__cap-text">{{ course.enrolled }}/{{ course.capacity }}</text>
<text v-if="course.scarcityLabel" class="mi-course-card__scarcity">{{ course.scarcityLabel }}</text>
</view>
<view class="mi-course-card__footer">
<text class="mi-course-card__price">{{ course.price }}</text>
<view
class="mi-course-card__btn"
:class="{ 'mi-course-card__btn--disabled': course.full }"
@tap.stop="quickBook(course)"
>
<text>{{ course.full ? '已约满' : '预约' }}</text>
</view>
</view>
</view>
</view>
<view v-if="!displayCourses.length" class="bt-empty">
<text class="bt-empty__text">暂无符合条件的课程</text>
</view>
</view>
<view
class="mi-course-list__fab"
hover-class="mi-tap-btn--hover"
@tap="goMyBooking"
>
<image class="mi-course-list__fab-icon" src="/static/images/clock.png" mode="aspectFit" />
<text class="mi-course-list__fab-text">我的预约</text>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import {
courseCatalogMock,
filterCourses,
enrichCourseForDisplay,
getWeekDates,
bookCourse
} from '@/common/memberInfo/bookingStore.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
const WEEK = ['日', '一', '二', '三', '四', '五', '六']
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
dateMode: 'day',
selectedDate: '2024-07-15',
courseType: 'all',
coach: '全部',
period: 'all',
coaches: courseCatalogMock.coaches,
typeOptions: courseCatalogMock.typeOptions,
periodOptions: courseCatalogMock.periodOptions,
courses: []
}
},
computed: {
periodLabels() {
return this.periodOptions.map((p) => p.label)
},
periodLabel() {
return this.periodOptions.find((p) => p.key === this.period)?.label || '全部时段'
},
weekDates() {
return getWeekDates(this.selectedDate)
},
dateOptions() {
const dates = this.dateMode === 'week' ? this.weekDates : this.weekDates
return dates.map((value) => {
const d = new Date(value.replace(/-/g, '/'))
return {
value,
week: WEEK[d.getDay()],
day: `${d.getMonth() + 1}/${d.getDate()}`
}
})
},
displayCourses() {
const filters = {
date: this.dateMode === 'day' ? this.selectedDate : '',
weekDates: this.dateMode === 'week' ? this.weekDates : [],
type: this.courseType,
coach: this.coach,
period: this.period
}
return filterCourses(this.courses, filters).map(enrichCourseForDisplay)
}
},
onShow() {
const store = loadMemberStore()
this.courses = store.courseCatalog.map((c) => ({ ...c }))
if (!this.selectedDate && this.courses.length) {
this.selectedDate = this.courses[0].date
}
},
methods: {
toggleDateMode() {
this.dateMode = this.dateMode === 'day' ? 'week' : 'day'
},
onCoachChange(e) {
this.coach = this.coaches[e.detail.value]
},
onPeriodChange(e) {
this.period = this.periodOptions[e.detail.value].key
},
openDetail(course) {
navigateToPage(`${PAGE.COURSE_DETAIL}?id=${course.id}`)
},
quickBook(course) {
if (course.full) return
navigateToPage(`${PAGE.COURSE_DETAIL}?id=${course.id}`)
},
goMyBooking() {
navigateToPage(PAGE.BOOKING)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
@import '@/common/style/memberInfo/pages/course-list-page.css';
</style>
@@ -0,0 +1,210 @@
<template>
<view class="scroll-container theme-light">
<view class="member-card-page">
<MemberInfoSubNav title="我的会员卡" @back="goBack" />
<view class="member-card-page__body">
<view class="mc-hero">
<view class="mc-hero__top">
<view class="mc-hero__title-row">
<image
class="mc-hero__crown"
src="/static/images/crown.png"
mode="aspectFit"
/>
<text class="mc-hero__name">{{ card.name }}</text>
</view>
<view class="mc-hero__badge">
<text class="mc-hero__badge-text">{{ card.status }}</text>
</view>
</view>
<text class="mc-hero__validity">有效期{{ card.validity }}</text>
<view class="mc-hero__bottom">
<view class="mc-hero__days">
<text class="mc-hero__days-num">{{ remainingDays }}</text>
<text class="mc-hero__days-unit"></text>
</view>
<view
class="mc-hero__renew"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="renewCard"
>
<image
class="mc-hero__renew-icon"
src="/static/images/refreshcw.png"
mode="aspectFit"
/>
<text class="mc-hero__renew-text">立即续费</text>
</view>
</view>
</view>
<view class="mc-records">
<view class="mc-records__header">
<text class="mc-records__title">使用记录</text>
<view class="mc-records__tabs">
<view
v-for="tab in recordTabs"
:key="tab.key"
class="mc-records__tab"
:class="{ 'mc-records__tab--active': activeFilter === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="switchFilter(tab.key)"
>
<text
class="mc-records__tab-text"
:class="{ 'mc-records__tab-text--active': activeFilter === tab.key }"
>
{{ tab.label }}
</text>
</view>
</view>
</view>
<view class="mc-records__divider"></view>
<view
v-for="(item, index) in filteredRecords"
:key="item.id"
class="mc-records__item"
>
<view v-if="index > 0" class="mc-records__divider"></view>
<view
class="mc-records__item-inner"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="showRecordDetail(item)"
>
<view
class="mc-records__icon-wrap"
:class="'mc-records__icon-wrap--' + item.iconTheme"
>
<image
class="mc-records__icon"
:src="item.icon"
mode="aspectFit"
/>
</view>
<view class="mc-records__info">
<text class="mc-records__item-title">{{ item.title }}</text>
<text class="mc-records__item-time">{{ item.time }}</text>
</view>
<text
class="mc-records__value"
:class="'mc-records__value--' + item.valueType"
>
{{ item.value }}
</text>
</view>
</view>
<view v-if="!filteredRecords.length" class="mc-records__empty">
<text class="mc-records__empty-text">暂无记录</text>
</view>
</view>
<view class="mc-rules">
<text class="mc-rules__title">使用规则</text>
<view
v-for="(rule, index) in rules"
:key="index"
class="mc-rules__item"
>
<view class="mc-rules__bullet"></view>
<text class="mc-rules__text">{{ rule }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { memberCardMock } from '@/common/memberInfo/mockData.js'
import {
loadMemberStore,
computeRemainingDays,
renewMemberCard
} from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
card: {},
recordTabs: memberCardMock.recordTabs,
records: [],
rules: memberCardMock.rules,
activeFilter: 'all'
}
},
computed: {
filteredRecords() {
if (this.activeFilter === 'all') {
return this.records
}
return this.records.filter((item) => item.type === this.activeFilter)
},
remainingDays() {
return computeRemainingDays(this.card.validityEnd)
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.card = { ...store.card }
this.records = store.records.map((item) => ({ ...item }))
},
switchFilter(filter) {
this.activeFilter = filter
},
renewCard() {
uni.showModal({
title: '续费会员卡',
content: '确认续费 90 天?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
renewMemberCard(store, 90)
this.activeFilter = 'all'
this.refreshFromStore()
uni.showToast({ title: '续费成功', icon: 'success' })
}
})
},
showRecordDetail(item) {
uni.showModal({
title: item.title,
content: `${item.time}\n变动:${item.value}`,
showCancel: false
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/member-card-page.css';
.mc-records__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.mc-records__empty-text {
font-size: 14px;
color: #8A99B4;
}
</style>
@@ -0,0 +1,240 @@
<template>
<view class="scroll-container theme-light">
<view class="member-page">
<MemberInfoHeader
:user-info="userInfo"
:stats="stats"
@user-info="goUserInfo"
/>
<view class="member-page__body">
<view class="member-page__sections">
<MemberInfoMemberCard
:card-info="cardInfo"
@view-all="goMemberCard"
@renew="onRenewCard"
/>
<MemberInfoQuickActions @action="onQuickAction" />
<MemberInfoBookingList
:items="bookingPreview"
@view-all="goBooking"
@item-tap="goBooking"
/>
<MemberInfoCheckInList
:items="checkIns"
@view-all="onCheckInViewAll"
@item-tap="onCheckInTap"
/>
<MemberInfoBodyReport
:report="bodyReport"
@view-history="onBodyReportHistory"
@view-report="onBodyReportView"
/>
<MemberInfoCouponPoints
:data="couponPoints"
@view-all="onCouponViewAll"
@use-coupon="onUseCoupon"
@redeem-points="onRedeemPoints"
/>
<MemberInfoReferral
:data="referral"
@view-rules="onReferralRules"
/>
<MemberInfoSettings @setting="onSetting" />
<MemberInfoLogout @logout="handleLogout" />
</view>
</view>
</view>
</view>
<TabBar :active="4" />
</template>
<script>
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import {
loadMemberStore,
getCenterPageData,
renewMemberCard
} from '@/common/memberInfo/store.js'
import MemberInfoHeader from '@/components/memberInfo/MemberInfoHeader.vue'
import MemberInfoMemberCard from '@/components/memberInfo/MemberInfoMemberCard.vue'
import MemberInfoQuickActions from '@/components/memberInfo/MemberInfoQuickActions.vue'
import MemberInfoBookingList from '@/components/memberInfo/MemberInfoBookingList.vue'
import MemberInfoCheckInList from '@/components/memberInfo/MemberInfoCheckInList.vue'
import MemberInfoBodyReport from '@/components/memberInfo/MemberInfoBodyReport.vue'
import MemberInfoCouponPoints from '@/components/memberInfo/MemberInfoCouponPoints.vue'
import MemberInfoReferral from '@/components/memberInfo/MemberInfoReferral.vue'
import MemberInfoSettings from '@/components/memberInfo/MemberInfoSettings.vue'
import MemberInfoLogout from '@/components/memberInfo/MemberInfoLogout.vue'
import TabBar from '@/components/TabBar.vue'
export default {
components: {
MemberInfoHeader,
MemberInfoMemberCard,
MemberInfoQuickActions,
MemberInfoBookingList,
MemberInfoCheckInList,
MemberInfoBodyReport,
MemberInfoCouponPoints,
MemberInfoReferral,
MemberInfoSettings,
MemberInfoLogout,
TabBar
},
data() {
return {
userInfo: {},
stats: {},
cardInfo: {},
bookingPreview: [],
checkIns: [],
bodyReport: {},
couponPoints: {},
referral: {}
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
const pageData = getCenterPageData(store)
this.userInfo = pageData.userInfo
this.stats = pageData.stats
this.cardInfo = pageData.cardInfo
this.bookingPreview = pageData.bookingPreview
this.checkIns = pageData.checkIns
this.bodyReport = pageData.bodyReport
this.couponPoints = pageData.couponPoints
this.referral = pageData.referral
},
goMemberCard() {
navigateToPage(PAGE.MEMBER_CARD)
},
goBooking() {
navigateToPage(PAGE.BOOKING)
},
goUserInfo() {
navigateToPage(PAGE.USER_INFO)
},
onRenewCard() {
uni.showModal({
title: '续费会员卡',
content: '确认续费 90 天?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
renewMemberCard(store, 90)
this.refreshFromStore()
uni.showToast({ title: '续费成功', icon: 'success' })
}
})
},
onQuickAction(type) {
const routes = {
booking: PAGE.COURSE_LIST,
bodyTest: PAGE.BODY_TEST_HOME,
bodyReport: PAGE.BODY_TEST_HISTORY,
trainReport: PAGE.TRAIN_REPORT,
coupon: PAGE.COUPONS,
points: PAGE.POINTS,
referral: PAGE.REFERRAL,
course: PAGE.MY_COURSES
}
if (routes[type]) {
navigateToPage(routes[type])
}
},
onCheckInViewAll() {
navigateToPage(PAGE.CHECK_IN_HISTORY)
},
onCheckInTap(item) {
uni.showModal({
title: item.title,
content: item.time,
showCancel: false
})
},
onBodyReportHistory() {
navigateToPage(PAGE.BODY_TEST_HISTORY)
},
onBodyReportView() {
const id = this.bodyReport?.recordId
const url = id
? `${PAGE.BODY_TEST_REPORT}?id=${id}`
: PAGE.BODY_TEST_HISTORY
navigateToPage(url)
},
onCouponViewAll() {
navigateToPage(PAGE.COUPONS)
},
onUseCoupon() {
navigateToPage(PAGE.COUPONS)
},
onRedeemPoints() {
navigateToPage(PAGE.POINTS)
},
onReferralRules() {
navigateToPage(PAGE.REFERRAL)
},
onSetting(key) {
const labels = {
notify: '通知设置',
password: '修改密码',
privacy: '隐私政策',
nfc: 'NFC 门禁卡',
delete: '注销账户'
}
if (key === 'delete') {
uni.showModal({
title: '注销账户',
content: '注销后数据将无法恢复,确定继续吗?',
confirmColor: '#E74C3C'
})
return
}
if (key === 'privacy') {
uni.showModal({
title: '隐私政策',
content: '我们重视您的隐私,详细政策请前往官网查看。',
showCancel: false
})
return
}
uni.showToast({
title: `${labels[key] || '设置'}开发中`,
icon: 'none'
})
},
handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '已退出', icon: 'success' })
}
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/member-info-page.css';
@import '@/common/style/memberInfo/member-info-header.css';
@import '@/common/style/memberInfo/member-info-member-card.css';
@import '@/common/style/memberInfo/member-info-quick-actions.css';
@import '@/common/style/memberInfo/member-info-booking-list.css';
@import '@/common/style/memberInfo/member-info-check-in-list.css';
@import '@/common/style/memberInfo/member-info-body-report.css';
@import '@/common/style/memberInfo/member-info-coupon-points.css';
@import '@/common/style/memberInfo/member-info-referral.css';
@import '@/common/style/memberInfo/member-info-settings.css';
@import '@/common/style/memberInfo/member-info-logout.css';
</style>
@@ -0,0 +1,225 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的课程" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
@tap="activeTab = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
</view>
<view class="bt-page__body">
<!-- 团课 -->
<template v-if="activeTab === 'group'">
<view class="mi-mod-subtabs">
<text
class="mi-mod-subtab"
:class="{ 'mi-mod-subtab--on': groupSub === 'ongoing' }"
@tap="groupSub = 'ongoing'"
>进行中</text>
<text
class="mi-mod-subtab"
:class="{ 'mi-mod-subtab--on': groupSub === 'completed' }"
@tap="groupSub = 'completed'"
>已完成</text>
</view>
<view
v-for="item in groupList"
:key="item.id"
class="mi-mod-course-card"
hover-class="mi-tap-card--hover"
@tap="onGroupCourse(item)"
>
<image class="mi-mod-course-card__banner" :src="item.banner" mode="aspectFill" />
<view class="mi-mod-course-card__content">
<text class="mi-mod-course-card__title">{{ item.title }}</text>
<text class="mi-mod-course-card__coach">{{ item.coach }}</text>
<view class="mi-mod-course-card__progress">
<view class="mi-mod-course-card__progress-bar">
<view class="mi-mod-course-card__progress-fill" :style="{ width: pct(item) + '%' }"></view>
</view>
<text class="mi-mod-course-card__progress-text">{{ item.progress }}/{{ item.total }}</text>
</view>
<text class="mi-mod-course-card__meta">{{ item.schedule }} · {{ item.location }}</text>
<view v-if="groupSub === 'ongoing' && item.canCancel" class="mi-mod-course-card__action" @tap.stop="goBooking">
<text>取消预约</text>
</view>
<view v-if="groupSub === 'completed' && item.canEvaluate" class="mi-mod-course-card__action" @tap.stop="evaluate(item)">
<text>去评价</text>
</view>
</view>
</view>
</template>
<!-- 私教 -->
<template v-else-if="activeTab === 'private'">
<view class="bt-card">
<text class="bt-card__title">剩余课时 {{ privateData.remaining }} </text>
<view class="mi-detail-coach">
<image class="mi-detail-coach__avatar" :src="privateData.coachAvatar" mode="aspectFill" />
<view>
<text class="mi-detail-coach__name">{{ privateData.coach }}</text>
<text class="mi-detail-coach__rating">下次 {{ privateData.nextClass }}</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">已约课程</text>
<view v-for="b in privateData.bookings" :key="b.id" class="mi-mod-session">
<text class="mi-mod-session__title">{{ b.title }}</text>
<text class="mi-mod-session__meta">{{ b.time }} · {{ b.location }} · {{ b.status }}</text>
</view>
</view>
</template>
<!-- 线上课 -->
<template v-else-if="activeTab === 'online'">
<view
v-for="item in onlineList"
:key="item.id"
class="mi-mod-course-card"
hover-class="mi-tap-card--hover"
@tap="goOnline(item)"
>
<image class="mi-mod-course-card__banner" :src="item.cover" mode="aspectFill" />
<view class="mi-mod-course-card__content">
<text class="mi-mod-course-card__title">{{ item.title }}</text>
<text class="mi-mod-course-card__meta">{{ item.duration }} · 进度 {{ item.progress }}%</text>
<text v-if="item.type === 'live'" class="mi-mod-course-card__next">直播 {{ item.liveTime }}</text>
</view>
</view>
</template>
<!-- 训练营 -->
<template v-else>
<view
v-for="item in packageList"
:key="item.id"
class="mi-mod-course-card"
>
<image class="mi-mod-course-card__banner" :src="item.banner" mode="aspectFill" />
<view class="mi-mod-course-card__content">
<text class="mi-mod-course-card__title">{{ item.title }}</text>
<text class="mi-mod-course-card__coach">{{ item.coach }}</text>
<view class="mi-mod-course-card__progress">
<view class="mi-mod-course-card__progress-bar">
<view class="mi-mod-course-card__progress-fill" :style="{ width: pct(item) + '%' }"></view>
</view>
<text class="mi-mod-course-card__progress-text">{{ item.progress }}/{{ item.total }} </text>
</view>
<text class="mi-mod-course-card__meta">{{ item.schedule }}</text>
</view>
</view>
</template>
<view v-if="isEmpty" class="bt-empty">
<text class="bt-empty__text">暂无课程</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { moduleMock, getMyCoursesData } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: moduleMock.myCourseTabs,
activeTab: 'group',
groupSub: 'ongoing',
privateData: {},
onlineList: [],
packageList: [],
groupData: { ongoing: [], completed: [] }
}
},
computed: {
groupList() {
return this.groupData[this.groupSub] || []
},
isEmpty() {
if (this.activeTab === 'group') return !this.groupList.length
if (this.activeTab === 'private') return !this.privateData.bookings?.length
if (this.activeTab === 'online') return !this.onlineList.length
return !this.packageList.length
}
},
onShow() { this.refresh() },
methods: {
refresh() {
const store = loadMemberStore()
this.groupData = getMyCoursesData(store, 'group')
this.privateData = getMyCoursesData(store, 'private')
this.onlineList = getMyCoursesData(store, 'online').list || []
this.packageList = getMyCoursesData(store, 'package').list || []
},
pct(item) {
return item.total ? Math.min(100, Math.round((item.progress / item.total) * 100)) : 0
},
goBooking() { navigateToPage(PAGE.BOOKING) },
goOnline(item) { navigateToPage(`${PAGE.ONLINE_COURSE}?id=${item.id}`) },
evaluate(item) {
navigateToPage(`${PAGE.COURSE_EVALUATE}?title=${encodeURIComponent(item.title)}`)
},
onGroupCourse(item) {
uni.showModal({
title: item.title,
content: `${item.coach}\n${item.schedule}\n${item.location}`,
showCancel: false
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-mod-subtabs {
display: flex;
flex-direction: row;
gap: 16px;
margin-bottom: 12px;
}
.mi-mod-subtab {
font-size: 14px;
color: var(--text-muted, #5E6F8D);
}
.mi-mod-subtab--on {
color: var(--primary-dark, #0B2B4B);
font-weight: 700;
}
.mi-mod-course-card__action {
margin-top: 6px;
}
.mi-mod-course-card__action text {
font-size: 12px;
font-weight: 600;
color: var(--accent-orange, #FF6B35);
}
</style>
@@ -0,0 +1,119 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="course">
<MemberInfoSubNav title="线上课程" @back="onBack" />
<image class="mi-detail-hero" :src="course.cover" mode="aspectFill" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ course.title }}</text>
<text class="bt-card__desc">时长 {{ course.duration }} · 进度 {{ course.progress }}%</text>
<view class="mi-mod-course-card__progress" style="margin-top:12px;">
<view class="mi-mod-course-card__progress-bar">
<view class="mi-mod-course-card__progress-fill" :style="{ width: course.progress + '%' }"></view>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">视频播放</text>
<view class="mi-online-player">
<image class="mi-online-player__play" src="/static/images/play.png" mode="aspectFit" />
<text class="mi-online-player__hint">点击播放支持倍速与拖拽</text>
</view>
<view class="mi-online-controls">
<text class="mi-online-controls__btn" @tap="seek(-10)">-10s</text>
<text class="mi-online-controls__btn" @tap="togglePlay">{{ playing ? '暂停' : '播放' }}</text>
<text class="mi-online-controls__btn" @tap="seek(10)">+10s</text>
<text class="mi-online-controls__btn" @tap="changeSpeed">倍速 {{ speed }}x</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">课程目录</text>
<view v-for="ch in course.chapters" :key="ch.id" class="mi-online-chapter">
<text class="mi-online-chapter__title">{{ ch.title }}</text>
<text class="mi-online-chapter__meta">{{ ch.duration }} · {{ ch.done ? '已学完' : '未学习' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getOnlineCourseById, updateOnlineProgress } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { course: null, playing: false, speed: 1 }
},
onLoad(options) {
this.course = getOnlineCourseById(loadMemberStore(), options?.id)
},
methods: {
onBack() { goBackOrTab(PAGE.MY_COURSES) },
togglePlay() {
this.playing = !this.playing
if (this.playing && this.course.progress < 100) {
const store = loadMemberStore()
updateOnlineProgress(store, this.course.id, Math.min(100, this.course.progress + 5))
persistMemberStore(store)
this.course.progress = Math.min(100, this.course.progress + 5)
}
},
seek(sec) { uni.showToast({ title: `跳转 ${sec > 0 ? '+' : ''}${sec}s`, icon: 'none' }) },
changeSpeed() {
const speeds = [1, 1.25, 1.5, 2]
const idx = (speeds.indexOf(this.speed) + 1) % speeds.length
this.speed = speeds[idx]
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-online-player {
height: 180px;
border-radius: 12px;
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.mi-online-player__play { width: 48px; height: 48px; }
.mi-online-player__hint { font-size: 12px; color: rgba(255,255,255,0.7); }
.mi-online-controls {
display: flex;
flex-direction: row;
justify-content: space-around;
margin-top: 12px;
}
.mi-online-controls__btn {
font-size: 12px;
color: var(--primary-deep, #1A4A6F);
font-weight: 600;
}
.mi-online-chapter {
padding: 10px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-online-chapter__title { font-size: 14px; color: var(--text-dark, #1E2A3A); display: block; }
.mi-online-chapter__meta { font-size: 11px; color: var(--text-muted, #5E6F8D); }
</style>
@@ -0,0 +1,92 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的积分" @back="goBack" />
<view class="bt-page__body">
<view class="mi-mod-points-hero">
<text class="mi-mod-points-hero__label">当前积分</text>
<text class="mi-mod-points-hero__value">{{ balance }}</text>
<text class="mi-mod-points-hero__tip">{{ config.rate }} · {{ config.rule }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">快捷入口</text>
<view class="bt-grid">
<view class="bt-grid__item" hover-class="mi-tap--hover" @tap="goMall">
<image class="bt-grid__icon" src="/static/images/star.png" mode="aspectFit" />
<text class="bt-grid__label">积分商城</text>
</view>
<view class="bt-grid__item" hover-class="mi-tap--hover" @tap="goHistory">
<image class="bt-grid__icon" src="/static/images/clock.png" mode="aspectFit" />
<text class="bt-grid__label">积分明细</text>
</view>
<view class="bt-grid__item" hover-class="mi-tap--hover" @tap="checkIn">
<image class="bt-grid__icon" src="/static/images/usercheck.png" mode="aspectFit" />
<text class="bt-grid__label">签到赚积分</text>
</view>
<view class="bt-grid__item" hover-class="mi-tap--hover" @tap="goReferral">
<image class="bt-grid__icon" src="/static/images/share2.png" mode="aspectFit" />
<text class="bt-grid__label">邀请赚积分</text>
</view>
</view>
</view>
<view class="bt-card">
<view class="bt-trend-link" style="border:none;margin:0;padding:0;" @tap="goHistory">
<text class="bt-card__title">最近明细</text>
<text class="bt-trend-link__text">查看全部</text>
</view>
<view v-for="item in historyPreview" :key="item.id" class="mi-mod-points-row">
<view class="mi-mod-points-row__info">
<text class="mi-mod-points-row__title">{{ item.title }}</text>
<text class="mi-mod-points-row__time">{{ item.time }}</text>
</view>
<text
class="mi-mod-points-row__amount"
:class="item.amount > 0 ? 'mi-mod-points-row__amount--earn' : 'mi-mod-points-row__amount--spend'"
>{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { getPointsPageData } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return { balance: 0, config: {}, historyPreview: [] }
},
onShow() {
const data = getPointsPageData(loadMemberStore())
this.balance = data.balance
this.config = data.config
this.historyPreview = data.history.slice(0, 5)
},
methods: {
goMall() { navigateToPage(PAGE.POINTS_MALL) },
goHistory() { navigateToPage(PAGE.POINTS_HISTORY) },
goReferral() { navigateToPage(PAGE.REFERRAL) },
checkIn() { uni.showToast({ title: '签到成功 +10 积分', icon: 'success' }) }
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,86 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="积分明细" @back="onBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': filter === tab.key }"
@tap="filter = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
</view>
<view class="bt-page__body">
<view
v-for="item in list"
:key="item.id"
class="mi-mod-points-row"
>
<view class="mi-mod-points-row__info">
<text class="mi-mod-points-row__title">{{ item.title }}</text>
<text class="mi-mod-points-row__time">{{ item.time }}</text>
</view>
<view class="mi-mod-points-row__right">
<text
class="mi-mod-points-row__amount"
:class="item.amount > 0 ? 'mi-mod-points-row__amount--earn' : 'mi-mod-points-row__amount--spend'"
>
{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
</text>
<text class="mi-mod-points-row__balance">余额 {{ item.balance }}</text>
</view>
</view>
<view v-if="!list.length" class="bt-empty">
<text class="bt-empty__text">暂无明细</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { filterPointsHistory } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
filter: 'all',
tabs: [
{ key: 'all', label: '全部' },
{ key: 'earn', label: '获取' },
{ key: 'spend', label: '消耗' }
],
list: []
}
},
watch: {
filter() { this.loadList() }
},
onShow() { this.loadList() },
methods: {
onBack() { goBackOrTab(PAGE.POINTS) },
loadList() {
const store = loadMemberStore()
this.list = filterPointsHistory(store, this.filter)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,93 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="积分商城" @back="onBack" />
<view class="bt-page__body">
<view class="mi-mod-points-hero">
<text class="mi-mod-points-hero__label">可用积分</text>
<text class="mi-mod-points-hero__value">{{ balance }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">兑换商品</text>
<view class="mi-mod-rewards">
<view
v-for="item in rewards"
:key="item.id"
class="mi-mod-reward"
hover-class="mi-tap-card--hover"
@tap="redeem(item)"
>
<image class="mi-mod-reward__icon" :src="item.icon" mode="aspectFit" />
<text class="mi-mod-reward__name">{{ item.name }}</text>
<text class="mi-mod-reward__cost">{{ item.cost }} 积分</text>
<text class="mi-mod-reward__stock">剩余 {{ item.stock }}</text>
</view>
</view>
</view>
<view v-if="redeemRecords.length" class="bt-card">
<text class="bt-card__title">兑换记录</text>
<view v-for="r in redeemRecords" :key="r.id" class="mi-mod-points-row">
<view class="mi-mod-points-row__info">
<text class="mi-mod-points-row__title">{{ r.name }}</text>
<text class="mi-mod-points-row__time">{{ r.time }}</text>
</view>
<text class="mi-mod-points-row__amount mi-mod-points-row__amount--spend">-{{ r.cost }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getPointsPageData, redeemPointsReward } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { balance: 0, rewards: [], redeemRecords: [] }
},
onShow() {
const data = getPointsPageData(loadMemberStore())
this.balance = data.balance
this.rewards = data.rewards
this.redeemRecords = data.redeemRecords
},
methods: {
onBack() { goBackOrTab(PAGE.POINTS) },
redeem(item) {
uni.showModal({
title: '确认兑换',
content: `使用 ${item.cost} 积分兑换「${item.name}」?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
const result = redeemPointsReward(store, item.id)
uni.showToast({ title: result.message, icon: result.ok ? 'success' : 'none' })
if (result.ok) {
persistMemberStore(store)
const data = getPointsPageData(store)
this.balance = data.balance
this.rewards = data.rewards
this.redeemRecords = data.redeemRecords
}
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,158 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="邀请好友" @back="goBack" />
<view class="bt-page__body">
<view class="mi-mod-referral-hero">
<text class="mi-mod-referral-hero__title">邀请好友一起健身</text>
<text class="mi-mod-referral-hero__desc">好友注册/购课双方均可获得积分奖励</text>
<view class="mi-mod-referral-code">
<text class="mi-mod-referral-code__label">我的邀请码</text>
<text class="mi-mod-referral-code__value">{{ data.code }}</text>
</view>
<view class="bt-hero__actions">
<view
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="copyCode"
>
<text class="bt-btn__text">复制邀请码</text>
</view>
<view
class="bt-btn bt-btn--ghost"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="shareInvite"
>
<image class="bt-btn__icon" src="/static/images/share2.png" mode="aspectFit" />
<text class="bt-btn__text">分享给好友</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">邀请成果</text>
<view class="mi-mod-referral-stats">
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--orange">{{ data.invited }}</text>
<text class="mi-mod-referral-stat__label">已推荐</text>
</view>
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--green">{{ data.registered }}</text>
<text class="mi-mod-referral-stat__label">已注册</text>
</view>
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--amber">{{ data.purchased }}</text>
<text class="mi-mod-referral-stat__label">已购课</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">已获得奖励</text>
<view class="mi-mod-referral-stats">
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--orange">{{ data.rewardSummary?.totalPoints || 0 }}</text>
<text class="mi-mod-referral-stat__label">积分</text>
</view>
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--green">{{ data.rewardSummary?.totalCoupons || 0 }}</text>
<text class="mi-mod-referral-stat__label">优惠券</text>
</view>
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--amber">{{ data.rewardSummary?.pendingCount || 0 }}</text>
<text class="mi-mod-referral-stat__label">待发放</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">邀请记录</text>
<view
v-for="item in data.records"
:key="item.id"
class="mi-mod-referral-row"
>
<view class="mi-mod-referral-row__info">
<text class="mi-mod-referral-row__name">{{ item.name }}</text>
<text class="mi-mod-referral-row__time">{{ item.time }}</text>
</view>
<view class="mi-mod-referral-row__right">
<text class="mi-mod-referral-row__status">{{ item.statusLabel }}</text>
<text class="mi-mod-referral-row__reward">{{ item.reward }}</text>
<text class="mi-mod-referral-row__reward-status">{{ item.rewardStatus }}</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">活动规则</text>
<view class="bt-advice-list">
<view
v-for="(rule, idx) in data.rules"
:key="idx"
class="bt-advice-item"
>
<view class="bt-advice-item__dot"></view>
<text class="bt-advice-item__text">{{ rule }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { getReferralPageData } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
data: { code: '', invited: 0, registered: 0, purchased: 0, records: [], rules: [] }
}
},
onShow() {
const store = loadMemberStore()
this.data = getReferralPageData(store)
},
methods: {
copyCode() {
uni.setClipboardData({
data: this.data.code,
success: () => uni.showToast({ title: '邀请码已复制', icon: 'success' })
})
},
shareInvite() {
uni.showActionSheet({
itemList: ['分享给微信好友', '生成邀请海报', '复制分享链接'],
success: (res) => {
const msgs = ['已唤起分享', '海报已生成', '链接已复制']
uni.showToast({ title: msgs[res.tapIndex] || '分享成功', icon: 'none' })
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-mod-referral-hero .bt-hero__actions {
margin-top: 16px;
}
</style>
@@ -0,0 +1,138 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="训练报告" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="p in periods"
:key="p.key"
class="bt-tab"
:class="{ 'bt-tab--active': period === p.key }"
@tap="switchPeriod(p.key)"
>
<text class="bt-tab__text">{{ p.label }}</text>
</view>
</view>
<view class="bt-page__body">
<view class="bt-metrics">
<view class="bt-metric"><text class="bt-metric__value">{{ report.summary.sessions }}</text><text class="bt-metric__label">完成课程</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ report.summary.hours }}</text><text class="bt-metric__label">运动时长(h)</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ report.summary.calories }}</text><text class="bt-metric__label">消耗(kcal)</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ report.summary.visits }}</text><text class="bt-metric__label">到店次数</text></view>
</view>
<view class="bt-card">
<text class="bt-card__title">运动时长趋势</text>
<BodyTestTrendChart :points="report.trendHours" unit="h" :width="chartWidth" :height="150" />
</view>
<view class="bt-card">
<text class="bt-card__title">消耗卡路里趋势</text>
<BodyTestTrendChart :points="report.trendCalories" unit="" :width="chartWidth" :height="150" />
</view>
<view class="mi-mod-tabs">
<view
v-for="t in typeFilters"
:key="t.key"
class="bt-tab"
:class="{ 'bt-tab--active': typeFilter === t.key }"
@tap="typeFilter = t.key"
>
<text class="bt-tab__text">{{ t.label }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">课程完成列表</text>
<view
v-for="item in sessions"
:key="item.id"
class="mi-mod-session"
hover-class="mi-tap-row--hover"
@tap="goSession(item)"
>
<view class="mi-mod-session__head">
<text class="mi-mod-session__title">{{ item.title }}</text>
<view class="mi-mod-session__tag" :class="'mi-mod-session__tag--' + item.type">
<text class="mi-mod-session__tag-text">{{ item.typeLabel }}</text>
</view>
</view>
<text class="mi-mod-session__meta">{{ item.date }} · {{ item.time }} · {{ item.coach }}</text>
<view class="mi-mod-session__footer">
<text class="mi-mod-session__stat">{{ item.duration }}</text>
<text class="mi-mod-session__stat">{{ item.calories }} kcal</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import BodyTestTrendChart from '@/components/memberInfo/BodyTestTrendChart.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { getTrainingReportData, filterTrainingSessions } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav, BodyTestTrendChart },
mixins: [subPageMixin],
data() {
return {
period: 'week',
periods: [
{ key: 'week', label: '本周' },
{ key: 'month', label: '本月' }
],
typeFilter: 'all',
typeFilters: [
{ key: 'all', label: '全部' },
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' },
{ key: 'free', label: '自由' }
],
report: { summary: {}, trendHours: [], trendCalories: [], sessions: [] },
sessions: [],
chartWidth: 300
}
},
onLoad() {
this.chartWidth = uni.getSystemInfoSync().windowWidth - 64
this.refresh()
},
onShow() { this.refresh() },
methods: {
refresh() {
const store = loadMemberStore()
this.report = getTrainingReportData(store, this.period)
this.sessions = filterTrainingSessions(store, { type: this.typeFilter })
},
switchPeriod(key) {
this.period = key
this.refresh()
},
goSession(item) {
navigateToPage(`${PAGE.TRAIN_SESSION}?id=${item.id}`)
}
},
watch: {
typeFilter() {
this.sessions = filterTrainingSessions(loadMemberStore(), { type: this.typeFilter })
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,58 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="session">
<MemberInfoSubNav title="训练详情" @back="onBack" />
<view class="bt-page__body">
<view class="bt-score-card">
<view class="bt-score-card__info">
<text class="bt-score-card__title">{{ session.title }}</text>
<text class="bt-score-card__date">{{ session.date }} {{ session.time }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">训练数据</text>
<view class="bt-metrics">
<view class="bt-metric"><text class="bt-metric__value">{{ session.duration }}</text><text class="bt-metric__label">时长</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ session.calories }}</text><text class="bt-metric__label">消耗(kcal)</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ session.heartRate }}</text><text class="bt-metric__label">平均心率</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ session.typeLabel }}</text><text class="bt-metric__label">类型</text></view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">签到时间</text>
<text class="bt-card__desc">{{ session.checkInTime }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">教练评语</text>
<text class="bt-card__desc">{{ session.comment }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getTrainingSessionById } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() { return { session: null } },
onLoad(options) {
this.session = getTrainingSessionById(loadMemberStore(), options?.id)
},
methods: {
onBack() { goBackOrTab(PAGE.TRAIN_REPORT) }
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,483 @@
<template>
<view class="scroll-container theme-light">
<view class="Pixso-frame-2_791">
<MemberInfoSubNav title="个人信息" @back="goBack" />
<view class="Pixso-frame-2_802">
<view class="frame-content-2_802">
<view class="avatar-block">
<view class="avatar-block__inner">
<image
class="avatar-block__photo"
:key="avatarKey"
:src="avatarSrc"
mode="aspectFill"
@tap="previewAvatar"
/>
<view
class="avatar-block__change"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap.stop="changeAvatar"
>
<image
class="avatar-block__icon"
src="/static/images/camera.png"
mode="aspectFit"
/>
<text class="avatar-block__text">更换</text>
</view>
</view>
</view>
<view class="Pixso-frame-2_810">
<view class="frame-content-2_810">
<view
class="Pixso-frame-2_811 user-info-row"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="editName"
>
<view class="frame-content-2_811">
<text class="Pixso-paragraph-2_812">姓名</text>
<text class="Pixso-paragraph-2_813">{{ name }}</text>
<image
class="Pixso-vector-2_814"
src="/static/images/chevronright1.png"
mode="aspectFit"
/>
</view>
</view>
<view class="Pixso-frame-2_816"></view>
<view
class="Pixso-frame-2_817 user-info-row"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="rebindPhone"
>
<view class="frame-content-2_817">
<text class="Pixso-paragraph-2_818">手机号</text>
<view class="Pixso-frame-2_819">
<view class="frame-content-2_819">
<text class="Pixso-paragraph-2_820">{{ displayPhone }}</text>
<view
class="Pixso-frame-2_821"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="rebindPhone"
>
<text class="Pixso-paragraph-2_822">换绑</text>
</view>
</view>
</view>
<image
class="Pixso-vector-2_823"
src="/static/images/chevronright.png"
mode="aspectFit"
/>
</view>
</view>
<view class="Pixso-frame-2_825"></view>
<view class="Pixso-frame-2_826">
<view class="frame-content-2_826">
<text class="Pixso-paragraph-2_827">性别</text>
<view class="Pixso-frame-2_828">
<view class="frame-content-2_828">
<view
class="gender-btn"
:class="{ 'gender-btn--active': gender === 'female' }"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="selectGender('female')"
>
<image
class="gender-btn__icon"
src="/static/images/venus.png"
mode="aspectFit"
/>
<text class="gender-btn__text"></text>
</view>
<view
class="gender-btn"
:class="{ 'gender-btn--active': gender === 'male' }"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="selectGender('male')"
>
<image
class="gender-btn__icon"
src="/static/images/mars.png"
mode="aspectFit"
/>
<text class="gender-btn__text"></text>
</view>
</view>
</view>
</view>
</view>
<view class="Pixso-frame-2_841"></view>
<picker mode="date" :value="birthdayValue" @change="onBirthdayChange">
<view
class="Pixso-frame-2_842 user-info-row"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
>
<view class="frame-content-2_842">
<text class="Pixso-paragraph-2_843">生日</text>
<text class="Pixso-paragraph-2_844">{{ birthday }}</text>
<image
class="Pixso-vector-2_845"
src="/static/images/chevronright0.png"
mode="aspectFit"
/>
</view>
</view>
</picker>
<view class="Pixso-frame-2_847"></view>
<view class="Pixso-frame-2_848">
<view class="frame-content-2_848">
<text class="Pixso-paragraph-2_849">身高</text>
<view
class="Pixso-frame-2_850 user-info-measure"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="editHeight"
>
<view class="frame-content-2_850">
<text class="Pixso-paragraph-2_851">{{ height }}</text>
<text class="Pixso-paragraph-2_852">cm</text>
</view>
</view>
<view class="Pixso-frame-2_853"></view>
<text class="Pixso-paragraph-2_854">体重</text>
<view
class="Pixso-frame-2_855 user-info-measure"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="editWeight"
>
<view class="frame-content-2_855">
<text class="Pixso-paragraph-2_856">{{ weight }}</text>
<text class="Pixso-paragraph-2_857">kg</text>
</view>
</view>
</view>
</view>
<view class="Pixso-frame-2_858"></view>
<view class="Pixso-frame-2_859">
<view class="frame-content-2_859">
<text class="Pixso-paragraph-2_860">微信</text>
<text class="Pixso-paragraph-2_861">已授权绑定</text>
<view class="stroke-wrapper-2_862">
<view class="Pixso-frame-2_862">
<text class="Pixso-paragraph-2_863">已绑定</text>
</view>
<view class="stroke-2_862"></view>
</view>
</view>
</view>
</view>
</view>
<view class="Pixso-frame-2_864">
<view class="frame-content-2_864">
<text class="Pixso-paragraph-2_865">健身目标</text>
<view class="goal-tags">
<view
v-for="goal in fitnessGoalOptions"
:key="goal"
class="goal-tag"
:class="{ 'goal-tag--selected': isGoalSelected(goal) }"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="toggleGoal(goal)"
>
<text class="goal-tag__text">{{ goal }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="user-info-save-bar">
<view
class="user-info-save-bar__btn"
hover-class="mi-tap-save--hover"
:hover-stay-time="150"
@tap="handleSave"
>
<text class="user-info-save-bar__text">保存</text>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { fitnessGoalOptions } from '@/common/memberInfo/mockData.js'
import { loadMemberStore, saveUserProfile } from '@/common/memberInfo/store.js'
import { previewImage, persistChosenImage } from '@/common/memberInfo/media.js'
import { maskPhone, normalizePhoneForStore } from '@/common/memberInfo/format.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
import {
validateName,
validatePhoneForRebind,
validateHeight,
validateWeight,
validateBirthday,
validateFitnessGoals,
validateUserProfile,
showValidationError
} from '@/common/memberInfo/validate.js'
const DEFAULT_AVATAR = '/static/images/AvatarEditWrap.png'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
name: '',
phone: '',
gender: 'female',
birthday: '',
height: '',
weight: '',
fitnessGoals: [],
avatar: DEFAULT_AVATAR,
avatarKey: 0,
avatarDirty: false,
fitnessGoalOptions
}
},
computed: {
avatarSrc() {
return this.avatar || DEFAULT_AVATAR
},
displayPhone() {
return maskPhone(this.phone)
},
birthdayValue() {
const match = String(this.birthday).match(/(\d{4})年(\d{2})月(\d{2})日/)
if (match) {
return `${match[1]}-${match[2]}-${match[3]}`
}
return '1995-06-15'
}
},
onShow() {
// 从相册/相机返回会再次触发 onShow,不能覆盖刚选未保存的头像
this.loadProfile({ preserveLocalAvatar: true })
},
methods: {
loadProfile(options = {}) {
const store = loadMemberStore()
const profile = store.profile
this.name = profile.name
this.phone = profile.phone
this.gender = profile.gender
this.birthday = profile.birthday
this.height = profile.height
this.weight = profile.weight
this.fitnessGoals = [...(profile.fitnessGoals || [])]
const storedAvatar = profile.avatar || DEFAULT_AVATAR
const hasUnsavedLocalAvatar =
options.preserveLocalAvatar &&
this.avatarDirty &&
this.avatar &&
this.avatar !== storedAvatar
if (!hasUnsavedLocalAvatar) {
this.setAvatar(storedAvatar)
this.avatarDirty = false
}
},
setAvatar(path) {
const next = path || DEFAULT_AVATAR
if (this.avatar !== next) {
this.avatar = next
this.avatarKey += 1
}
},
getProfilePayload() {
return {
name: this.name,
phone: normalizePhoneForStore(this.phone),
gender: this.gender,
birthday: this.birthday,
height: this.height,
weight: this.weight,
fitnessGoals: [...this.fitnessGoals],
avatar: this.avatar
}
},
handleSave() {
const result = validateUserProfile(
this.getProfilePayload(),
this.fitnessGoalOptions
)
if (!result.ok) {
showValidationError(result.message)
return
}
const store = loadMemberStore()
saveUserProfile(store, result.value)
this.applyValidatedProfile(result.value)
this.avatarDirty = false
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => this.goBack(), 600)
},
applyValidatedProfile(profile) {
this.name = profile.name
this.phone = profile.phone
this.gender = profile.gender
this.birthday = profile.birthday
this.height = profile.height
this.weight = profile.weight
this.fitnessGoals = [...profile.fitnessGoals]
},
changeAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const tempPath =
res.tempFilePaths?.[0] || res.tempFiles?.[0]?.tempFilePath
if (!tempPath) return
// 真机先用 tempFilePath 立即展示,避免 saveFile 异步期间被 onShow 覆盖
this.setAvatar(tempPath)
this.avatarDirty = true
uni.showToast({ title: '头像已选择', icon: 'success' })
persistChosenImage(tempPath).then((savedPath) => {
if (savedPath && savedPath !== this.avatar) {
this.setAvatar(savedPath)
}
})
}
})
},
previewAvatar() {
previewImage(this.avatarSrc, DEFAULT_AVATAR)
},
editName() {
uni.showModal({
title: '修改姓名',
editable: true,
placeholderText: '请输入姓名(2-8字)',
content: this.name,
success: (res) => {
if (!res.confirm) return
const result = validateName(res.content)
if (!result.ok) {
showValidationError(result.message)
return
}
this.name = result.value
}
})
},
rebindPhone() {
uni.showModal({
title: '换绑手机号',
editable: true,
placeholderText: '请输入11位手机号',
content: normalizePhoneForStore(this.phone) || this.phone,
success: (res) => {
if (!res.confirm) return
const result = validatePhoneForRebind(res.content)
if (!result.ok) {
showValidationError(result.message)
return
}
this.phone = result.value
uni.showToast({ title: '手机号已更新', icon: 'success' })
}
})
},
selectGender(gender) {
this.gender = gender
},
onBirthdayChange(e) {
const value = e.detail.value
const [y, m, d] = value.split('-')
const formatted = `${y}${m}${d}`
const result = validateBirthday(formatted)
if (!result.ok) {
showValidationError(result.message)
return
}
this.birthday = result.value
},
editHeight() {
uni.showModal({
title: '修改身高',
editable: true,
placeholderText: '50-250,单位 cm',
content: String(this.height),
success: (res) => {
if (!res.confirm) return
const result = validateHeight(res.content)
if (!result.ok) {
showValidationError(result.message)
return
}
this.height = result.value
}
})
},
editWeight() {
uni.showModal({
title: '修改体重',
editable: true,
placeholderText: '20-300,单位 kg',
content: String(this.weight),
success: (res) => {
if (!res.confirm) return
const result = validateWeight(res.content)
if (!result.ok) {
showValidationError(result.message)
return
}
this.weight = result.value
}
})
},
toggleGoal(goal) {
const index = this.fitnessGoals.indexOf(goal)
if (index >= 0) {
this.fitnessGoals.splice(index, 1)
return
}
const preview = [...this.fitnessGoals, goal]
const result = validateFitnessGoals(preview, this.fitnessGoalOptions)
if (!result.ok) {
showValidationError(result.message)
return
}
this.fitnessGoals.push(goal)
},
isGoalSelected(goal) {
return this.fitnessGoals.includes(goal)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/user-info-page.css';
@import '@/common/style/memberInfo/pages/user-info-pixso.css';
.user-info-row,
.user-info-measure {
border-radius: 8px;
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<template>
<view class="tab-page">
<view class="tab-page__header">
<text class="tab-page__title">训练</text>
<text class="tab-page__subtitle">记录每一次进步</text>
</view>
<view class="train-cards">
<view class="train-card" hover-class="train-card--hover" @tap="goTrainReport">
<text class="train-card__title">训练报告</text>
<text class="train-card__desc">查看本周/本月运动数据与趋势</text>
</view>
<view class="train-card" hover-class="train-card--hover" @tap="goBodyTest">
<text class="train-card__title">智能体测</text>
<text class="train-card__desc">连接设备获取专业体测报告</text>
</view>
<view class="train-card" hover-class="train-card--hover" @tap="goBodyHistory">
<text class="train-card__title">体测报告</text>
<text class="train-card__desc">历史记录与对比分析</text>
</view>
<view class="train-card" hover-class="train-card--hover" @tap="goCheckIn">
<text class="train-card__title">签到记录</text>
<text class="train-card__desc">到店打卡与训练频次</text>
</view>
</view>
<view class="bottom-placeholder"></view>
<TabBar :active="2" />
</view>
</template>
<script setup>
import TabBar from '@/components/TabBar.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
function goTrainReport() {
navigateToPage(PAGE.TRAIN_REPORT)
}
function goBodyTest() {
navigateToPage(PAGE.BODY_TEST_HOME)
}
function goBodyHistory() {
navigateToPage(PAGE.BODY_TEST_HISTORY)
}
function goCheckIn() {
navigateToPage(PAGE.CHECK_IN_HISTORY)
}
</script>
<style lang="scss" scoped>
.tab-page {
min-height: 100vh;
background-color: #f0f4f8;
padding-bottom: 160rpx;
}
.tab-page__header {
padding: 48rpx 32rpx 16rpx;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #1a202c;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.train-cards {
display: flex;
flex-direction: column;
gap: 20rpx;
padding: 24rpx 32rpx;
}
.train-card {
padding: 28rpx 32rpx;
border-radius: 24rpx;
background: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.train-card__title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: #1e2a3a;
}
.train-card__desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.bottom-placeholder {
height: 40rpx;
}
</style>