feat: 新增个人身体画像评估与馆主经营助手
把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
45
packages/app/src/components/BodySilhouette.vue
Normal file
45
packages/app/src/components/BodySilhouette.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<view class="sil">
|
||||
<text class="hint">点选身体区域,可多选</text>
|
||||
<view class="figure">
|
||||
<view class="head zone" :class="{ on: selected.includes(BodyRegion.NECK) }" @tap="toggle(BodyRegion.NECK)">头颈</view>
|
||||
<view class="row">
|
||||
<view class="shoulder zone" :class="{ on: selected.includes(BodyRegion.SHOULDER) }" @tap="toggle(BodyRegion.SHOULDER)">肩</view>
|
||||
</view>
|
||||
<view class="torso zone" :class="{ on: selected.includes(BodyRegion.UPPER_BACK) }" @tap="toggle(BodyRegion.UPPER_BACK)">上背</view>
|
||||
<view class="waist zone" :class="{ on: selected.includes(BodyRegion.LOW_BACK) }" @tap="toggle(BodyRegion.LOW_BACK)">腰</view>
|
||||
<view class="pelvis zone" :class="{ on: selected.includes(BodyRegion.PELVIS) }" @tap="toggle(BodyRegion.PELVIS)">骨盆</view>
|
||||
<view class="row">
|
||||
<view class="hip zone" :class="{ on: selected.includes(BodyRegion.HIP) }" @tap="toggle(BodyRegion.HIP)">髋</view>
|
||||
</view>
|
||||
<view class="row">
|
||||
<view class="knee zone" :class="{ on: selected.includes(BodyRegion.KNEE) }" @tap="toggle(BodyRegion.KNEE)">膝</view>
|
||||
</view>
|
||||
<view class="leg zone" :class="{ on: selected.includes(BodyRegion.LEG) }" @tap="toggle(BodyRegion.LEG)">腿</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BodyRegion } from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{ selected: readonly string[] }>()
|
||||
const emit = defineEmits<{ (e: 'change', value: BodyRegion[]): void }>()
|
||||
|
||||
function toggle(region: BodyRegion) {
|
||||
const current = new Set(props.selected)
|
||||
if (current.has(region)) current.delete(region)
|
||||
else current.add(region)
|
||||
emit('change', [...current] as BodyRegion[])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sil { padding: 12rpx 0 8rpx; }
|
||||
.hint { display: block; text-align: center; color: #8a7b6e; font-size: 22rpx; margin-bottom: 16rpx; }
|
||||
.figure { display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
|
||||
.zone { min-width: 160rpx; padding: 16rpx 28rpx; border-radius: 999rpx; background: #f3eee8; color: #6b5c50; font-size: 24rpx; text-align: center; }
|
||||
.zone.on { background: #6b8276; color: #fff; }
|
||||
.head { min-width: 120rpx; }
|
||||
.row { display: flex; gap: 12rpx; }
|
||||
</style>
|
||||
19
packages/app/src/components/PortraitProgress.vue
Normal file
19
packages/app/src/components/PortraitProgress.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<view class="progress">
|
||||
<view class="bar"><view class="fill" :style="{ width: `${percent}%` }" /></view>
|
||||
<text class="label">{{ step }}/{{ total }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
const props = defineProps<{ step: number; total: number }>()
|
||||
const percent = computed(() => Math.round((props.step / props.total) * 100))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.progress { display: flex; align-items: center; gap: 16rpx; margin: 8rpx 0 24rpx; }
|
||||
.bar { flex: 1; height: 8rpx; background: #efe8e1; border-radius: 8rpx; overflow: hidden; }
|
||||
.fill { height: 100%; background: #6b8276; }
|
||||
.label { font-size: 22rpx; color: #8a7b6e; }
|
||||
</style>
|
||||
68
packages/app/src/components/PortraitRadar.vue
Normal file
68
packages/app/src/components/PortraitRadar.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<canvas canvas-id="portraitRadar" class="radar" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { BODY_DIMENSION_LABELS, type BodyPortraitScores } from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{ scores: BodyPortraitScores }>()
|
||||
const labels = ['肩颈', '脊柱', '核心', '髋骨盆', '下肢']
|
||||
const keys = ['cervicalShoulder', 'spinalMobility', 'coreControl', 'hipPelvis', 'lowerLimb'] as const
|
||||
|
||||
function draw() {
|
||||
const ctx = uni.createCanvasContext('portraitRadar')
|
||||
const w = 280
|
||||
const h = 280
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
const radius = 96
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.setStrokeStyle('#d9cfc5')
|
||||
ctx.setFillStyle('#edf2ec')
|
||||
for (let ring = 1; ring <= 4; ring++) {
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
|
||||
const x = cx + Math.cos(angle) * radius * (ring / 4)
|
||||
const y = cy + Math.sin(angle) * radius * (ring / 4)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.setFillStyle('rgba(107,130,118,0.35)')
|
||||
ctx.setStrokeStyle('#6b8276')
|
||||
ctx.beginPath()
|
||||
keys.forEach((key, i) => {
|
||||
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
|
||||
const value = Math.max(8, props.scores[key]) / 100
|
||||
const x = cx + Math.cos(angle) * radius * value
|
||||
const y = cy + Math.sin(angle) * radius * value
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
ctx.setFillStyle('#5d5148')
|
||||
ctx.setFontSize(11)
|
||||
ctx.setTextAlign('center')
|
||||
labels.forEach((label, i) => {
|
||||
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
|
||||
const x = cx + Math.cos(angle) * (radius + 22)
|
||||
const y = cy + Math.sin(angle) * (radius + 22)
|
||||
ctx.fillText(label, x, y)
|
||||
})
|
||||
ctx.draw()
|
||||
}
|
||||
|
||||
onMounted(draw)
|
||||
watch(() => props.scores, draw, { deep: true })
|
||||
void BODY_DIMENSION_LABELS
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.radar { width: 560rpx; height: 560rpx; margin: 0 auto; }
|
||||
</style>
|
||||
@@ -42,6 +42,8 @@ const emit = defineEmits<{
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
{ key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
|
||||
{ key: 'portrait', type: 'item', title: '身体状态评估', path: '/pages/portrait/index' },
|
||||
{ key: 'plan', type: 'item', title: '我的改善计划', path: '/pages/portrait/plan', requireAuth: true },
|
||||
...(props.isAdmin
|
||||
? [{
|
||||
key: 'teaching-schedule',
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
<template>
|
||||
<view class="quick-entry">
|
||||
<!-- ① Not logged in -->
|
||||
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
|
||||
<text class="pill-label">欢迎来到工作室</text>
|
||||
<view v-if="!userStore.loggedIn || userStore.memberships.length === 0" class="entry-pill pill-login" @tap="handlePortrait">
|
||||
<text class="pill-label">3 分钟了解自己的身体状态</text>
|
||||
<view class="pill-action action-login">
|
||||
<text class="pill-action-text">微信登录</text>
|
||||
<text class="pill-action-text">开始评估</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ② Logged in, no memberships → new user -->
|
||||
<view
|
||||
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
|
||||
class="entry-pill pill-trial"
|
||||
@tap="handleTrialEntry"
|
||||
>
|
||||
|
||||
<text class="pill-label">首次体验专属课程</text>
|
||||
<view class="pill-action action-trial">
|
||||
<text class="pill-action-text">预约体验课</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ③ Has valid active card -->
|
||||
<!-- Has valid active card -->
|
||||
<template v-else-if="userStore.hasValidMembership">
|
||||
<view class="entry-pill pill-active" @tap="handleBooking">
|
||||
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
|
||||
@@ -75,6 +62,10 @@ async function handleLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePortrait() {
|
||||
uni.navigateTo({ url: '/pages/portrait/index' })
|
||||
}
|
||||
|
||||
function handleTrialEntry() {
|
||||
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
|
||||
}
|
||||
|
||||
16
packages/app/src/components/SafetyNotice.vue
Normal file
16
packages/app/src/components/SafetyNotice.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<view class="notice">
|
||||
<text class="title">建议先确认运动条件</text>
|
||||
<text class="body">{{ message }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ message: string }>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notice { margin: 24rpx 0; padding: 28rpx; border-radius: 20rpx; background: #f7eee8; }
|
||||
.title { display: block; font-size: 30rpx; color: #7a4e3e; margin-bottom: 12rpx; }
|
||||
.body { display: block; font-size: 26rpx; line-height: 1.7; color: #8a6458; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user