feat: 新增个人身体画像评估与馆主经营助手

把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-11 22:09:19 +08:00
parent b8c0dd6781
commit ab6602e41d
56 changed files with 4640 additions and 25 deletions

View 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>

View 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>

View 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>

View File

@@ -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',

View File

@@ -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' })
}

View 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>